WebSocket と SSE

20 分で読了
このページの目次

WebSocket は持続的な双方向メッセージチャネルを提供します。SSE は HTTP 応答内でテキストイベントを送信します。両者ともサーバーからのプッシュが可能ですが、選択の鍵となるのは「誰がプッシュできるか」ではなく、双方向性の方向性、メッセージの種類、リカバリセマンティクス、およびインフラストラクチャのサポートです。

例えば、注文ページは主に状態変化を受信するためのもので、クライアントが注文を変更する場合は通常の HTTP を使用します。このようなケースでは SSE で要件を満たせます。一方、共同編集では頻繁な双方向操作とカスタムメッセージプロトコルが必要となるため、WebSocket の方が自然です。どちらを選択した場合でも、切断後のリカバリとスローコンシューマーの処理はアプリケーション設計に依存します。

問題参照ポイント
WebSocket は必ず 101 を返すかHTTP/1.1 Upgrade と h2/h3 拡張 CONNECT
send 1回 = 1つのネットワークパケットかメッセージ、フレーム、転送セグメンテーション
mask は秘密を保護できるかマスクは暗号化ではない。機密性は TLS などに依存
Pong はアプリケーションタスクの健全性を証明するかプロトコルスタックの生存とビジネス処理は分離される
SSE 再接続でイベントの欠落が保証されないかID、サーバーログ、消費確認
ページが重くなり、メモリが増加する送信キュー、バックプレッシャー、メッセージのマージ

WebSocket 接続確立:プロトコルを確認してからメッセージを送信

HTTP/1.1 のハンドシェイクでは、GET、Upgrade、Connection フィールドが使用され、クライアントはランダムな 16 バイト値の base64 形式を Sec-WebSocket-Key として提供します。サーバーは 101 と正しい Sec-WebSocket-Accept を返します。サブプロトコルは、クライアントが提供した候補の中から選択する必要があります。

Accept の計算は、Key フィールドのテキスト値と規定の GUID を連結し、SHA1 と base64 を実行します。Key を 16 バイトにデコードしてから連結するわけではありません。これは、サーバーがこのプロトコルアップグレードを理解していることを証明するものであり、証明書の認証や機密性の保証は行いません。

import base64
import hashlib

key = 'dGhlIHNhbXBsZSBub25jZQ=='
guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
assert len(base64.b64decode(key)) == 16
accept = base64.b64encode(hashlib.sha1((key + guid).encode('ascii')).digest()).decode('ascii')
assert accept == 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
wrong = base64.b64encode(hashlib.sha1(base64.b64decode(key) + guid.encode('ascii')).digest()).decode('ascii')
assert accept != wrong
print('RFC ハンドシェイクベクトル通過:', accept)

HTTP/2、HTTP/3 では、双方がサポートする場合、拡張 CONNECT を使用して WebSocket を確立することもできます。成功した応答は、HTTP/1.1 の 101 アップグレードではなく、対応する 2xx になります。これは単一の HTTP ストリームを伝達するものであり、マルチプレクスト接続全体を WebSocket に変更するものではありません。RFC 8441、RFC 9220

メッセージ、フレーム、およびマスク

WebSocket メッセージはフレームに分割可能。制御フレームはフラグメント間に挿入可能 Text:FIN=0 メッセージの最初のフラグメント Ping:FIN=1 独立した制御フレーム Continuation:FIN=1 メッセージの残りと終了 アプリケーションは、個々の TCP セグメントではなく、再構築されたメッセージを受信 テキストメッセージ全体は有効な UTF-8 である必要がある 制御フレームのペイロードは最大 125 バイトで、フラグメント化不可。フレーム境界、メッセージ境界、転送パケット境界は異なる。

基本フレームヘッダーには、FIN、拡張保持ビット、opcode、MASK、および長さエンコーディングが含まれます。長さが 0 から 125 の場合は直接表現でき、126 の場合は後に 16 ビットの長さが続き、127 の場合は後に 64 ビットの長さが続き、最上位ビットは 0 である必要があります。マスクが存在する場合は、さらに 4 バイトのキーが続きます。

クライアントからサーバーへのフレームはマスクする必要があります。サーバーから送信されるフレームはマスクしてはならず、TLS 内の WebSocket フレームでもこのルールに従います。各フレームには、新規かつ予測不可能なマスクキーが必要です。以下の例の固定キーは、標準的な教育用ベクトルを再現するためだけに使用されます。

payload = b'Hello'
mask = bytes.fromhex('37fa213d')
masked = bytes(v ^ mask[i % 4] for i, v in enumerate(payload))
wire = bytes([0x81, 0x80 | len(payload)]) + mask + masked
assert wire.hex() == '818537fa213d7f9f4d5158'
assert bytes(v ^ mask[i % 4] for i, v in enumerate(masked)) == payload
assert len(wire) == 11  # 2 B 基本ヘッダー + 4 B mask + 5 B コンテンツ
assert 2 + len(payload) == 7  # サーバー側:同じコンテンツ、マスクなしのフレーム
print('クライアント / サーバー 短フレームバイト:', len(wire), 2 + len(payload))

XOR マスクは、傍受者が内容を復元するのを防ぐものではありません。これは特定のインフラストラクチャ攻撃からの保護のために使用され、機密性は wss などの転送保護に依存します。FIN はメッセージフラグメントシーケンスの終了を示し、制御フレームはその間に挿入できます。制御フレームはフラグメント化できず、ペイロードは 125 バイト以下です。テキストメッセージの UTF-8 有効性は、再構築セマンティクス下でチェックされ、各フラグメントが文字境界で正確に終了することを要求されるわけではありません。RFC 6455:フレームとフラグメント

ハートビート、クローズ、およびビジネス確認

Ping/Pong は、プロトコルチャネルが応答を持っているかどうかを確認します。Pong は要求に応じて Ping データに応答しますが、WebSocket ライブラリはアプリケーションスレッドがビジーの場合でも独立して処理できるため、Pong はデータベースの利用可能性、ルームサブスクリプションの有効性、またはビジネスメッセージの送信完了を保証するものではありません。

ブラウザの WebSocket API には、プロトコル Ping を直接送信する JavaScript メソッドは提供されていません。アプリケーションハートビートが必要な場合は、シーケンス番号と時刻を含むビジネスメッセージを定義し、アプリケーション側で処理して応答します。JSON 文字列を「ping」と呼ぶからといって、それが opcode 9 の制御フレームであると誤解しないでください。WebSockets API

クローズハンドシェイクでは Close フレームを使用します。ステータスコードがある場合は、ネットワークバイトオーダーで 2 バイトを書き込み、その後に任意の UTF-8 理由が続きます。1000 は正常なクローズを示し、1006 はローカルでの異常クローズを表すために使用されますが、Close フレームのステータスコードとしてオンラインで送信することはできません。クローズ理由が、特定のビジネスメッセージの最終結果と等しいわけではありません。

アプリケーション確認には、独立したメッセージ ID、送信結果、または処理済みシーケンス番号を使用する必要があります。例えば、「操作 17 は受信済み」と「操作 17 は永続的にコミット済み」は異なる確認ポイントです。接続が切断された後、最後の send が返されたかどうかだけでなく、操作 ID に基づいて照会またはリプレイする必要があります。

SSE はイベント形式であり、chunked に依存しない

SSE 応答は Content-Type: text/event-stream を使用し、コンテンツは UTF-8 テキストです。フィールド行と空行がイベントを形成します。HTTP/1.1 では chunked を使用して伝達できますが、HTTP/2/3 では対応する DATA ストリームによって伝達されます。「HTTP chunked を使用する必要がある」と定義することはできません。

行意味
data: ...イベントデータ。複数行は改行で連結
event: alertイベントタイプ。指定しない場合は message
id: 42イベント ID の更新。リカバリ位置に使用
retry: 3000ミリ秒単位で再接続時間を設定
: keepaliveコメント。ビジネスイベントはディスパッチされない
空行完全なイベントのディスパッチ処理をトリガー

以下は、2 つの data 行を持つイベントと 1 つのコメントハートビートを生成します。イベント形式の空行は、TCP 接続の終了で代用できません。最後に区切り文字が完了していないコンテンツは、完全なイベントとして自動的に配信されません。

wire = ('id: 42\nevent: alert\ndata: first line\ndata: second line\n\n'
        ': keepalive\n\n').encode('utf-8')
first, heartbeat, trailing = wire.decode('utf-8').split('\n\n')
fields = {}
values = []
for line in first.splitlines():
    name, value = line.split(':', 1)
    value = value[1:] if value.startswith(' ') else value
    if name == 'data':
        values.append(value)
    else:
        fields[name] = value
assert fields == {'id': '42', 'event': 'alert'}
assert '\n'.join(values) == 'first line\nsecond line'
assert heartbeat.startswith(':') and trailing == ''
print('イベントタイプ / ID / 複数行データ:', fields['event'], fields['id'], repr('\n'.join(values)))

この例は、LF 形式とフィールド連結のみを検証するものであり、完全な EventSource パーサーではありません。実際の形式では、CRLF/CR、空の id、未知のフィールド、および NUL などの処理ルールも定義されています。HTML:イベントストリームの解析

再接続とリカバリ:ID は自動的に履歴ログを生成しない

Last-Event-ID はリカバリ位置のみを伝達。リプレイ機能はサーバー側から提供される クライアント イベントサービス イベント id=42:クライアントが解析してディスパッチ 接続中断後の再接続:Last-Event-ID: 42 ログは継続イベントを保持:契約に従って 43、44…をリプレイ カーソルが保持ウィンドウを超過:明確な再同期パスを返す ブラウザイベントディスパッチはビジネスコミット確認ではない。消費側は重複排除が必要で、ギャップ処理はアプリケーションの合意による。

EventSource は、回復可能な切断後に再接続を試み、記録された Last-Event-ID を添付します。サーバーは ID に基づいたリプレイ機能を実装する必要があります。ログが 1 時間しか保持されていない場合、クライアントが 2 時間オフラインだった場合は、最新のイベントから静かに開始するのではなく、スナップショットの再取得とサブスクライブ継続の明確なプロセスを提供する必要があります。

ブラウザがイベントをディスパッチして位置を更新することは、ビジネスコンシューマーが永続的にコミットしたことを意味しません。ページのクラッシュ、重複リプレイ、処理の失敗などにより、アプリケーションでの重複排除とチェックポイントが必要になる場合があります。EventSource のみに依存して、exactly-once のビジネス配信を提供することはできません。EventSource オブジェクトを再作成しても、旧オブジェクトのリカバリ状態は自動的に保持されません。

SSE コメントはリンクのアクティビティを提供できますが、自動再接続自体はハートビートではありません。サーバーは 204 で再接続意図を終了することもでき、クライアントは close を呼び出すこともできます。アプリケーションは、一時的なネットワーク切断と資格情報の無効化、サービスの永久停止などの条件を区別する必要があります。HTML:EventSource

Last-Event-ID があるのに、なぜスナップショットを再取得するのか?

まず、カーソルを保持起点の 2 つ手前に置くと、金色のイベントはログから取得できなくなります。カーソルを起点の 1 つ手前に移動すると、次のイベントはちょうどログ内にあり、完全なリプレイ条件を満たします。実際のシステムでは、イベント消費とスナップショットサブスクリプションの一貫性も解決する必要があります。

図を準備しています
Last-Event-ID があるのに、なぜスナップショットを再取得するのか?

カーソルは単なるリカバリ位置であり、リプレイ機能は実際に保持されたログに由来する。欠落したセグメントが期限切れになった場合、最も古い利用可能なイベントから静かに続行すると、変更を見逃す可能性がある。

ブラウザ認証とソースチェック

ブラウザの WebSocket コンストラクタとネイティブ EventSource は、任意のカスタムリクエストヘッダーインターフェースを提供しないため、Authorization を直接追加できるかのように見せかけることはできません。EventSource は適用可能な Cookie と withCredentials 設定を使用できます。クロスオリジン読み取りは CORS に従います。fetch を使用してイベントストリームを自分で読み取る場合、解析、リカバリ、再接続もコードで処理する必要があります。

WebSocket ハンドシェイクは、通常の CORS 許可ヘッダーを使用してソースを認証するものではありません。ブラウザ Cookie を使用して認証する場合、サーバーは Origin とビジネス権限を検証して、他のサイトがユーザー資格情報を借用して接続を確立するのを防ぐ必要があります。ブラウザ以外のクライアントは Origin を自己申告するため、これもユーザー認証に代わるものではありません。

資格情報が期限切れになった後、既存の長寿命接続をどのように処理するかは、プロトコルの合意によるものです:再認証、切断後の再接続、またはメッセージごとの権限検証。ハンドシェイク時に一度だけチェックして接続を無限に存続させると、アイデンティティの取り消しと権限の更新が適用されにくくなります。

バックプレッシャーとプロキシ:プッシュできるからといって無限にキューに入れられるわけではない

古典的な WebSocket API の send は送信キューに追加され、bufferedAmount は未送信データを観察するのに役立ちますが、ビジネスプロデューサーに完全な待機可能なバックプレッシャープロトコルを自動的に提供するわけではありません。受信側でも、メッセージサイズと消費キューを制限して、低速な処理によるメモリ増加を防ぐ必要があります。

例えば、ダッシュボードは 1 秒間に 100 回更新されるが最新状態のみを表示する必要がある場合、キーに基づいて中間更新をマージできます。監査イベントは随意に破棄できないため、キューの上限、永続ログ、およびスローコンシューマーのリカバリプロセスを設定する必要があります。2 つのニーズに同じ「キューがいっぱいなら破棄」という戦略を使用すべきではありません。

プロキシのバッファリングにより、SSE がバッチ処理されてから配信される可能性があります。アイドルタイムアウトにより、データのない長寿命接続が閉じられる可能性があります。ハートビート周期は、フルチェーンの最短アイドル制限とネットワークのジッターを考慮して決定する必要があり、15 秒を固定してコピーするべきではありません。デプロイメントの排空では、最大待機時間と再接続のバックオフを定義し、同時再起動による再接続ストームを避ける必要があります。

permessage-deflate は WebSocket テキストのサイズを削減できますが、CPU、状態、および圧縮情報漏洩のトレードオフが増加します。コンテキストテイクオーバーがメッセージ間で履歴を保持するかどうかは、ネゴシエーションによって決定され、「圧縮を有効にする」ことを無条件の利益として扱うべきではありません。RFC 7692

選択基準

要件WebSocketSSE
クライアントとサーバー間の頻繁な双方向メッセージ交換直接双方向チャネルクライアント操作は通常 HTTP を経由
生バイナリコンテンツバイナリメッセージをサポートテキスト形式。別途エンコードまたはダウンロードが必要
ブラウザのリカバリサポートアプリケーション側で再接続とリカバリを実装ネイティブ再接続と Last-Event-ID。リプレイはサーバー側で実装
インフラ要件実際の Upgrade/CONNECT 伝達をサポート長寿命応答とタイムリーなリフレッシュをサポート
ビジネス信頼性カスタム ACK、重複排除、リプレイ消費セマンティクス、重複排除、およびログも必要

小規模で低頻度の更新にはポーリングを使用することもあります。サーバープッシュの要件があるからといって、自動的に WebSocket が選択されるわけではありません。まず、レイテンシ、メッセージの方向、損失許容度、およびリカバリコストを明確にしてから、伝達方式を選択します。

継続的な参照

  • HTTP/1.1:メッセージ区切りと CORS。
  • HTTP/2、HTTP/3:マルチプレクストストリーム。
  • リバースプロキシ:バッファリング、タイムアウト、および排空。
  • REST/API:操作のアイデンティティ、非同期ステータス、および冪等性。