← 部落格

不用 Action Cable 的 Hotwire:在 Rails 用 Hibira 傳送 Turbo Streams

· Hibira

我們為什麼做 hibira-rails:broadcasts_to 和 turbo_stream_from 照舊,WebSocket 連線交給 Hibira。它換掉的那一個方法、簽章串流如何維持安全、訊息過大時怎麼辦,以及五分鐘的實際操作。

在 Rails 裡做即時更新,最迷人的就是那一行程式碼:

class Message < ApplicationRecord
  broadcasts_to :room
end

再在頁面上放一行 <%= turbo_stream_from @room %>,有人新增一則訊息,所有開著這個聊天室的瀏覽器就會同時多出一行,不用寫任何 JavaScript。這是 Hotwire 讓人愛上 Rails 的地方。

等到要上線時,問題才浮現:這一行的背後是 Action Cable。WebSocket 連線開在你自己的 Puma 裡,跨機器廣播要有 Redis 或 Solid Cable,部署平台有連線數上限,使用者一多,還得把 cable server 拆出去另外擴充。

這篇文章講的是我們做 hibira-rails 的經過:為什麼想做它、它換掉了哪一個點,以及怎麼在五分鐘內把現有的 Rails app 接上去。

那一行背後,其實是一整套伺服器

Action Cable 並不難用,難的是養它。每一位開著頁面的使用者,都占著你應用程式伺服器上的一條長連線。平常這沒什麼感覺,但在流量尖峰、部署重啟或伺服器換機時,這些連線會一起斷、一起重連,而你的 Rails 程序要同時處理一般請求和這波重連。

瀏覽器數千條 WebSocket
長連線
你的 Puma同時處理 HTTP 與 WebSocket
發布
Redis 或 Solid Cable跨機器同步
Redis 或 Solid Cable
轉送
你的 Puma
推送
瀏覽器
用 Action Cable 時,連線和廣播都在你的基礎設施裡

我們做 Hibira 的出發點,就是讓團隊不必自己養這一層。Hibira 是託管的即時推播服務,相容 Pusher Channels 協定,所以 pusher-js 這類現成的函式庫都能直接連上。問題是,Rails 開發者用的不是 pusher-js,而是 broadcasts_to 和 turbo_stream_from。如果要他們為了換服務改寫所有 model 和 view,那就不叫「接上去」,而是「重寫一遍」。

所以我們給自己定了一個目標:model 不改、view 不改,裝好之後,原本那一行就改走 Hibira。

只換一個點

讀過 turbo-rails 的原始碼就會發現,不管你用的是 broadcasts_to、broadcast_append_to、broadcasts_refreshes,還是在背景工作裡跑的 broadcast_replace_later_to,最後全部會走到同一個方法:Turbo::StreamsChannel.broadcast_stream_to。它把渲染好的 Turbo Stream HTML 交給 Action Cable。

這給了我們一個很乾淨的切入點。hibira-rails 只覆寫這一個方法:把同一段 HTML 改成送到 Hibira 的頻道。頁面這一側也一樣,turbo_stream_from 原本會產生 <turbo-cable-stream-source>,現在改產生 <hibira-turbo-stream-source>,由一小段 JavaScript 訂閱頻道,再把收到的內容交給 Turbo。Turbo 本身完全不知道訊息是從哪裡來的。

之前

Modelbroadcasts_to
TurboStreamsChannel
Action Cable你的 Puma 與 Redis
WebSocket
瀏覽器

之後

Modelbroadcasts_to
TurboStreamsChannel
HTTPS 發布一次
Hibira扇出
WebSocket
瀏覽器
裝了 hibira-rails 之後,同一行 broadcasts_to 改走 Hibira

你的 Rails 程序從此只做它擅長的事:處理請求、渲染 HTML、發布一次。WebSocket 連線、重連和扇出都交給 Hibira。

安全規則不變:拿得到簽章,才訂閱得到

換掉傳輸層,最怕的是不小心把權限也換掉了。Action Cable 的規則很簡單:turbo_stream_from 會把串流名稱加上簽章放進頁面,伺服器只接受簽章正確的訂閱。能看到這個頁面的人,才拿得到這個簽章。

我們保留了一模一樣的規則。每一個 Turbo 串流對應 Hibira 上的一個私有頻道,頻道名稱由串流名稱雜湊而來。瀏覽器要訂閱時,必須帶著頁面上那個簽章回到你的 Rails app 換取授權;簽章不對,或頻道和串流對不上,就拒絕。

瀏覽器你的 Rails appHibira頁面含簽章後的串流名稱turbo_stream_from連線取得 socket_idPOST /hibira/turbo/authsocket_id、頻道、簽章授權簽章用 app secret 簽,secret 不離開伺服器subscribeprivate-turbo-…broadcasts_to 觸發一則 turbo-stream 事件turbo-stream交給 Turbo 更新畫面
訂閱私有頻道時,簽章由你的 Rails app 驗證

app secret 只存在你的伺服器上,瀏覽器拿到的只有公開的 app key。

跑一遍:五分鐘接上現有的 app

我們用一個全新的 Rails 8.1 專案示範,而且建立時刻意加上 --skip-action-cable,證明整個過程用不到 Action Cable 伺服器。

先安裝 gem,再執行安裝程式:

bundle add hibira-rails
bin/rails generate hibira:install

安裝程式會建立 config/initializers/hibira.rb、用 importmap 釘選 pusher-js(jsbundling 的專案則用你的套件管理工具安裝),並在 app/javascript/application.js 加上一行 import "hibira/turbo"。

接著把 Hibira 後台 app 頁面上的三個值放進環境變數,或是 bin/rails credentials:edit 裡的 hibira::

HIBIRA_APP_ID=your-app-id
HIBIRA_APP_KEY=your-app-key
HIBIRA_APP_SECRET=your-app-secret

這裡有兩件事最容易卡住。secret 只會在建立應用程式後顯示一次,當下就要存好;app ID 和 key 之後在應用程式頁面的總覽 → 憑證都找得到。另外,免費方案的允許的來源不能用 *,要把頁面所在的每個來源都加進去,例如開發時的 http://localhost:3000 和正式網域,否則瀏覽器連線時會被拒絕。

然後檢查一次:

$ bin/rails hibira:check
ok    App 1 on ws.hibira.io:443
ok    Credentials accepted by the REST API
ok    Turbo Streams go through Hibira
info  Push notifications off (set HIBIRA_PUSH_INSTANCE_ID and HIBIRA_PUSH_SECRET_KEY)

model 和 view 就是 Turbo 手冊上的寫法,一個字都沒改。打開兩個視窗,在其中一個送出訊息,另一個不用重新整理就會出現:

兩個瀏覽器視窗:在視窗 B 送出的訊息,立刻出現在視窗 A,中間沒有 Action Cable。
兩個瀏覽器視窗:在視窗 B 送出的訊息,立刻出現在視窗 A,中間沒有 Action Cable。

換頁之後也一樣。Turbo 換頁時,舊頁面的訂閱會先保留一下,如果新頁面訂閱的是同一個串流,就直接沿用,不會斷線重連。

測試不會壞

把廣播改送到外部服務,最容易出事的是測試:每跑一次測試就打一次網路,或是 Turbo 內建的 assert_turbo_stream_broadcasts 突然失效。

所以預設值是 turbo = :auto:只要設定了金鑰就改走 Hibira,但在 test 環境裡維持 Action Cable 的行為,你原本的廣播斷言照常通過,CI 也不會連到外部。想要強制開或關,設成 true 或 false 就好。

訊息太大的時候

這是我們做的過程中,最需要想清楚的一件事。Turbo Stream 送的是渲染好的 HTML,一張商品卡片可能幾百個位元組,一整段表格就可能好幾 KB。而每個方案都有單則事件的大小上限,Free 方案是 4 KB。

超過上限時,Hibira 會拒絕那則事件。如果什麼都不做,結果就是其他使用者的畫面悄悄不更新了,這是最糟的情況。

我們的做法是:遇到過大的廣播,自動改送一則 Turbo 8 的 page refresh。這一則只有幾十個位元組,收到的頁面會自己重新抓一次最新內容。畫面不會像 append 那樣只多出一行,但一定會是對的。

Modelbroadcast_append_to
12 KB 的 HTML
Hibira超過上限,拒絕
Hibira
回報過大
hibira-rails改送 refresh
hibira-rails
幾十個位元組
Hibira
瀏覽器重新抓取頁面,畫面一定是對的
廣播超過事件上限時,自動改送 page refresh

如果你的頁面本來就是長列表,我們更建議直接用 Turbo 8 的 broadcasts_refreshes,搭配 turbo_refreshes_with method: :morph:每一則廣播都很小,畫面用 morph 更新,捲動位置也不會跳。不想要自動 refresh 的話,也可以設成直接記錄後丟棄,或丟出例外。

不只 Turbo

既然已經把 Rails 和 Hibira 接起來了,我們順便把幾件常見的事一起做好:

  • 你自己的私有與 presence 頻道:設定 authorize_channel,在裡面用 current_user 決定誰能訂閱哪個頻道,presence 頻道就回傳使用者資料。它繼承你的 ApplicationController,登入狀態和 CSRF 保護照舊。
  • Webhook:在 /hibira/webhooks 收 Hibira 的 webhook,自動驗證簽章。輪替 secret 的寬限期內,用舊 secret 簽的也照收,不會因為還沒重新部署就漏掉事件。
  • 推播通知:Hibira.push 發送 iOS、Android 和瀏覽器推播,/hibira/push/token 發使用者 token 給 SDK 用。

支援範圍

  • Rails 7.0 到 8.1,Ruby 3.1 以上,turbo-rails 1.5 到 2。每一個組合都有跑 CI。
  • importmap 和 jsbundling 都能用(Propshaft 的 assets:precompile 已驗證)。
  • 建立時用了 --skip-action-cable 的專案也能用。

hibira-rails 已經發布在 RubyGems,原始碼以 MIT 授權放在 GitHub。如果你有一個靠 Action Cable 撐著的 Hotwire app,歡迎試試看:註冊 Hibira 的免費方案不需要信用卡,裝好之後,那一行 broadcasts_to 就不用再由你的伺服器扛了。

我們還在為其他框架和引擎做類似的整合,下一個是誰,我們下次再揭露,敬請期待。