WebSocket サーバーを書く

WebSocket サーバーは、特定のプロトコルに従うサーバーの任意のポートを待ち受けする TCP アプリケーションに他なりません。カスタムサーバーを作成したことがない人にとっては、カスタムサーバーの作成は大変な作業のように思えるかもしれません。しかし、実際には、選択したプラットフォームに基本的な WebSocket サーバーを実装するのは、それほど難しいことではありません。

WebSocket サーバーは、 Berkeley sockets が利用可能なサーバーサイドプログラミング言語、例えば C(++)、Python、PHPサーバーサイド JavaScript などで記述することができます。これは特定の言語のチュートリアルではありませんが、独自のサーバーの作成を容易にするガイドとして役立ちます。

この記事は、既に HTTP の仕組みに精通しており、中程度のプログラミング経験があることを前提に書かれています。言語によっては、 TCP ソケットの知識が必要な場合があります。このガイドの範囲は、 WebSocket サーバーを書くために必要な最小限の知識を提示することです。

メモ: 最新の公式 WebSockets 仕様である RFC 6455 を参照してください。第 1 章と第 4-7 章はサーバー実装者にとって特に興味深いものです。第 10 章ではセキュリティについて説明しています。サーバーを公開する前にセキュリティを正しく理解する必要があります。

ここでは WebSocket サーバーについて非常に低水準で説明しています。WebSocket サーバーは多くの場合、リバースプロキシー(通常の HTTP サーバーなど)を使用して WebSocket ハンドシェイクを検出、事前処理し、それらのクライアントを実際の WebSocket サーバーに送信します。つまり、(例えば)クッキーと認証ハンドラーを使用してサーバー側のコードを膨らませる必要はありません。

WebSocket ハンドシェイク

まず、サーバーは標準の TCP ソケットを使用して着信ソケット接続を待ち受ける必要があります。プラットフォームによっては、すでに処理されている可能性があります。たとえば、サーバーが example.com、 8000 番ポートで待ち受けしているとし、ソケットサーバーが example.com/chat に対する GET リクエストに応答したとします。

警告: サーバーは選択したポートで待ち受けしますが、80 または 443 以外のポートを選択すると、ファイアウォールやプロキシーの問題が発生する可能性があります。ブラウザーは WebSocket の接続に安全な接続を必要としますが、ローカル機器では例外を設けている可能性があります。

ハンドシェイクは WebSockets の "Web" です。それは HTTP から WS への橋渡しです。ハンドシェイクでは、接続の詳細がネゴシエートされ、いずれの当事者も条件が悪い場合には完了前に取り消すことができます。 サーバーはクライアントがリクエストするすべてのものを理解するように注意する必要があります。そうしないとセキュリティの問題が発生します。

メモ: request-uri (ここでは /chat)の意味は仕様書では定義されていません。多くの人がこれを使用して、 1 つのサーバーが複数の WebSocket アプリケーションを処理できるようにします。たとえば、example.com/chat はマルチユーザーチャットアプリを呼び出すことができ、同じサーバーの /game はマルチプレイヤーゲームを呼び出すことができるようにするなどです。

クライアントハンドシェイクリクエスト

サーバーを構築していても、クライアントは WebSocket ハンドシェイクプロセスを開始する必要があります。したがってクライアントのリクエストをどのように解釈するかを知っておく必要があります。クライアントは次のようなかなり標準的な HTTP リクエスト (HTTP バージョンは 1.1 以上でなければならず、メソッドはGET でなければなりません) を送信します。

http
GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

クライアントはここで拡張子やサブプロトコルを求めることができます。詳細は「その他」を参照してください。また User-AgentRefererCookie、認証ヘッダーなどの一般的なヘッダーも存在する可能性があります。これらに対しては何をしても構いません。 WebSocket には直接関係しません。それらを無視することも安全です。多くの一般的な設定では、リバースプロキシーは既にそれらを処理しています。

メモ: すべてのブラウザーOrigin ヘッダーを送信します。このヘッダーをセキュリティ(同一オリジンのチェック、自動許可または拒否など)に使用し、見せたくなければ 403 Forbidden を送ることができます。これは[クロスサイト WebSocket ハイジャッキング (CSWH)] (https://cwe.mitre.org/data/definitions/1385.html) に対して効果があります。ただし、ブラウザー以外のエージェントは、偽の Origin を送信するだけであることに注意してください。ほとんどのアプリケーションは、このヘッダーのないリクエストを拒否します。

ヘッダーが解釈されていないか値が正しくない場合、サーバーは 400 ("Bad Request") を送信し、すぐにソケットを閉じる必要があります。通常は、HTTP レスポンス本体でハンドシェイクが失敗した理由を示すかもしれませんが、メッセージは表示されないかもしれません(ブラウザーは表示しません)。 サーバーが WebSocket のバージョンを認識しない場合、サーバーは解釈可能なバージョンを含む Sec-WebSocket-Version ヘッダーを返す必要があります。上記の例では、 WebSocket プロトコル 13 版であることを示しています。

ここで、最も興味深いヘッダーは Sec-WebSocket-Key です。次に見てみましょう。

メモ: 通常の HTTP ステータスコードは、ハンドシェイクの前にのみ使用できます。ハンドシェイクが成功したら、別のコードセット (仕様の 7.4 節で定義されている) を使用する必要があります。

サーバーハンドシェイクレスポンス

サーバーがハンドシェイクリクエストを受け取ると、プロトコルが HTTP から WebSocket に変更されることを示す特別なレスポンスを送り返す必要があります。そのヘッダーは次のようなものです(各ヘッダー行は \r\n で終わり、ヘッダーの終わりを示すために最後の行の後に追加の \r\n を置くことを忘れないでください)。

http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

さらに、サーバーはここで拡張/サブプロトコルのリクエストを決定することができます。詳しくはその他を参照してください。 Sec-WebSocket-Accept ヘッダーは、クライアントが送信した Sec-WebSocket-Key からサーバーが導き出す必要がある点で重要です。これを得るには、クライアントの Sec-WebSocket-Key と "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" という文字列(これは「マジック文字列」)を連結して、その結果の SHA-1 hash をとり、そのハッシュの base64 エンコーディング値を返せばいいのです。

メモ: この一見複雑すぎるプロセスは、サーバーが WebSocket に対応しているかどうかをクライアントに明らかにするために存在します。これは、サーバーが WebSockets 接続を受け入れても、データを HTTP リクエストとして解釈する場合にセキュリティ問題が発生する可能性があるため、重要なことです。

したがって、 Key が "dGhlIHNhbXBsZSBub25jZQ==" だった場合、 Accept は "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" になります。サーバーがこれらのヘッダーを送信すると、ハンドシェイクは完了し、データのスワップを開始できます。

メモ: サーバーは、 Set-Cookie のような他のヘッダーを送信したり、レスポンスハンドシェイクを送信する前に他のステータスコードで認証またはリダイレクトを要求したりすることができます。

クライアントの追跡

これは WebSocket プロトコルとは直接関係ありませんが、ここで触れておく価値があります。サーバーはクライアントのソケットを追跡する必要があるので、すでにハンドシェイクを完了したクライアントと再びハンドシェイクを続ける必要はありません。同じクライアントの IP アドレスは何度も接続を試みることができます。しかし、サーバーは DoS 攻撃から身を守るために、あまりにも多くの接続が試みられた場合、拒否することがあります。

例えば、ユーザー名や ID 番号を、対応する WebSocket やその接続に関連付ける必要のあるデータと一緒に表にしておくとよいでしょう。

データフレームの交換

クライアントとサーバーのどちらもがいつでもメッセージを送信することができます。これが WebSocket の魔法です。しかし、これらのいわゆる「フレーム」のデータから情報を抽出することはあまり魔法のような経験ではありません。すべてのフレームは同じ特定のフォーマットに従いますが、クライアントからサーバーに向かうデータは XOR 暗号化(32 ビットキー)を使用してマスクされます。本明細書の第 5 節でこれについて詳細に説明する。

フォーマット

各データフレーム(クライアントからサーバーへ、またはその逆)は、次の同じ形式に従います。

bash
Frame format:

      0                   1                   2                   3
      0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
     +-+-+-+-+-------+-+-------------+-------------------------------+
     |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
     |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
     |N|V|V|V|       |S|             |   (if payload len==126/127)   |
     | |1|2|3|       |K|             |                               |
     +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
     |     Extended payload length continued, if payload len == 127  |
     + - - - - - - - - - - - - - - - +-------------------------------+
     |                               |Masking-key, if MASK set to 1  |
     +-------------------------------+-------------------------------+
     | Masking-key (continued)       |          Payload Data         |
     +-------------------------------- - - - - - - - - - - - - - - - +
     :                     Payload Data continued ...                :
     + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
     |                     Payload Data continued ...                |
     +---------------------------------------------------------------+

つまり、フレームには次のバイトが格納されているということです。

MASK ビットはメッセージがエンコードされているかどうかを示します。クライアントからのメッセージはマスクされている必要がありますので、サーバーはこのビットが 1 であることを確認する必要があります。(実際、仕様書の第 5.1 節では、クライアントがマスクされていないメッセージを送信する場合、サーバーはクライアントから切断する必要があります。)フレームをクライアントに戻すときは、マスクしたりマスクビットを設定しないでください。後でマスキングについて説明します。注意:セキュアソケットを使用している場合でも、メッセージをマスクする必要があります。RSV1-3 は無視することができますが、それは拡張のためのものです。

opcode フィールドは、ペイロードデータをどのように解釈するかを定義します。継続の場合 0x0、テキスト (UTF-8 で常にエンコードされる) の場合は 0x1、バイナリーの場合は 0x2、およびその他のいわゆる「制御コード」については後で説明します。この版の WebSocket では、0x30x7 および 0xB0xF は意味を持ちません。

FIN ビットは、これがシリーズ内の最後のメッセージであるかどうかを示します。0 の場合、サーバーはメッセージのより多くの部分をリスニングし続けます。それ以外の場合、サーバーは配信されたメッセージを考慮する必要があります。これについては後で詳しく説明します。

本体長のデコード

ペイロードデータを読み取るには、いつ読み終えるべきかを知っておく必要があります。そのためペイロードの長さを知ることが重要です。残念ながら、これはやや複雑です。それを読むには、次の手順を実行します。

  1. ビット 9 から 15 までを読み取り、それを符号なし整数として解釈します。それが 125 以下であれば、それが長さです。これで完了です。 126 の場合は手順 2 に、 127 の場合は手順 3 に進んでください。
  2. 次の 16 ビットを読み取り、それを符号なし整数として解釈します。これで完了です。
  3. 次の 64 ビットを読み取り、それを符号なし整数として解釈します(最上位ビットは必ず 0 になります)。これで完了です。

データの読み込みとマスク解除

MASK ビットがセットされていた場合は(クライアントからサーバーへのメッセージではそうあるべきです)、次の 4 オクテット(32 ビット)を読み込みます。これがマスキングキーです。本体長とマスキングキーがデコードされたら、ソケットからそのバイト数を読み取ることができます。データを ENCODED、キーを MASK 呼ぶことにします。 DECODED を取得するには、ENCODED のオクテット(バイト列、すなわちテキストデータの文字の列)をループし、オクテットを MASK の(i モジュロ 4)番目のオクテットを使用して XOR します。擬似コードで表してみます(JavaScript が有効な場合)。

js
const MASK = [1, 2, 3, 4]; // 4 バイトマスク
const ENCODED = [105, 103, 111, 104, 110]; // encoded string "hello"

// デコードする内容のバイト配列を作成
const DECODED = Uint8Array.from(ENCODED, (elt, i) => elt ^ MASK[i % 4]); // マスクの XOR を実行

これで、アプリケーションに応じて DECODED が何を意味するのかを理解することができます。

メッセージフラグメンテーション

FIN フィールドとオペコードフィールドは連携して、別々のフレームに分割されたメッセージを送信します。これはメッセージフラグメンテーションと呼ばれます。フラグメンテーションは、オペコード 0x00x2 でのみ使用できます。

オペコードはフレームの意味を示しています。0x1 の場合、ペイロードはテキストです。0x2 の場合、ペイロードはバイナリーデータです。ただし、0x0 の場合、フレームは継続フレームです。つまりサーバーはフレームのペイロードをそのクライアントから受信した最後のフレームに連結する必要があります。ここでは、サーバーがテキストメッセージを送信するクライアントに反応する概略を示します。第 1 のメッセージは単一のフレームで送信され、第 2 のメッセージは3つのフレームにわたって送信されます。FIN とオペコードの詳細は、クライアントに対してのみ表示されます。

Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!

最初のフレームにメッセージ全体が含まれていることに注意してください (FIN=1 および opcode!=0x0)、それによりサーバーは適切に処理または応答できます。クライアントが送信した 2 番目のフレームにはテキストペイロード (opcode=0x1) がありますが、メッセージ全体がまだ到着していません (FIN=0)。そのメッセージの残りの部分はすべて継続フレーム (opcode=0x0) と共に送信され、メッセージの最終フレームは FIN=1 でマークされます。仕様書の 5.4 節では、メッセージフラグメンテーションについて説明があります。

Ping と Pong: WebSockets の鼓動

ハンドシェイク後の任意の時点で、クライアントまたはサーバーのどちらかが、相手に ping を送信することを選択できます。 ping が受信されると、受信者はできるだけ早く pong を返さなければなりません。 これを使用して、たとえばクライアントがまだ接続されていることを確認できます。

ping や pong は単なる通常のフレームですが、制御フレームです。ping のオペコードは 0x9、pong のオペコードは 0xA です。ping を取得したら、ping と同じペイロードデータを持つ pong を送ります(ping と pong の場合、最大本体長は 125 です)。ping を送信することなく pong を取得することもできます。その場合はこれを無視してください。

メモ: pong を送信する機会を得る前に複数の ping を受信した場合でも、送信する pong は 1 つだけです。

接続を閉じる

クライアントまたはサーバーの接続を閉じるには指定した制御シーケンスを含むデータの制御フレームを送信して、終了ハンドシェイクを開始します (5.5.1 項を参照)。このようなフレームを受信すると、もう1つのピアはレスポンスとしてクローズフレームを送信します。最初のピアは接続を閉じます。接続の終了後に受信されたそれ以上のデータは、その後破棄されます。

その他

メモ: WebSocket のコード、拡張機能、サブプロトコルなどは、IANA WebSocket プロトコルレジストリーに登録されています。

WebSocket の拡張機能とサブプロトコルは、ハンドシェイク中にヘッダーを介して交渉されます。拡張機能とサブプロトコルはとても似ていますが、明確な区別があります。拡張機能は WebSocket フレームを制御し、ペイロードを変更しますが、サブプロトコルは WebSocket ペイロードを構造化し、何も変更しません。拡張機能は任意のもので一般化されています(圧縮など)。サブプロトコルは必須のもので、ローカライズされています(チャットや MMORPG ゲームなど)。

拡張機能

拡張機能はファイルを誰かに電子メールで送る前に圧縮していると考えてください。あなたが何をしても、同じデータをさまざまな形で送信しています。受信者は最終的にローカルコピーと同じデータを得ることができますが、別の方法で送信されます。それが拡張機能の機能です。 WebSockets はプロトコルとデータを送信する簡単な方法を定義しますが、圧縮などの拡張機能では同じデータを短い形式で送信することができます。

メモ: 拡張機能については、仕様書の 5.8, 9, 11.3.2, 11.4 節で説明しています。

サブプロトコル

サブプロトコルをカスタム XML スキーマまたは doctype 宣言と考えてください。あなたはまだ XML とその構文を使用していますが、あなたが合意した構造によってさらに制限されます。WebSocket のサブプロトコルはまさにそのようなものです。それらは空想的な何かを導入しておらず、構造を確立するだけです。doctype やスキーマと同様に、両者はサブプロトコルに同意しなければなりません。doctype やスキーマとは異なり、サブプロトコルはサーバー上に実装されており、クライアントから外部参照することはできません。

メモ: サブプロトコルは、仕様のセクション 1.9、4.2、11.3.4、11.5 で説明されています。

クライアントは特定のサブプロトコルを要求する必要があります。 これを行うには、元のハンドシェイクの一部として次のようなものを送ります。

http
GET /chat HTTP/1.1
...
Sec-WebSocket-Protocol: soap, wamp

次のものも同等です。

http
...
Sec-WebSocket-Protocol: soap
Sec-WebSocket-Protocol: wamp

これでサーバーはクライアントが提案して対応しているプロトコルの 1 つを選択する必要があります。複数ある場合は、クライアントが送信した最初のものを送信します。サーバーが soapwamp の両方を使用できると想像してください。 次に、レスポンスハンドシェイクで次のメッセージが送信されます。

http
Sec-WebSocket-Protocol: soap

警告: サーバーは複数の Sec-Websocket-Protocol ヘッダーを送信できません。 サーバーがサブプロトコルを使用したくない場合、 Sec-WebSocket-Protocol ヘッダーを送信してはいけません。空白のヘッダーを送信するのは間違いです。クライアントは、必要なサブプロトコルを取得できない場合に接続を閉じることがあります。

サーバーが特定のサブプロトコルに従うようにしたいのであれば、必然的にサーバー上に特別なコードが必要になります。 json サブプロトコルを使用しているとしましょう。このサブプロトコルではすべてのデータが JSON として渡されます。クライアントがこのプロトコルを要求し、サーバーがそれを使用したい場合、サーバーは JSON パーサーを持つ必要があります。実際に言えば、これはライブラリーの一部になりますが、サーバーはデータを渡す必要があります。

メモ: 名前の競合を避けるため、サブプロトコル名をドメイン文字列の一部にすることをお勧めします。Example Inc. 専用の独自の形式を使用するカスタムチャットアプリを構築する場合は、次のように使用します: Sec-WebSocket-Protocol: chat.example.com。これは必須ではないことに注意してください。これは単なるオプションです。任意の文字列を使用できます。

関連情報