ブロードキャストがページに届くまで
ページは名前の付いたストリームを 1 つ以上購読し、アプリケーションのどこからでも名前を指定してストリームにブロードキャストでき、購読しているページはすべて約 10 分の 1 秒以内にそのメッセージを受け取ります。
- ストリームには名前がある。 名前は短いテキストなら何でもよく(
"alerts")、レコードを渡すとそのテーブルと id が名前になります("articles:12"、Cybertrain::Stream.name_for(@article))。 - ブロードキャストは 1 行。
Cybertrain::Stream.broadcast(name, payload)は、フレームワークがアプリケーションのデータベースに自分で作るテーブルcybertrain_messagesに行を挿入します。DB.transactionの中では、トランザクションがコミットされたときだけメッセージが送られます。行なので、ブロードキャストはデータベースを共有するどのプロセスからでも動きます:リクエスト、workerプロセスのジョブ、フィードのコールバック、Main.command。 - 各 Web プロセスがテーブルをポーリングする。
serverプロセスごとに 1 本のスレッド(ストリームのポーラー)がstream_poll_ms(100 ms)ごとに新しい行を読み、そのプロセスが抱えるブラウザーにそれぞれ渡します。1 分に 1 回、stream_retention(60 秒)より古い行を削除します。 - ブラウザーは 1 つのエンドポイントにつなぐ。
/cybertrain/streamに Server-Sent Events(EventSource)か WebSocket で、読んでよいストリームを示す署名付きトークンを添えてつなぎます(署名付きストリーム名)。
起動するものはありません:./<app> server がポーラーを動かし、エンドポイントを提供します。config.stream_path = "" で機能全体が無効になります。
Turbo Streams
Turbo は、Rails 8 のアプリケーションと同じように、<turbo-stream action="append" target="comments"> 要素からページを更新します。レイアウトで 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 とレコードをいくつでも受け取り、そのすべてに 1 つのトークンを持つ URL の <turbo-stream-source> 要素を 1 つ書き出します。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
パーシャルは 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 に入ります(JSON は String としてブロードキャストします: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"} のエンベロープを持つテキストフレーム 1 つで、ページは接続中に JSON のテキストフレームを送ってストリームを変えられます:
| ページが送るもの | サーバーの応答 |
|---|---|
{"subscribe":"<token>"} |
{"subscribed":["prices","alerts"]}:接続が今読んでいるすべてのストリーム |
{"unsubscribe":"<token>"} |
{"unsubscribed":["alerts"]} |
| 検証できないトークン | {"error":"bad token"} |
| それ以外 | {"error":"bad message"} |
mode=json の接続は、トークンなしで開いてメッセージで購読することもできます。15 秒たってもストリームを 1 つも読んでいない接続は閉じられます(コード 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) はイベントを 1 つ整形します。クライアントが去ると out.closed? が true になり、それ以降の書き込みは何もしません。ブロックはセッション、flash、ヘッダーに触れてはいけません:それらはレスポンスの先頭と一緒に送られています。
署名付きストリーム名
ページがストリームを読めるのは、それを名指すトークンがあるときだけです。トークンはストリーム名と、secret_key_base からストリーム専用に導出した鍵で作った HMAC-SHA256 の署名からなります(Rails の turbo/signed_stream_verifier_key と同じです)。そのためトークンがセッションクッキーとして通ることはありません。エンドポイントは署名を確かめ、セッションもデータベースも読みません。これは Turbo のモデルです:ページをレンダリングできた人は、そのストリームを読めます。トークンに期限はありません。secret_key_base を変えるとすべてのトークンが無効になり、そのデプロイをまたいで開いていたページは再読み込みされるまで受信が止まります(EventSource は 403 を最終的なものとして扱います)。
- 1 人のユーザーのためのストリームは、そのユーザーのレコードにちなんで名付けたストリーム
turbo_stream_from @user("users:7")で、そのユーザーのページにだけレンダリングします。トークンを手に入れた人は誰でも、シークレットが変わらない限りそのストリームを読めます。 - トークンに期限はありません。
CYBERTRAIN_SECRET_KEY_BASEを変えると、すべてのトークン(とすべてのセッション)が無効になります。 - トークンは URL に載るので、リバースプロキシのアクセスログに記録されます。フレームワーク自身のリクエストログは
[FILTERED]と書きます。/cybertrain/streamについてはプロキシのログを止めるか、そのログをストリームと同じくらい非公開にしてください。 - トークンが不正か欠けていると、SSE では 403、WebSocket ではハンドシェイク直後の 1008 によるクローズになります。
- 名前は 1〜200 バイトで、制御文字とカンマを含みません。トークン 1 つには最大 16 個、接続 1 つには最大 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 までの無音の秒数。クライアントへの 1 回の書き込みが待てる時間でもあります |
stream_max_connections |
1000 |
プロセスあたりの開いたストリームの数。超えると SSE は 503、WebSocket は 1013 で閉じられます |
stream_max_message |
65536 |
ページからの WebSocket フレーム 1 つのバイト数(超えると 1009 で閉じます) |
stream_max_backlog |
1000 |
遅いクライアント 1 つのために待つメッセージ数。超えると切り離されます(再接続して再送を受けます) |
開いたストリームは、ページが開いている間、接続 1 つとそのグリーンスレッドを占有します。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 を提供せず、ポーラーも動かしません。そのブロードキャストは行であり、サーバーが配信します(プロセスの役割)。- 1 つのデータベースを共有する複数のサーバーは、それぞれ自分のブラウザーにすべてのメッセージを、同じ順序・同じ id で配信します。
ページのストリームのテスト:ストリームとコンポーネントのテスト。設計:docs/design.md D36 と D37。
フィードのヘルス
WebSocket のフィードは、HTTP を提供しない feeds のプロセスで動くことが多いので、そのヘルスはデータベースを通じて伝わります。フィードを動かす各プロセスの監視スレッドが、feed_status_interval 秒(10)ごとに、購読ごとに 1 行を cybertrain_feeds に書きます。このテーブルはフレームワークのものです(最初の使用時に作られ、db/schema.rb には含めません)。server のプロセスの GET /up はこれを読み、どれかの行について次のどちらかが成り立つと 503 feed stale を返します:
- 書き手が古い:行が最後に書かれてから
health_feed_stale秒(180、CYBERTRAIN_HEALTH_FEED_STALE)より長くたっている。つまり、フィードを動かすプロセスが死んだか、止まったか、データベースを失った。または、 - フィードが黙っている:行は新しいが、フィードがその間メッセージを届けず、ping にも答えていない(何も受け取ったことのないフィードは開始時から数えます)。
2 つは別々に確かめられるので、生きている書き手が黙ったフィードを隠すことはありません。数えるのは、アプリケーションがいまも購読しているフィードの行だけです。どの役割も同じバイナリを動かすので、どの subscribe も登録していない名前の行(消したり名前を変えたりしたフィード)は無視されます。health_feed_stale は feed_status_interval より大きくなければなりません。そうでないと、2 回の書き込みの間にすべての行が古くなるからです。起動時にそれ以外は拒否されます。CYBERTRAIN_HEALTH_FEEDS=0(または false)はこの確認を止めます。しきい値より長く静かなのが正常なフィードや、別の方法でフィードを見ているデプロイのためです。テーブルが読めないとき、/up は 200 のままで、読めるようになるまで [WARN] の行を 1 つだけ出します。
./<app> feeds status は、運用する人のために行をフィードごとに 1 行で表示します:
./blog feeds status
trades connected web-1:4242 last activity 3s ago restarts 0 updated 4s ago
- 行のキーはフィードの名前だけです。同じフィードを動かす 2 つのプロセスは互いの行を上書きします。各フィードを 1 つのプロセスで動かす理由がまた 1 つ増えます。
- きれいに止められた
feedsのプロセス(TERM か INT:デプロイ、台数の削減)は、自分が書いた行を消します。わざと止めた役割は数えられなくなります。クラッシュしたプロセスや、フィードが何度も死んだ後で終了したプロセスは行を残し、それが古くなります。/upが報告するのはまさにそれです。 - 消したり名前を変えたりしたフィードの行は残りますが、
/upはそれを無視し、feeds statusは(not registered)と印を付けます。好きなときに消してください(./<app> dbconsoleでDELETE FROM cybertrain_feeds WHERE name = 'trades';)。 statusはフィードの名前に使えません。./<app> feeds statusがテーブルを表示するので、subscribe("status", ...)は ArgumentError を投げます。/upは何も再起動しません。Kamal がそれを見るのはデプロイ中の web の役割だけで、Docker はプロセスが終了したときにコンテナを再起動します。feedsのプロセスは、フィードが何度も死んだ後で終了します。その役割に再起動ポリシーを与えてください(プロセスの役割)。
設計:docs/design.md D56。