广播如何到达页面
页面订阅一个或多个具名的流;应用中的任何部分都可以按名称向某个流广播;每个订阅了该流的页面大约在十分之一秒内收到消息。
- 流有名称。 名称可以是任意简短的文本(
"alerts"),也可以是一条记录,其名称是它的表名和 id("articles:12",Cybertrain::Stream.name_for(@article))。 - 一次广播就是一行。
Cybertrain::Stream.broadcast(name, payload)向cybertrain_messages插入一行,这张表由框架自己在应用的数据库中创建。在DB.transaction内部,只有事务提交时消息才会发出。因为它是一行,所以任何共享该数据库的进程都可以广播:请求、worker进程中的作业、订阅回调、Main.command。 - 每个 Web 进程轮询这张表。 每个
server进程有一个线程(流轮询器),每stream_poll_ms(100 毫秒)读取新行,并把每一行交给该进程持有的浏览器。它每分钟删除一次早于stream_retention(60 秒)的行。 - 浏览器连接到同一个端点
/cybertrain/stream,通过 Server-Sent Events(EventSource)或 WebSocket,并带上一个签名令牌,令牌指明它可以读取哪些流(签名的流名称)。
不需要启动任何东西:./<app> server 会运行轮询器并提供该端点。config.stream_path = "" 关闭整个功能。
Turbo Streams
Turbo 根据 <turbo-stream action="append" target="comments"> 元素更新页面,就像 Rails 8 应用那样。在布局中加载 Turbo(没有资源管道:使用 CDN 或放在 public/ 中的副本),然后用 turbo_stream_from 让页面订阅:
<head>
<%= csrf_meta_tags %>
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@8/dist/turbo.es2017-esm.js"></script>
</head>
<%= turbo_stream_from @article %>
<div id="comments">
<% @article.comments.each do |comment| %>
<%= render "comments/comment", comment: comment %>
<% end %>
</div>
turbo_stream_from 接受任意数量的 String 和记录,并输出一个 <turbo-stream-source> 元素,其 URL 为它们全部携带一个令牌。Turbo 把它作为 EventSource 打开;turbo_stream_from @article, ws: true 则改为给出一个 ws:// URL(在 HTTPS 之后为 wss://),Turbo 把它作为 WebSocket 打开。无论哪种方式,页面都不需要你写任何 JavaScript。不过 Turbo 不会重新打开丢失的 WebSocket(网络中断、服务器重启):那个页面在重新加载之前收不到更多更新,而 EventSource 会自行重连并重放错过的内容。除非确实需要 WebSocket,否则请保留默认方式,或者用你自己的 JavaScript 重连(见下文的 &after=)。
然后在改变了数据的动作中广播:
def create
@comment = Comment.new(comment_params)
@comment.article_id = @article.id
if @comment.save
Cybertrain::Stream.broadcast_append_to(@article, target: "comments",
partial: "comments/comment", locals: { comment: @comment })
end
redirect_to article_path(@article), status: :see_other
end
broadcast_append_to(stream, target:, ...) 渲染内容,把它包进一个 <turbo-stream> 元素并广播出去。内容是以下之一:带 locals: 的 partial:(由 Cybertrain::Views.render_partial 渲染)、component:(一个视图组件),或 html:(除非是 SafeString,否则会被转义)。目标是一个 DOM id,或一条记录,其 DOM id 为 dom_id(record)("comment_7")。其他方法用法相同:broadcast_replace_to、broadcast_update_to、broadcast_prepend_to、broadcast_before_to、broadcast_after_to、broadcast_remove_to(stream, target:) 和 broadcast_refresh_to(stream)(Turbo 8 以变形方式重新加载页面)。
Turbo 还会把链接和表单变成 fetch 请求(Turbo Drive)。scaffold 已经按 Turbo 期望的方式响应:表单成功后以 303 重定向,失败后以 422 重新显示表单。
用 Turbo Stream 响应表单
Turbo 提交的表单接受 text/vnd.turbo-stream.html。要用流而不是重定向来响应,就自己渲染这个元素;Cybertrain::Turbo 负责构建它们(replace、update、append、prepend、before、after、remove、refresh):
def destroy
@comment = Comment.where(article_id: @article.id).find(params[:id])
@comment.destroy
if request.header("accept").to_s.include?("text/vnd.turbo-stream.html")
render html: Cybertrain::Turbo.remove(@comment), content_type: "text/vnd.turbo-stream.html"
else
redirect_to article_path(@article), status: :see_other
end
end
从作业、定时任务或数据订阅中广播
广播不需要请求,因此作业可以渲染并发送一条广播:
Cybertrain::Job.handle("price_refresh") do |payload|
item = Item.find(Cybertrain::Cast.int(payload))
item.refresh_price!
Cybertrain::Stream.broadcast_replace_to("prices", target: item,
partial: "items/price", locals: { item: item })
end
partial 由 Cybertrain::Views.render_partial 渲染,它在没有请求的情况下渲染:路径辅助方法(item_path(item))可以使用,而需要请求的辅助方法(csrf_token、params、flash、request_path、*_url)会抛出 Cybertrain::Views::OutsideRequest(参见在请求之外渲染)。作业既在服务器中运行,也在 worker 进程中运行,它写入的行会到达所有服务器的页面。
在 JavaScript 中使用 Server-Sent Events
对于不由 Turbo 驱动的页面(图表、行情条),以 JSON 方式请求流。stream_source_path 给出带令牌和 mode=json 的端点路径,signed_stream_name 只给出令牌:
<div id="price" data-src="<%= stream_source_path "prices", "alerts" %>"></div>
<script type="module">
const el = document.getElementById("price");
const source = new EventSource(el.dataset.src);
source.addEventListener("prices", (event) => {
const message = JSON.parse(event.data); // {"stream":"prices","id":42,"data":"..."}
el.textContent = JSON.parse(message.data).price;
});
source.addEventListener("alerts", (event) => console.log(JSON.parse(event.data).data));
</script>
在 mode=json 下,每条消息都是一个以其流命名的事件,其数据为 {"stream": ..., "id": ..., "data": ...},负载放在 data 中(以 String 的形式广播 JSON:Stream.broadcast("prices", JSON.generate({ "price" => 12.5 })))。在默认的 mode=raw 下,负载原样作为普通的 message 事件发出,这正是 Turbo 读取的内容。
连接断开时浏览器会自动重连,并发送它看到的最后一个事件的 id(Last-Event-ID)。端点随后会先重发这些流中 id 更大、且仍在表中(在 stream_retention 之内)的所有消息,因此中间的内容不会丢失。离开时间更长的页面应当重新加载。在第一个事件到达之前,浏览器没有可发送的 id,所以在这段窗口内断开的连接不会得到任何重放:页面加载时渲染的内容就是它在下一次广播到达之前的状态。静默 15 秒后会发出一行注释(: ping),以便代理保持连接打开。
在 JavaScript 中使用 WebSocket
同一个 URL 也响应 WebSocket。页面由它构造出一个 ws:// 或 wss:// URL。请像这里一样通过 data- 属性传递路径和令牌:<script> 元素中的 <%= %> 会被 HTML 转义,而浏览器在那里不会解码它(服务器会收到 &)。
<div id="ticker" data-src="<%= stream_source_path "prices" %>" data-alerts="<%= signed_stream_name "alerts" %>"></div>
<script type="module">
const el = document.getElementById("ticker");
const scheme = location.protocol === "https:" ? "wss://" : "ws://";
const socket = new WebSocket(scheme + location.host + el.dataset.src);
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
if (message.stream) console.log(message.stream, message.id, message.data);
});
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ subscribe: el.dataset.alerts }));
});
</script>
在 mode=json 下,每条消息是一个文本帧,包含同样的 {"stream", "id", "data"} 信封,页面可以在连接期间发送 JSON 文本帧来更改它的流:
| 页面发送 | 服务器回答 |
|---|---|
{"subscribe":"<token>"} |
{"subscribed":["prices","alerts"]}:该连接现在读取的所有流 |
{"unsubscribe":"<token>"} |
{"unsubscribed":["alerts"]} |
| 无法验证的令牌 | {"error":"bad token"} |
| 其他任何内容 | {"error":"bad message"} |
mode=json 的连接也可以不带令牌打开,然后通过消息订阅;15 秒后仍未读取任何流的连接会被关闭(代码 1008)。WebSocket 没有 Last-Event-ID:要在重连后补上错过的内容,请在 URL 上加上 &after=<the last id you saw>。服务器在每次静默 15 秒后发送 ping,并回应 ping;来自页面的二进制帧会被忽略。关闭代码:服务器停止时为 1000,令牌无效时为 1008,帧超过 stream_max_message 时为 1009,进程已满或页面落后太多时为 1013。
从动作中流式输出
动作可以让自己的响应保持打开,就像 Rails 的 ActionController::Live 那样:stream 在动作之后、在连接的线程上运行这个块,并把套接字作为 Cybertrain::Sink 交给它。
def progress
stream do |out|
10.times do |i|
out.write(Cybertrain::SSE.event(JSON.generate({ "done" => i * 10 }), event: "progress", id: i.to_s))
break if out.wait_closed(1) # 客户端离开时为 true
end
end
end
stream(content_type: "text/event-stream", status: :ok) 以 Transfer-Encoding: chunked 响应,并在块返回时关闭连接。SSE.event(data, event: "", id: "", retry_ms: 0) 格式化一个事件;客户端离开后 out.closed? 变为 true,之后的写入什么也不做。块不能再碰会话、flash 或响应头:它们已经随响应头部一起发出。
签名的流名称
页面只有持有指明某个流的令牌,才能读取该流。令牌由流名称和一个 HMAC-SHA256 签名组成,签名的密钥是从 secret_key_base 专为流派生出来的(如同 Rails 的 turbo/signed_stream_verifier_key),因此令牌绝不会是有效的会话 cookie;端点检查签名,既不读取会话,也不读取数据库。这就是 Turbo 的模型:能够渲染该页面的人就能读取它的流。令牌不会过期;更改 secret_key_base 会使所有令牌失效,跨越这次部署仍然打开的页面在重新加载之前收不到更新(EventSource 把 403 视为最终结果)。
- 专属于某个用户的流,是以该用户的记录命名的流
turbo_stream_from @user("users:7"),只在该用户的页面上渲染。任何拿到该令牌的人,只要密钥不变,都能读取这个流。 - 令牌不会过期。更改
CYBERTRAIN_SECRET_KEY_BASE会使所有令牌(以及所有会话)失效。 - 令牌放在 URL 中传递,因此反向代理的访问日志会记录它。框架自己的请求日志把它写成
[FILTERED];请为/cybertrain/stream关闭代理的日志,或者让这些日志与流本身同样保密。 - 无效或缺失的令牌在 SSE 下是 403,在 WebSocket 下则是握手之后立即以 1008 关闭。
- 名称为 1 到 200 字节,不含控制字符和逗号;一个令牌最多携带 16 个名称,一个连接最多 64 个。
限制与设置
在 config/app.rb 中设置(c.stream_poll_ms = 250);值超出范围时,启动会给出消息并停止。
| 设置 | 默认值 | 作用 |
|---|---|---|
stream_path |
"/cybertrain/stream"(CYBERTRAIN_STREAM_PATH) |
端点的路径;"" 关闭流 |
stream_poll_ms |
100 |
每个服务器进程读取新消息的频率(至少 10) |
stream_retention |
60 |
消息在表中保留的秒数,也就是重连最多能补回多久 |
stream_ping_interval |
15 |
发送 ping 之前的静默秒数;也是对客户端的一次写入最多可以等待的时间 |
stream_max_connections |
1000 |
每个进程打开的流数量;超过后 SSE 得到 503,WebSocket 以 1013 关闭 |
stream_max_message |
65536 |
来自页面的单个 WebSocket 帧的字节数(超过则以 1009 关闭) |
stream_max_backlog |
1000 |
为一个慢客户端积压的消息数,超过后将其断开(它会重连并获得重发) |
每个打开的流在页面打开期间占用一个连接及其绿色线程。在 nginx 之后,响应的 X-Accel-Buffering: no 头会关闭缓冲;对于 WebSocket,代理必须传递 Upgrade 和 Connection(Caddy 和 kamal-proxy 都会)。
哪个进程做什么
./<app> server总是运行轮询器和端点,在CYBERTRAIN_WORKER=0时也是如此:它向自己持有的浏览器投递消息。在轮询器运行之前,端点回答 503(stream poller is not running);serve会在打开端口之前启动它,所以提供服务的进程从不会出现这个回答。./<app> worker和./<app> feeds不提供 HTTP,也不运行轮询器。它们的广播是行,由服务器投递(进程角色)。- 共享一个数据库的多个服务器,各自把每条消息以相同的顺序和相同的 id 投递给自己的浏览器。
测试页面的流:测试流和组件。设计:docs/design.md D36 和 D37。
数据订阅的健康状况
WebSocket 数据订阅通常运行在不提供 HTTP 的 feeds 进程中,所以它的健康状况通过数据库传递:每个运行数据订阅的进程的监督线程,每隔 feed_status_interval 秒(10)为每个订阅向 cybertrain_feeds 写入一行。这张表属于框架(在首次使用时创建,不写入 db/schema.rb)。server 进程中的 GET /up 会读取它,当任何一行满足以下条件时返回 503 feed stale:
- 发布者过期:这一行最后一次写入是在
health_feed_stale秒(180,CYBERTRAIN_HEALTH_FEED_STALE)之前,说明运行该订阅的进程已死掉、卡住或失去了数据库;或者 - 订阅陷入沉默:这一行是新的,但订阅在这么长的时间里既没有送来消息,也没有回应 ping(从未收到过任何东西的订阅从它启动时算起)。
两者分别检查,所以活着的发布者永远不会掩盖一个沉默的订阅。只有应用仍在订阅的数据订阅的行才计入:每个角色运行同一个二进制文件,所以名称没有被任何 subscribe 注册的行(你删除或改名的订阅)会被忽略。health_feed_stale 必须大于 feed_status_interval,否则每一行在两次写入之间都会过期,启动时会拒绝这样的设置。CYBERTRAIN_HEALTH_FEEDS=0(或 false)会关闭这项检查,适用于合理地安静超过阈值的订阅,或用其他方式监视订阅的部署。无法读取这张表时,/up 仍返回 200,并只记录一条 [WARN],直到它再次可读。
./<app> feeds status 为运维人员打印这些行,每个订阅一行:
./blog feeds status
trades connected web-1:4242 last activity 3s ago restarts 0 updated 4s ago
- 每一行只以订阅的名称为键:运行同一个订阅的两个进程会互相覆盖对方的行,这也是让每个订阅只在一个进程中运行的又一个理由。
- 被干净地停止的
feeds进程(TERM 或 INT:一次部署、一次缩容)会删除它写入的行,所以你有意停止的角色不再被计入。崩溃的进程,或在订阅反复死掉之后退出的进程,会留下这些行让它们过期,而这正是/up要报告的。 - 你删除或改名的订阅会留下它的行,
/up会忽略它,feeds status会把它标记为(not registered);你可以随时删除它(./<app> dbconsole,然后DELETE FROM cybertrain_feeds WHERE name = 'trades';)。 status不能用作订阅名称:subscribe("status", ...)会抛出 ArgumentError,因为./<app> feeds status会打印这张表。/up不会重启任何东西。Kamal 只在部署期间为 web 角色轮询它,而 Docker 在进程退出时重启容器,feeds进程在订阅反复死掉之后就会退出;请给这个角色设置重启策略(进程角色)。
设计说明:docs/design.md D56。