← 博客

不用 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 应用接上去。

那一行背后,其实是一整套服务器

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 应用换取授权;签名不对,或频道和流对不上,就拒绝。

浏览器你的 Rails 应用Hibira页面含签名后的流名称turbo_stream_from连接取得 socket_idPOST /hibira/turbo/authsocket_id、频道、签名授权签名用 app secret 签,secret 不离开服务器subscribeprivate-turbo-…broadcasts_to 触发一条 turbo-stream 事件turbo-stream交给 Turbo 更新页面
订阅私有频道时,签名由你的 Rails 应用验证

app secret 只存在你的服务器上,浏览器拿到的只有公开的 app key。

跑一遍:五分钟接上现有的应用

我们用一个全新的 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 后台应用页面上的三个值放进环境变量,或是 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 给 SDK 发放用户 token。

支持范围

  • 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 应用,欢迎试试看:注册 Hibira 的免费套餐不需要信用卡,装好之后,那一行 broadcasts_to 就不用再由你的服务器扛了。

我们还在为其他框架和引擎做类似的集成,下一个是谁,我们下次再揭晓,敬请期待。