# 不用 Action Cable 的 Hotwire：在 Rails 用 Hibira 传送 Turbo Streams

> 我们为什么做 hibira-rails：broadcasts_to 和 turbo_stream_from 照旧，WebSocket 连接交给 Hibira。它换掉的那一个方法、签名流如何保持安全、消息过大时怎么办，以及五分钟的实际操作。

Source: https://hibira.io/zh-cn/blog/rails-turbo-streams
Updated: 2026-11-10

在 Rails 里做实时更新，最迷人的就是那一行代码：

```ruby
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 进程要同时处理普通请求和这波重连。

```text
flow: 用 Action Cable 时，连接和广播都在你的基础设施里
浏览器|数千条 WebSocket --长连接--> 你的 Puma|同时处理 HTTP 与 WebSocket --发布--> Redis 或 Solid Cable|跨机器同步
Redis 或 Solid Cable --转发--> 你的 Puma --推送--> 浏览器
```

我们做 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 本身完全不知道消息是从哪里来的。

```text
flow: 装了 hibira-rails 之后，同一行 broadcasts_to 改走 Hibira
== 之前
Model|broadcasts_to --> Turbo|StreamsChannel --> Action Cable|你的 Puma 与 Redis --WebSocket--> 浏览器
== 之后
Model|broadcasts_to --> Turbo|StreamsChannel --HTTPS 发布一次--> *Hibira|扇出 --WebSocket--> 浏览器
```

你的 Rails 进程从此只做它擅长的事：处理请求、渲染 HTML、发布一次。WebSocket 连接、重连和扇出都交给 Hibira。

## 安全规则不变：拿得到签名，才订阅得到

换掉传输层，最怕的是不小心把权限也换掉了。Action Cable 的规则很简单：`turbo_stream_from` 会把流名称加上签名放进页面，服务器只接受签名正确的订阅。能看到这个页面的人，才拿得到这个签名。

我们保留了一模一样的规则。每一个 Turbo 流对应 Hibira 上的一个私有频道，频道名称由流名称哈希而来。浏览器要订阅时，必须带着页面上那个签名回到你的 Rails 应用换取授权；签名不对，或频道和流对不上，就拒绝。

```text
sequence: 订阅私有频道时，签名由你的 Rails 应用验证
浏览器 | 你的 Rails 应用 | *Hibira
2 >> 1: 页面含签名后的流名称 | turbo_stream_from
1 > 3: 连接 | 取得 socket_id
1 > 2: POST /hibira/turbo/auth | socket_id、频道、签名
2 >> 1: 授权签名 | 用 app secret 签，secret 不离开服务器
1 > 3: subscribe | private-turbo-…
2 > 3: broadcasts_to 触发 | 一条 turbo-stream 事件
3 > 1: turbo-stream | 交给 Turbo 更新页面
```

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

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

我们用一个全新的 Rails 8.1 项目演示，而且创建时特意加上 `--skip-action-cable`，证明整个过程用不到 Action Cable 服务器。

先安装 gem，再运行安装程序：

```sh
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:`：

```sh
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` 和正式域名，否则浏览器连接时会被拒绝。

然后检查一次：

```text
$ 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。](https://hibira.io/portal/articles/rails-turbo-two-windows-zh-CN.webp)

切换页面之后也一样。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 那样只多出一行，但一定是对的。

```text
flow: 广播超过事件上限时，自动改发 page refresh
Model|broadcast_append_to --12 KB 的 HTML--> *Hibira|超过上限，拒绝
*Hibira --报告过大--> hibira-rails|改发 refresh
hibira-rails --几十个字节--> *Hibira --> 浏览器|重新获取页面，内容一定是对的
```

如果你的页面本来就是长列表，我们更建议直接用 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](https://rubygems.org/gems/hibira-rails)，源码以 MIT 许可证放在 [GitHub](https://github.com/hibira-io/rails)。如果你有一个靠 Action Cable 撑着的 Hotwire 应用，欢迎试试看：注册 [Hibira](https://hibira.io) 的免费套餐不需要信用卡，装好之后，那一行 `broadcasts_to` 就不用再由你的服务器扛了。

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