3.WebSocketプロトコルバージョンhybi-07〜RFC(hybi-17)仕様解説編 ずっとβ版

ブラウザーは現時点(2012/02/01)での各最新のブラウザーを対象とします。(Chrome16,Firefox10,Opera11,Safari5)
ブラウザーに実装されているWebSocket(API)のことを"WebSocketクライアント"と呼ぶことにします。
※単に"hybi-"で始まる単語はプロトコルバージョンを指す言葉とします。また、hixi-76はhybi-00と同じものですのでhixi-76をhybi-00と呼ぶことにし、hybi-17がRFC6455の仕様となったためhybi-17を単にRFCと呼ぶことにします。
※2012/04/24 マスクについて勘違いしていました。hybi-15からはサーバーからクライアントにデータを送る場合は必ずマスク無しで送らないといけないようです。その部分を修正しました。

気になったところなどがありましたらコメントにてツッコんでください。

一気にhybi-07以降の仕様の解説です。
というのも、hybi-07からRFCのハンドシェイク処理およびデータ送受信時のフレーム構成、クローズ処理においてはほとんど同じためです。(これ以外の処理においては違いはあります。)
違いがあるとすれば、hybi-11以降からはhybi-04〜hybi-10で使用されたSec-WebSocket-Originというフィールド名が"Origin"に変更されたというところだけです。(hybi-03以前でも"Origin"が使用されていたため"戻された"という表現が正しいかな?)
このことにより、hybi-07に対応したWebSocketサーバーを(フィールド名の違いや細かい仕様の違いは吸収して)実装すれば(Sec-WebSocket-Versionのチェックを行わなければ)RFCでもつながるサーバーとなります。

英語が読める方はRFC6455の仕様を見ながら読み進めていただければ理解がしやすかと思います。(逆に仕様と違う所があればツッコミお願いしますw)

ハンドシェイク処理

Chromeのハンドシェイクリクエストを例に見てみましょう。
GET /Chrome HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: localhost:8181
Origin: http://d.hatena.ne.jp
Sec-WebSocket-Key: 7r5Lzy+riXX12fjRYxBGMw==
Sec-WebSocket-Version: 13
hybi-07以降のハンドシェイクリクエストで送られてくるフィールドは
  • Upgrade
  • Connection
  • Host
  • Origin
  • Sec-WebSocket-Key
  • Sec-WebSocket-Version
の6つです。
続いて、ハンドシェイクレスポンスの例を見てみます。
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
hybi-00との大きな違いは、HTTPプロトコルでのコンテンツ部分(バイナリデータ部分)がなく、ヘッダーのみかつすべて有効な文字でハンドシェイク処理を行っているというところです。
聞いた話なのですが、hybi-00では特にプロキシを通したときにこのコンテンツ部分が削られてたものがサーバーに届き、ハンドシェイクが行えずWebSocketをつなぐことができないという状況が発生するようです。
ハンドシェイクの仕様変更はセキュリティの問題もそうですが、こういった障害に対する対応も含めたものと思われます。
hybi-07以降のハンドシェイクレスポンスに含めなければならないフィールドは
  • Upgrade
  • Connection
  • Sec-WebSocket-Accept
の3つとなります。 hybi-07以降のハンドシェイク処理においてもhybi-00のハンドシェイク処理同様、サブプロトコルが設定された場合は、Sec-WebSocket-protocolフィールドがリクエストおよびレスポンスに追加されます(今回も同様にサブプロトコルに関する解説は割愛させていただきます)。
各行およびフィールドに設定する値は次のようになります。
HTTPレスポンスヘッダー "HTTP/1.1 101 Switching Protocols"固定です。
Upgradeフィールド "websocket"固定です。(大文字小文字の区別はありません。)
Connectionフィールド "Upgrade"固定です。(大文字小文字の区別はありません。)
Sec-WebSocket-Acceptフィールド ハンドシェイクリクエストのSec-WebSocket-Keyの値から定められた方法で作成した文字列を設定します。詳しくは次の「Sec-WebSocket-Acceptフィールド値の作成方法」で解説します。
Sec-WebSocket-Acceptフィールド値の作成方法
ハンドシェイクリクエストで送られてくるSec-WebSocket-Keyフィールドの値と"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"(このGUID文字列は固定です)を文字列結合したものからSHA1ハッシュを計算した結果をBase64に変換したものがSec-WebSocket-Acceptフィールドに設定する値となります。
例として、ハンドシェイクリクエストで
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
という値から、Sec-WebSocket-Acceptフィールドの値を作成してみます。
  1. "dGhlIHNhbXBsZSBub25jZQ=="と"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"を文字列結合 = "dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
  2. "dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11"からSHA1ハッシュ値を計算 = sha1[0] = 0xb3, sha1[1] = 0x7a, sha1[2] = 0x4f, sha1[3] = 0x2c ...
  3. SHA1ハッシュ値Base64文字列に変換 = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
この"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="をSec-WebSocket-Acceptフィールドに設定します。
これらの値を設定してハンドシェイクレスポンスを返します。
ハンドシェイクレスポンスを返す処理をC#のコードで組みますと以下のようになります。
// Sec-WebSocket-Keyの値を取得
string webSocketKey = Regex.Match(handshakeRequest, "Sec-WebSocket-Key: (.+?)\r\n", RegexOptions.IgnoreCase).Groups[1].Value;
// ハンドシェイクリクエストの固定部分文字列作成
string responseString = "HTTP/1.1 101 Switching Protocols\r\n" +
                        "Upgrade: websocket\r\n" +
                        "Connection: Upgrade\r\n" +
                        "Sec-WebSocket-Accept: {0}\r\n\r\n";
// Sec-WebSocket-Keyの値と"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"を文字列結合し、SHA1ハッシュを計算後Base64文字列に変換する
string webSocketAccept = Convert.ToBase64String(sha1.ComputeHash(Encoding.UTF8.GetBytes(webSocketKey + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")));
// Sec-WebSocket-Acceptに値を設定
handshakeResponse = Encoding.UTF8.GetBytes(string.Format(responseString, webSocketAccept));
// ハンドシェイクレスポンスを返す
socket.Send(handshakeResponse);

データ送受信処理

hybi-00と同様に、hybi-07以降においてもフレームにしてから送信します。ただし、hybi-00でのフレーム構成と比べると結構複雑なフレーム構成となっています。
hybi-07以降のフレーム構成図を見てみましょう。一番上の数字はビットオフセットの値です。
      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 ...                |
     +---------------------------------------------------------------+
フレームの構成を見てみます。
バイト位置
()は2バイト目下位7ビットの値
ビット位置 説明
1バイト目 先頭ビット 1バイト目の先頭ビットはFINフラグとなっており、このフラグが0の場合は分割したフレームで送信されたときにまだ次のフレームがあることを示します。普段は、このFINフラグを1に設定して(最終フレーム)データを送ります。(このフラグの使い道がいまだによくわかっていません)
下位4ビット opcodeの値が入ります。このopcodeの値でこのフレーム(のデータ)がどんなものかを識別します。
opcodeの値 データ種別
0x0 継続フレーム:(FINフラグとともに使用されるもの?すいません。この値もよくわかっていません。)
0x1 テキストフレーム:文字列データ
hybi-00と同様に、テキストデータの文字コードUTF-8を使用しなければなりません。
また、テキストフレームを受信した場合はデータがUTF-8の有効な文字列かチェックしなければならなくなりました。
0x2 バイナリフレーム:バイナリデータ
Firefox10はまだバイナリデータの送受信に対応していません。
0x3〜0x7 Reserved:未使用
0x8 クローズフレーム:このフレームを受信したら接続を切ります。
0x9 Pingフレーム:Pingを送る場合はopcodeをこの値に設定して送ります。
0xA Pongフレーム:Pongを返すときにopcodeをこの値に設定します。
0xB〜0xF Reserved:未使用
2バイト目 先頭ビット 2バイト目の先頭ビットはMASKフラグとなっており、このフラグが1の場合はデータがマスクされています。なお、クライアントからサーバーにデータを送信する場合は必ずマスクを行わなければなりません。逆にサーバーからクライアントにデーターを送るときはマスクなしで、hybi-15からは"MUST"が付き必ずマスク無しで送信するようにとなっています。
下位7ビット ここにはデータ長か、126または127が入ります。
説明
126未満 126未満の場合は、その値がデータ長となります。
126 このバイトの後に続く2バイトの値がデータ長となります。
127 このバイトの後に続く8バイトの値がデータ長となります。
MASKフラグ=1の場合のみ
3〜6バイト目(126未満)
5〜8バイト目(126)
10〜13バイト目(127)
MASKフラグが0の場合はこの4バイトはなくデータ長の後にデータが続きます。MASKフラグが1の場合、データ長のあとに続く4バイトにはマスクキーが入ります。マスクされたデータをこのマスクキーでアンマスクすれば元のデータに戻すことができます。マスクについては次の「データのマスク」の項目で解説します。
MASKフラグ=0の場合
3バイト目以降(126未満)
5バイト目以降(126)
10バイト目以降(127)

MASKフラグ=1の場合
7バイト目以降(126未満)
9バイト目以降(126)
14バイト目以降(127)
ここにデータが入ります。
データのマスク
マスク方法は4バイトのランダムなデータを作成し、これをマスクキーとします。送信するデータをこのマスクキーとのXORしたものがマスクデータとなります。
マスクは、4バイトごとにループして行います。
実際にC#でマスク処理を書くと以下のようになります。
//byte[] sendData = 送信データ;
byte[] maskedData = new byte[sendData.Length];
byte[] maskKey = ランダムな4バイト;
for(int i = 0; i < sendData.Length; i++)
{
    maskedData[i] = sendData[i] ^ maskKey[i % 4];
}
// maskedDataを送信フレームに設定する。
同様に、マスクされたデータをマスクキーでXORすれば元のデータとなります。
//byte[] maskedData = マスクデータ;
//byte[] maskKey = マスクキー;
byte[] data = new byte[maskedData.Length];
for(int i = 0; i < maskedData.Length; i++)
{
     data[i] = maskedData[i] ^ maskKey[i % 4];
}
// テキストフレームの場合はUTF8エンコードで文字列にする。
string str = Encoding.UTF8.GetString(data);
これらのことを踏まえて、送信データ引数にWebSocketのフレームに変換したものを返す関数を実装すると以下のようなコードとなります。
// サーバー側の実装のためマスク処理は省略しています。
// 第1引数:送信するデータ。文字列を送信する場合は、バイト配列にしてからこの関数を呼ぶ。
// 第2引数:opcode
private byte[] WebSocketFrameEncode(byte[] data, byte opcode)
{
    byte[] webSocketFrame;
    // フレームのヘッダーを入れる配列
    byte[] hd;
    if (data.Length <= 125)
    {
        // ヘッダーを2バイトにする
        hd = new byte[2];
        // データ長が126未満の場合は2バイト目にデータ長を設定
        hd[1] = (byte)data.Length;
    }
    else if (data.Length <= 65535)
    {
        // ヘッダーを4バイトにする
        hd = new byte[4];
        // データ長が126以上65536未満の場合は2バイト目に126を設定
        hd[1] = (byte)126;
        // データ長をバイト配列に変換
        byte[] lenData = BitConverter.GetBytes((UInt16)data.Length);
        // エンディアンが逆なため配列を反転
        Array.Reverse(lenData);
        // フレームヘッダーの3バイト目からデータ長をコピー
        Array.Copy(lenData, 0, hd, 2, 2);
    }
    else
    {
        // ヘッダーを10バイトにする
        hd = new byte[10];
        データ長が65536以上の場合は2バイト目に127を設定
        hd[1] = (byte)127;
        // データ長をバイト配列に変換
        byte[] lenData = BitConverter.GetBytes((UInt64)data.Length);
        // エンディアンが逆なため配列を反転
        Array.Reverse(lenData);
        // フレームヘッダーの3バイト目からデータ長をコピー
        Array.Copy(lenData, 0, hd, 2, 8);
    }
    // FINフラグとopcodeを設定する
    hd[0] = 0x80 | opcode;
    // ヘッダーと送信データをつなげる
    webSocketFrame = new byte[hd.Length + data.Length];
    Buffer.BlockCopy(hd, 0, webSocketFrame, 0, hd.Length);
    Buffer.BlockCopy(data, 0, webSocketFrame, hd.Length, data.Length);
    // 作成したフレームを戻す
    return webSocketFrame;
}
続いて、WebSocketのフレームからデータを取り出す関数を実装すると以下のようなコードとなります。
private byte[] WebSocketFrameDecode(byte[] webSocketFrame)
{
    // FINフラグ、Opcode、MASKフラグ、データ長を取得
    bool fin = (webSocketFrame[0] & 0x80) == 0x80;
    byte opcode = webSocketFrame[0] & 0x0f;
    bool mask = (webSocketFrame[1] & 0x80) == 0x80;
    // 一応、データ長をUInt64として宣言する。
    UInt64 payloadLen = (UInt64)(webSocketFrame[1] & 0x7f);
    // データのオフセットを2に初期化する
    int offset = 2;
    if (payloadLen == 126)
    {
        // データ長が126だった場合
        // WebSocketフレームからデータ長のバイト配列(2バイト)を取得
        byte[] lenArray = new byte[2];
        Array.Copy(webSocketFrame, offset, lenArray, 0, 2);
        // エンディアンが逆なので反転
        Array.Reverse(lenArray);
        // バイト配列からデータ長を取得
        payloadLen = BitConverter.ToUInt16(lenArray, 0);
        // データのオフセットを+2する
        offset += 2;
    }
    else if (payloadLen == 127)
    {
        // データ長が127だった場合
        // WebSocketフレームからデータ長のバイト配列(8バイト)を取得
        byte[] revArray = new byte[8];
        Array.Copy(webSocketFrame, offset, revArray, 0, 8);
        // エンディアンが逆なので反転
        Array.Reverse(lenArray);
        // バイト配列からデータ長を取得
        payloadLen = BitConverter.ToUInt64(revArray, 0);
        // データのオフセットを+8する
        offset += 8;
    }
    byte[] maskKey = new byte[4];
    if (mask)
    {
        // MASKフラグがたっていた場合(=1)マスクキーを取得する
        Array.Copy(webSocketFrame, offset, maskKey, 0, 4);
        // データのオフセットを+4する
        offset += 4;
    }
    // データを取得する
    payloadData = new byte[payloadLen];
    Array.Copy(webSocketFrame, offset, payloadData, 0, payloadLen);
    if (mask)
    {
        // MASKフラグがたっていた場合はアンマスクを行う
        for (int i = 0; i < payloadLen; i++)
        {
            payloadData[i] = (byte)(payloadData[i] ^ maskKey[i % 4]);
        }
    }
    // フレームから取り出したデータを戻す
    return payloadData;
}

Ping(Pong)処理

hybi-01からPing(Pong)を仕様において定義されるようになりました。ただし、hybi-06まではhybi-01と同じフレーム構成およびopcode体系が使用されていましたが、hybi-07以降においてはフレーム構成の変更とともにopcodeの体系も変更となりました。hybi-01〜hybi-06ではopcode=0x2がPing、opcode=0x3がPongであったのに対し、hybi-07以降はopcde=0x9がPing、opcde=0xAがPongとなりました。
Pingで送られてきたデータをそのままPongとして返さなければならないと仕様ではなっています。
ただし、Pingを送る際にペイロードデータはなくてもいいらしいです。つまり、0x89 0x10 0x9a 0x6e 0x33 0x72という感じで(0x9a 0x6e 0x33 0x72はマスクキー)、ペイロードデータがないフレームでもかまいません。(ちょっと自信ない)
Pongを返す場合は、Pingで送られてきたフレームの1バイト目を0x8Aにしてそのまま返せばいいです。
C#コード
byte[] receiveFrame = 送られてきたPingフレーム;
receiveFrame[0] = 0x8A;
// Pongを返す
socket.send(receiveFrame);

クローズ処理

hybi-00と同様、クローズにおいてもハンドシェイクを行うようですが、すいません。英語が読めないのでめhybi-07以降におけるハンドシェイクの詳しい仕様がわからないため、クロー処理の解説は省略します。 ただ、opcode=0x8のフレームを受信した場合は、接続を切ります。
hybi-00での解説でも書きましたがSocketオブジェクトのCloseメソッドを実行するだけでもとりあえずは問題ないかと思います。


以上を踏まえてプログラムを組めば、とりあえずWebSocketでデータの送受信が行えるhybi-07以降に対応したサーバーの実装ができると思います。