不用 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 进程要同时处理普通请求和这波重连。
我们做 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 本身完全不知道消息是从哪里来的。
之前
之后
你的 Rails 进程从此只做它擅长的事:处理请求、渲染 HTML、发布一次。WebSocket 连接、重连和扇出都交给 Hibira。
安全规则不变:拿得到签名,才订阅得到
换掉传输层,最怕的是不小心把权限也换掉了。Action Cable 的规则很简单:turbo_stream_from 会把流名称加上签名放进页面,服务器只接受签名正确的订阅。能看到这个页面的人,才拿得到这个签名。
我们保留了一模一样的规则。每一个 Turbo 流对应 Hibira 上的一个私有频道,频道名称由流名称哈希而来。浏览器要订阅时,必须带着页面上那个签名回到你的 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 手册上的写法,一个字都没改。打开两个窗口,在其中一个发送消息,另一个不用刷新就会出现:

切换页面之后也一样。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 那样只多出一行,但一定是对的。
如果你的页面本来就是长列表,我们更建议直接用 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 就不用再由你的服务器扛了。
我们还在为其他框架和引擎做类似的集成,下一个是谁,我们下次再揭晓,敬请期待。