WebSocket

クライアントとサーバー間で双方向のリアルタイム通信を実現するプロトコル

Webリアルタイム
「WebSocket」の技術書を見る →

WebSocket とは

WebSocket は、HTTP のハンドシェイクで接続を確立した後、クライアントとサーバー間で双方向のリアルタイム通信を行うプロトコル (RFC 6455) である。HTTP のリクエスト/レスポンスモデルと異なり、サーバーからクライアントへ能動的にデータを送信できる。

HTTP との比較

HTTP との主な違いを以下に比較する。

観点HTTPWebSocket
方向リクエスト/レスポンス双方向
接続リクエストごとに開閉持続的
オーバーヘッドヘッダーが毎回付くハンドシェイク後はフレームごとの数バイト
プロトコルhttp:// / https://ws:// / wss://
用途REST API、Web ページチャット、ゲーム、通知

ハンドシェイク後もオーバーヘッドがゼロになるわけではない。フレームには最小 2 バイトのヘッダーが付き、ペイロードが 126 バイト以上なら長さフィールドが伸びる。加えて RFC 6455 はクライアントから送るフレームすべてにマスク処理を義務付けているため、4 バイトのマスキングキーも毎回乗る。効いているのは、Cookie や認証ヘッダーのように毎回数百バイト単位で付いてくる情報を送り直さずに済む点である。

ハンドシェイク

接続は普通の HTTP リクエストとして始まり、サーバーが 101 Switching Protocols を返した時点で、同じ TCP 接続がそのまま WebSocket に切り替わる。専用のポートを開けずに済み、既存のファイアウォールや TLS の設定を流用できるのはこの作りのためである。

クライアント → HTTP Upgrade リクエスト → サーバー
  GET /chat HTTP/1.1
  Host: api.example.com
  Upgrade: websocket
  Connection: Upgrade
  Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
  Sec-WebSocket-Version: 13

サーバー → 101 Switching Protocols → クライアント
  HTTP/1.1 101 Switching Protocols
  Upgrade: websocket
  Connection: Upgrade
  Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

→ 以降は WebSocket フレームで双方向通信

Sec-WebSocket-Accept の値は、クライアントが送った Sec-WebSocket-Key に RFC 6455 が定める固定の識別子 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 を連結し、SHA-1 を取って Base64 にしたものである。鍵の交換ではなく、応答してきた相手が WebSocket を解していることの確認に使う。RFC はこの識別子を「WebSocket を理解しないネットワーク機器が使うとは考えにくい文字列」として選んでおり、Upgrade を無視したサーバーや途中のキャッシュの応答を、確立成功と誤認する事故を弾く役目を持つ。認証には関与しないので、利用者の識別は別に用意する。

Sec-WebSocket-Version: 13 が RFC 6455 の版を指す。ハンドシェイクが HTTP なので Cookie や TLS はそのまま効き、wss:// は既定で 443 番を通る。一方 HTTP/2 と HTTP/3 には Upgrade ヘッダーが無いため、この手順は使えない。HTTP/2 上では RFC 8441、HTTP/3 上では RFC 9220 が定める拡張 CONNECT で確立し、サーバーが SETTINGS_ENABLE_CONNECT_PROTOCOL を通知していることが前提になる。

クライアント側

ブラウザー側は WebSocket オブジェクトを作るだけで、ハンドシェイクは自動で進む。

const ws = new WebSocket('wss://api.example.com/ws');

ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'subscribe', channel: 'notifications' }));
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received:', data);
};

ws.onclose = (event) => {
  console.log(`Closed: ${event.code} ${event.reason}`);
  // 再接続ロジック
};

つまずきやすい点が 2 つある。1 つは送信のタイミングで、readyState が CONNECTING のまま send() を呼ぶと例外になる。上の例のように onopen の中で送るか、readyState が OPEN であることを確かめてから送る。もう 1 つは認証で、ブラウザーのコンストラクターは接続先 URL とサブプロトコル名しか受け取らないため、Authorization のような独自ヘッダーを付けられない。ハンドシェイク時に送られる Cookie を使うか、トークンをサブプロトコル名やクエリ文字列に載せるか、接続後の最初のメッセージで認証を済ませるまで購読を受け付けない作りにするかを選ぶことになる。

SSE との使い分け

分かれ目は、クライアントからも送る必要があるかどうかである。SSE も接続を張り続ける方式だが、流れる向きがサーバーからクライアントへの一方向に限られる。

用途選ぶ方式理由
チャットやゲームで双方向にやり取りするWebSocket同じ接続でクライアントからも送信できる。サーバーからの一方向しか流れない方式では、操作を送るための経路を別に用意することになる
通知やログをサーバーから流すだけSSE受け取り専用なら双方向の持続接続は要らず、ws:// への切り替えを挟まず HTTP のままで扱える
ダッシュボードの更新送信方向で決める数値を表示し直すだけなら SSE で足りる。画面から購読するチャンネルを切り替えるなら、クライアント発の送信ができる WebSocket にする

そもそも持続接続を張れない環境 (途中の機器が接続を維持してくれない場合など) では ロングポーリング が代替になる。相手がブラウザーではなくサーバーなら、接続を保たずイベントごとに HTTP を投げる Webhook の方が運用は軽い。

再接続の実装

持続接続は必ず切れる前提で組む。途中のプロキシやロードバランサーは無通信のまま一定時間が過ぎた接続を落とし、モバイル回線では回線の切り替えで切断され、サーバーをデプロイすれば全接続がまとめて閉じる。

function connect(url: string) {
  let delay = 1000;
  let closedByApp = false;

  const open = (): WebSocket => {
    const ws = new WebSocket(url);

    ws.onopen = () => {
      delay = 1000; // つながったら待ち時間を戻す
    };

    ws.onclose = () => {
      if (closedByApp) return; // 自分で閉じたときは再接続しない
      const wait = delay + Math.random() * 1000; // 一斉再接続を避けるためずらす
      delay = Math.min(delay * 2, 30000);
      setTimeout(open, wait);
    };

    return ws;
  };

  return { socket: open(), close: () => { closedByApp = true; } };
}

素朴に書くと踏みやすい落とし穴が 3 つある。1 つ目は、待ち時間を接続成功時に戻し忘れることである。上限まで伸びたままにすると、しばらく安定して動いた後に一度切れただけで、復帰が 30 秒後になる。2 つ目は、待ち時間を全クライアントで揃えてしまうことである。サーバーの再起動で一斉に切れた接続が同じ秒数後にまとめて戻り、立ち上がったばかりのサーバーを再び倒す。乱数でずらす。3 つ目は、自分で閉じた場合まで再接続してしまうことである。画面を離れる際に閉じたはずの接続が裏で復活し続け、通信量と接続数だけが増える。

切断そのものを減らすには、無通信の時間を作らないことが効く。RFC 6455 は制御フレームとして Ping (オペコード 0x9) と Pong (0xA) を定めており、Ping を受け取った側は Pong を返さなければならない。ただしブラウザーの WebSocket API は Ping を送る手段を公開していないため、クライアント側から死活を確かめたい場合はアプリケーション独自の軽いメッセージを定期的に往復させる。サーバー側のライブラリは Ping と Pong を扱えるものが多いので、サーバーから打つ方が素直である。

現場での応用を知るには関連書籍も役立つ。

共有:

この記事は役に立ちましたか?

関連用語

関連する記事