ライブレンダリング
非同期レンダリングは、遅いセクションを後から 届けます。届くのは一度きりです。チャットのログ、メトリクスのパネル、通知の フィードが求めているのはその逆です。サーバが新しいことを知ったなら、いま誰かが 見ているページの一領域がそれを伝えるべきです。
ライブレンダリングはそのための仕組みです。テンプレートのパラメータが値を出し 続けるソースになり、それを読む境界が配信のたびに再描画され、ブラウザは画面が 開いている限りページ自身の URL への接続を1本保ちます。
変わるところ、変わらないところ
Section titled “変わるところ、変わらないところ”テンプレートの書き方は変わりません。live ソースは async な値とまったく同じ
await 節に束縛します。
external live WatchMetrics(id: string): Pointexternal async LoadTitle(id: string): string
export component Gauge(id: string): html {{await title = LoadTitle(id), point = WatchMetrics(id)} <h1>{title}</h1> <p>{point.label}: {point.value}</p>{fallback} <p>待機中</p>{/await}}{live} 節も専用の終端子もありません。値がどれくらいの頻度で届くかを言っていた
のは待ち受け側ではなく宣言側だからです。LoadTitle は一度届いてそのまま、
WatchMetrics は届き続け、どの描画も両方を読みます。ソースが async から live
に変わっても、それを呼ぶテンプレートは1つも変わりません。
ハンドラに至っては何も変わりません。live ソースは生成コードがサブスクリプションの
コンテキスト付きで呼ぶので、作るハンドルも、Params に通す値も、レスポンス側の
ストリーミング API もありません。
func WatchMetrics(ctx context.Context, id string) iter.Seq2[Point, error] { return func(yield func(Point, error) bool) { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: point, err := sample(ctx, id) if !yield(point, err) { return } } } }}このシグネチャには、名指ししておく価値のある性質が3つあります。
コンテキストは必須です。 external async の関数は取っても取らなくても構いま
せんが、external live の関数は必ず取ります。終わらないソースには他に戻る理由が
なく、サブスクリプションより長生きする goroutine は上限のないリークだからです。
yield されたエラーは終端ではなく1回の配信です。 境界は recover 節を描画した
うえでサブスクリプションを保ち、次の正常な値が来ればまた本来の内容に戻ります。
サブスクリプションを終わらせるのは、シーケンスが return することだけです。
pull であることがバックプレッシャです。 ランタイムは1つずつ引き取るので、
画面が使える速度より速いソースは自分の yield で止まり、単に tick を取りこぼし
ます。サイズを決めるキューも、捨てる処理もありません。
すでに channel を返すサービスがあるなら、シーケンスの中で range して適合させます。
func WatchMessages(ctx context.Context, room string) iter.Seq2[[]Message, error] { return func(yield func([]Message, error) bool) { updates := chat.Subscribe(ctx, room) for messages := range updates { if !yield(messages, nil) { return } } }}1回の配信は差分ではなく領域の全体
Section titled “1回の配信は差分ではなく領域の全体”上の WatchMessages が yield しているのは、新着メッセージではなく現在のリスト
全体です。これは規則であり、他のすべてを単純にしている前提でもあります。
- 描画が入力の純粋な関数のままなので、繰り返しても安全である
- 再接続に必要なのは次の1回の配信だけで、再送もサーバ側の蓄積も要らない
- 取りこぼしや合流が起きても、失われるのは切断中の鮮度だけで、その後には残らない
代償も実在します。サブツリーは毎回まるごと再描画されるので、長いリストは配信の たびにその長さぶんのコストを払います。テンプレートは配信をまたいで状態を持たない ので、蓄積を持つのはソースの持ち主です。
1つ1つの値に意味がある——必ず見せなければならないイベント——ようなソースは、 速いソースが合流される以上、ここでは形が合いません。累積した状態を yield して、 そのイベントはシグナルとして送ってください。 まさにこのために用意された経路で、こちらは間引かれません。
最初のレスポンスがすること
Section titled “最初のレスポンスがすること”最初のレスポンスに特別なことは何もありません。ドキュメントは非同期ページと同じ ようにストリーミングされ、シェルと fallback がコミットされ、確定した境界から順に 置き換わります。live な境界は最初の配信を通常の完了として受け取り、そこで 購読をやめるので、ドキュメントはきちんと終わります。
ここから2つの結果が出ます。
html.async_timeout の内にソースが何か出せたなら、最初の描画にはローディング表示
ではなく実際の内容が載ります。出せなかった場合はコミット済みの fallback がそのまま
残ります。そしてこれは失敗ではないので、recover 節は描画されず、エラーログにも
何も出ません。まだ言うことがないソースは、壊れてはいないからです。
クローラも、フィードリーダも、JavaScript を切ったブラウザも、そのおかげで live 領域の実際の描画を1回受け取ります。永久のプレースホルダではありません。 1つのテンプレートが両方の相手に応えます。
ブラウザがすること
Section titled “ブラウザがすること”ドキュメントの末尾には、この先に何か来るのかどうかを述べる不活性なマーカーが 付きます。live な仕事が残っていると書かれていたとき、ランタイムは接続を1本開き ます。
GET /dashboard/7Pw-Response-Mode: live同じ URL、同じルート、同じハンドラ、同じ認証です。ルートがもう一度実行され、 ドキュメントの本文は書かず、購読が生きている間ずっと配信ごとに1レコードを流します。
ページがもう一度実行されること自体が設計の核です。トークンもケイパビリティも クライアント側の保存状態もなしに、live な束縛が引数を取り戻す方法がこれだから です。境界 ID は描画ツリーの位置を表すので、同じページを実行し直せば同じ ID が 出てきて、配信はブラウザがすでに持っているプレースホルダを指します。
したがって再接続は、最初の接続とまったく同じリクエストです。サーバが寿命の上限で 健全な接続を閉じたとき、ストリームが途中で切れたとき、ネットワークが落ちたときに ランタイムがそれを行います。前者にはジッタ付きの即時再接続を、後者には上限付きの 指数バックオフを使います。ページの他の部分は再描画されません。本文はそもそも転送 されていないからです。
まれな2つのケースは、意図的に単純に扱います。ページが持っていない境界 ID への 配信は、ページの構造が変わったこと——見ている人のいるダッシュボードにパネルが 1つ増えたなど——を意味し、それを正しく配置するにはブラウザが描画していない ドキュメントを描画する必要があります。デプロイで生成コードが変わった場合は、 すべての ID の意味が変わっているかもしれません。どちらも接続を止めてページを 1回リロードします。URL ごとにガードを持たせてあるので、同じ状態を出し続ける サーバがリロードループを起こすことはありません。
入力ではなく出力を置く
Section titled “入力ではなく出力を置く”live な境界のサブツリーは、ユーザーが別のことをしている最中に、サーバ側の都合で 置き換わります。その中にあるブラウザ側の状態は予告なく壊れます。入力欄の値も、 キャレットも、選択範囲もです。
そこで、live な領域には出力を置くという規則があり、これは生成時に強制されます。
live 節の主サブツリーにある form、input、textarea、select、
contenteditable は警告ではなく生成エラーです。失敗の現れ方が、ユーザーが
入力したものの無言の消失だからです。フォームは境界の外に置き、live なデータを
中に置いてください。
コンパイラが捕まえられないものも知っておく価値があります。
- 領域内のリンクやボタンにフォーカスがあると、置き換えで失われる
- 領域自身のスクロール位置は戻り、高さが変わればページもずれる
- 再生中の video や audio は最初からになり、CSS アニメーションも同じ
読み上げの扱いもあなたの責任です。その更新が読者を遮る価値があるかを知っている
のはあなただけだからです。毎秒描き直されるゲージは何も読み上げるべきではありま
せん。チャットのログは role="log" が適切で、これは polite を含みます。その属性は
境界の外側の要素に付けてください。置き換えられるサブツリーの中にあるものは、
中身ごと壊れて作り直され、ライブリージョンがリセットされます。
キーはすべて非同期レンダリングと同じ html バインディングの下にあり、すべてが
html.streaming に依存します。バッファされたドキュメントは live な境界をその場で
確定させてプレースホルダを書かないので、streaming を切ると、配信先のないところへ
ブラウザが配信を当てにいく代わりに、live 配信そのものが無効になります。
[html]streaming = truelive = true # そもそも live 接続に応えるかlive_max_duration = "10m" # 健全な接続を閉じ、また来ることを期待するlive_duration_jitter = 20 # その寿命をばらつかせる割合(%)live_idle_timeout = "5m" # 何も配信していない接続を閉じるlive_max_boundaries = 32 # 1接続が扱う境界数の上限live_max_responses = 4 # クライアントあたりの同時接続数の上限live = false は障害ではなく安全なつまみです。どのドキュメントも有効なままで、
live な境界がコミットした内容を保ち、接続を促されるクライアントもいません。この
負荷を落としてもエラーページにはなりません。
最も分かりにくい一方で、効果が大きいのが接続時間の上限です。接続を無期限に維持すると、 認可状態も残り続け、デプロイ後も古いプロセスへの接続が切れず、1クライアントが1つの インスタンスに固定されます。数分ごとに接続を閉じれば、この3つを解消できます。 その代わり、接続を切り替えるたびにページが1回実行されます。ジッターが必要なのはそのためで、これがないと再起動 1回で全クライアントが同期し、以後どの周期でも群れが揃って戻ってきます。
middleware.request_timeout を設定しているなら、それは live 接続も縛ります。
live_max_duration より大きくするか、0 のままにしてください。
再接続はページ実行1回ぶんです。ハンドラも、レイアウトも、await 境界ももう一度 走り、その出力は捨てられます。キャパシティ計画では開いている接続数ではなく毎秒の ページ実行数を数えてください。データベースに届くのはそちらです。
その実行が描き直すのは live な境界だけではなくページ上のすべての境界で、その多くは 前と同じ内容になります。転送されるのは違っていたものだけです。各配信は画面に置いた バイト列の validator を運び、ブラウザは次の接続でそれを返し、サーバが今描いたものと validator が一致する領域はそのまま放置されます。ストリーミングされたドキュメントは、 自分が招く接続に同じ validator を渡します。だからページを開いて最初の接続も、あとの 接続と同じだけしか払いません。サーバは何も保持せず、validator が届かなければ、 どのみち送るはずだった配信を1回払うだけです。
2つのインスタンスが validator を比較できるのは鍵を共有しているときだけです。
html.update.validator_key を設定すれば共有されます。部分更新を使うかどうかは
関係ありません。鍵は 32 バイト以上の素材を持つ必要があり——base64 ならデコード後で
数えます——満たさなければ未設定と同じ扱いになり、プロセスごとに鍵を持ちます。これは
部分更新が起動時に強制するのと同じ下限で、推測できる鍵は、ログやプロキシの観測者が
エントロピーの低い領域の内容を validator から確認できてしまうからです。共有鍵が
なければプロセスごとに鍵を持つので、同じプロセスに戻った再接続は変わったぶんだけを、
別のプロセスに着いた再接続は全部を転送します——後者はこの仕組みが入る前のすべての
再接続と同じです。効いているかどうかは、トレースされた
レスポンスの pw.live.suppressed と pw.live.suppressed_bytes に出ます。
描画はクライアントごとです。1つのゲージを10画面が見ていれば、tick ごとに10回
描画します。再構成される入力も認可もクライアントごとに違うからです。1つの上流を
それらの購読で共有するのはソースの仕事で、WatchMetrics の中で1回購読して配る
のが正しい形です。サーキットブレーカもそこに置きます。そこで封じ込められた上流の
故障は正直に劣化します。境界は最後に描画した内容を保ち、ページは正しいままです。
接続は連鎖の head タグを運ぶ head レコードで開きます。だから、ドキュメントが
一度も運ばなかったコンポーネントに live 配信の内容が到達しても、マークアップが
着地する前にスタイルシートが入ります。これはナビゲーション差分が守っている順序と
同じで、2つが1つのレコード文法を話し、1つのリーダーで読まれるようになった理由でも
あります。配信は await レコードです——境界 id とそれを埋めるマークアップ、まさに
それそのものだからです。
live レスポンスは圧縮しません。数秒ごとにフラッシュすると圧縮率は落ちますし、 個人化された内容とリクエストに影響される値が混ざった長寿命のストリームは、 1つのドキュメントよりはるかに多くのサンプルを圧縮オラクルに与えます。
失敗したとき、読者に見えるもの
Section titled “失敗したとき、読者に見えるもの”| 起きたこと | 読者に見えるもの |
|---|---|
| ソースがエラーを yield した | recover の内容。次の正常な値で本来の内容に戻る |
| ソースが終わった | 最後の描画がそのまま残り、接続は閉じて再試行しない |
recover 節がない |
最後の描画が残り、失敗はログに届き、接続は閉じる |
| 接続が切れた | 再接続が現在の状態を配信するまで、最後の描画が残る |
| デプロイされた | 1回のリロード |
| live を無効にしている | ドキュメントがコミットした内容がそのまま。有効なページのまま |
この表に壊れた画面はありません。それが保つ価値のある性質です。ドキュメントは常に 完全なページであり、live 配信はそれをより新しくするだけです。
モードを第2のエンドポイントではなくリクエストヘッダで選ぶのは、ページが自分の URL
から描画されなければならないからです。パスやクエリのモードトークンはテンプレート
スコープに届いてしまいますし、並行するルートは、すでに生成されテスト済みの認可と
バインディングを二重化します。カスタムヘッダはクロスオリジンのフォームやリンクから
は付けられないので、CSRF 対策が気にする種類のリクエストが届かないという利点も
あります。live に応えうるドキュメントは Vary: Pw-Response-Mode を、live レスポンス
は no-store を必ず持ちます。
ストリームは改行区切りの JSON で、1行が1レコードです。
{"control":"open","version":"9f1c…"}{"id":"tb-1","html":"<p>throughput: 41</p>"}{"id":"tb-1","html":"<p>throughput: 44</p>"}{"control":"closed","reason":"retry","retry_after_ms":2000}マークアップによる区切りは、HTML パーサーが到着順にバイト列を処理できるようにするためのものです。
最初のドキュメントを処理した後はパーサーを使わないため、
レコードのほうが自然な形です。open レコードは ID の背後にあるビルドを名指しし、
closed レコードは必ず最後に書かれます。接続がきれいに閉じただけでは、ソースが
終わったのか、健全なレスポンスを上限が閉じたのかを言えないからです。そしてこの
2つは、クライアントに正反対の振る舞いを要求します。
DOM に見える変更が1つあります。確定した境界はこれまでプレースホルダを置き換えて 消えていましたが、いまは適用された内容が境界 ID を持つコメントノードの対で挟まれ ます。これが後続の配信が置き換えるアドレスです。コメントは不活性で、CSS にも レイアウトにも影響せず、囲むのではなく範囲を挟むので、トップレベル要素が複数ある 配信でも、あなたが書いていないコンテナは要りません。
