2.WebSocketプロトコルバージョンhybi-00(hixi-76)仕様解説編 ずっとβ版

ブラウザーは現時点(2012/02/01)での各最新のブラウザーを対象とします。(Chrome16,Firefox10,Opera11,Safari5)
ブラウザーに実装されているWebSocket(API)のことを"WebSocketクライアント"と呼ぶことにします。
※単に"hybi-"で始まる単語はプロトコルバージョンを指す言葉とします。また、hixi-76はhybi-00と同じものですのでhixi-76をhybi-00と呼ぶことにします。

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

さて、今回はWebSocketプロトコルの仕様を(私が理解している範囲で)解説します。サーバーの実装を目標としますのでサーバーを中心にして解説します。
とりあえず最低限通信(データ送受信)ができるレベルを目標としますので、ハンドシェイク、データ送受信、Ping(Pong)、クローズの4つの処理に関する部分のみとします。
実装WebSocketクライアント対応プロトコルバージョン確認編で、ブラウザーによって対応するプロトコルバージョンが違うということがわかりました。どのブラウザーがどのプロトコルバージョンに対応しているのかをここで再度確認しましょう。
ブラウザー プロトコルバージョン
Chrome RFC(hybi-17)
Firefox(PC版及びAndroid版) hybi-10
Opera(PC版およびAndroid版) Safari(PC版およびiOS版) hybi-00
英語が読める方はhybi-00の仕様を見ながら読み進めていただければ理解がしやすかと思います。(逆に仕様と違う所があれば教えて下さいw)

ハンドシェイク処理

ハンドシェイク処理は、WebSocketクライアントから送られてくるハンドシェイクデータ(以降、ハンドシェイクリクエストと呼ぶことにします。)を受信し、それに対するハンドシェイクデータ(以降、ハンドシェイクレスポンスと呼ぶことにします。)を返し、WebSocketクライアントがハンドシェイクレスポンスを検証後、(検証が通れば)接続を開始する処理です。
ハンドシェイク処理はHTTPプロトコルを使用して行います。
ハンドシェイクリクエストの例を見てみます。
GET /Safari HTTP/1.1
Upgrade: WebSocket
Connection: Upgrade
Host: localhost:8181
Origin: http://www.apple.com
Sec-WebSocket-Key1: c98  713K41 x  05
Sec-WebSocket-Key2: 2m&0  1t` 1Q| 7V   0GM5    4<U9A8

?,m?YZ??
HTTPプロトコルヘッダーのGETメソッド文の他に、ハンドシェイクリクエストで送られてくるフィールドは
  • Upgrade
  • Connection
  • Host
  • Origin
  • Sec-WebSocket-Key1
  • Sec-WebSocket-Key2
の6つです。この他にもWebSocketクライアント側でサブプロトコルを指定した場合はSec-WebSocket-Protocolというフィールドも送られてきますが、今回はサブプロトコルに関する解説は割愛させて頂きます。
ハンドシェイクリクエストの例を見ると最後に文字化けしたようなデータがあります(8バイトのバイナリデータ)。これも重要なデータとなります。
ハンドシェイクリクエストを元にハンドシェイクレスポンスを返します。
ハンドシェイクレスポンスの例を見てみましょう。
HTTP/1.1 101 WebSocket Protocol Handshake
Upgrade: WebSocket
Connection: Upgrade
Sec-WebSocket-Origin: http://example.com
Sec-WebSocket-Location: ws://example.com/demo

8jKS'y:G*Co,Wxa-
ハンドシェイクレスポンスに含めなければならないフィールドは、Upgrade,Connection,Sec-WebSocket-Origin,Sec-WebSocket-Locationの4つです。
各行およびフィールドに設定する値は次のようになります。
HTTPレスポンスヘッダー "HTTP/1.1 101 WebSocket Protocol Handshake" 固定です。
Upgradeフィールド "WebSocket" 固定です。(大文字小文字の区別はありません。)
Connectionフィールド "Upgrade" 固定です。(同様に大文字小文字の区別はありません。)
Sec-WebSocket-Originフィールド ハンドシェイクリクエストのOriginフィールドの値をそのまま設定します。
上記のハンドシェイクリクエストの値を例にすると
Sec-WebSocket-Origin: http://www.apple.com
となります。
Sec-WebSocket-Locationフィールド "ws://" + ハンドシェイクリクエストのHostフィールドの値 + GETメソッド文に含まれるパスを設定します。
上記のハンドシェイクリクエストを例にすると
Sec-WebSocket-Location: ws://www.apple.com/Safari
となります。
上記以外にハンドシェイクリクエストの最後に文字化けしたようなデータ(16バイトのバイナリデータ)があります。ここには定められた方法によって作成されたデータを設定します。作成するデータのことをAcceptデータと呼ぶことにします。
Acceptデータの作成方法
AcceptデータはハンドシェイクレスポンスのSec-WebSocket-key1の値、Sec-WebSocket-Key2の値、ハンドシェイクリクエストの最後の8バイトバイナリデータを使用して作成します。
Sec-WebSocket-key1の値に含まれる数字部分を抜き出し結合してそれを数値にしたものを、値に含まれるスペースの個数で割った値をバイナリデータ(4バイト)に変換します。
例として Sec-WebSocket-Key1: 3 7 78B 67  5      W%89
という値でバイナリデータをを求めてみます。
  • 数字を抜き出して数値に変換した値 = 377867589
  • スペースの数 = 11
  • 数値 / スペースの数 = 377867589 / 11 = 34351599
  • 34351599 をバイナリデータに変換 > bin[0] = 0x02, bin[1] = 0x0c, bin[2] = 0x29, bin[3] = 0xef
Sec-WebSocket-Key2も同じ方法で値からバイナリデータを作成します。

上記で求めた2つの4バイトバイナリデータとハンドシェイクリクエストの最後にあった8バイトバイナリデータを結合し、16バイトのバイナリデータにします。
 0                       4                       8                       16
 +-----------------------+-----------------------+-----------------------+
 |   Key1バイナリデータ  |  Key2 バイナリデータ  | 8バイトバイナリデータ | 
 +-----------------------+-----------------------+-----------------------+
この16バイトのバイナリデータからMD5ハッシュ値(16バイト)を計算したものがAcceptデータとなります。
4つのフィールドの後ろ(HTTPプロトコルのコンテンツ部分)にこの16バイトのAcceptデータをつなげたものをハンドシェイクレスポンスとしてクライアントに返します。
以上がハンドシェイク処理となります。
ハンドシェイクレスポンスを返すとブラウザー側で検証を行い通れば、WebSocketが接続されます。(WebSocketクライアントのonopenイベントが発生します。)
もし、検証に失敗した場合は接続に失敗します。(WebSocketクライアントのoncloseイベントが発生します。)
ハンドシェイクレスポンスを返す処理をC#のコードに書いてみます。
//string hr = Encoding.UTF8.GetString(ハンドシェイクリクエストバイト配列);

// パスを取得
string path = Regex.Match(hr, @"^GET\s([^\s]+)\s", RegexOptions.IgnoreCase).Groups[1].Value;
// Hostフィールドの値を取得
string host = Regex.Match(hr, "Host: (.+?)\r\n", RegexOptions.IgnoreCase).Groups[1].Value;
// Originフィールドの値を取得
string origin = Regex.Match(hr, "Origin: (.+?)\r\n", RegexOptions.IgnoreCase).Groups[1].Value;
// Sec-WebSocket-Key1の値を取得
string sk1 = Regex.Match(hr, "Sec-WebSocket-Key1: (.+?)\r\n", RegexOptions.IgnoreCase).Groups[1].Value;
// Sec-WebSocket-Key2の値を取得
string sk2 = Regex.Match(hr, "Sec-WebSocket-Key2: (.+?)\r\n", RegexOptions.IgnoreCase).Groups[1].Value;
// 最後の8バイトバイナリデータを取得
byte[] last8Bytes = new byte[8];
Array.Copy(entry.receiveData, entry.receiveData.Length - 8, last8Bytes, 0, 8);
// ハンドシェイクリクエストの固定部分文字列作成
string responseString = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" +
                        "Upgrade: WebSocket\r\n" +
                        "Connection: Upgrade\r\n" +
                        "Sec-WebSocket-Origin: {0}\r\n" +
                        "Sec-WebSocket-Location: {1}\r\n\r\n"; // 注意:レスポンスヘッダーの最後は\r\n2つ
// Sec-WebSocket-Origin 及び Sec-WebSocket-Location の値を設定後バイト配列に変換する
byte[] responseStringBin = utf8.GetBytes(string.Format(responseString, origin, "ws://" + host + path));
string digit1 = "", digit2 = "";
int spc1 = 0, spc2 = 0;
// Sec-WebSocket-Key1フィールド及びSec-WebSocket-Key2の値からそれぞれ数字抽出及びスペースの数を取得
foreach (char c in sk1) if (char.IsDigit(c)) digit1 += c; else if (c == ' ') spc1++;
foreach (char c in sk2) if (char.IsDigit(c)) digit2 += c; else if (c == ' ') spc2++;
// 数値 / スペースの数 を計算しバイナリデータを作成
byte[] skb1 = BitConverter.GetBytes((Int32)(Int64.Parse(digit1) / spc1));
byte[] skb2 = BitConverter.GetBytes((Int32)(Int64.Parse(digit2) / spc2));
// エンディアンが逆のため配列を反転させる
Array.Reverse(skb1);
Array.Reverse(skb2);
// 2つのバイナリデータ及びハンドシェイクリクエストの最後の8バイトバイナリデータを結合する
byte[] concatenatedKeys = new byte[16];
Array.Copy(skb1, 0, concatenatedKeys, 0, 4);
Array.Copy(skb2, 0, concatenatedKeys, 4, 4);
Array.Copy(last8Bytes, 0, concatenatedKeys, 8, 8);
// MD5ハッシュ値を計算
byte[] acceptData = md5.ComputeHash(concatenatedKeys);
// ハンドシェイクレスポンスデータを作成
byte[] handshakeResponse = new byte[responseStringBin.Length + 16];
Array.Copy(responseStringBin, handshakeResponse, responseStringBin.Length);
Array.Copy(acceptData, 0, handshakeResponse, responseStringBin.Length, 16);

// ハンドシェイクレスポンスを送信
soket.send(handshakeResponse);

データ送受信処理

WebSocketでデータを送信するとき、データをフレームという形にしてから送らなければなりません。 hybi-00のフレームはシンプルです。
        Handshake
           |
           V
        Frame type byte <--------------------------------------.
           |      |                                            |
           |      `--> (0x00 to 0x7F) --> Data... --> 0xFF -->-+
           |                                                   |
           `--> (0x80 to 0xFE) --> Length --> Data... ------->-'
テキストデータを送信する場合は、先頭に0x00〜0x7Fの1バイト、その後にペイロードデータ、最後に0xFFの1バイトという構成となります。なお、テキストデータの文字コードUTF-8を使用しなければなりません。バイナリデータを送信する場合は、先頭に0x80〜0xFEの1バイト、続けてデータの長さ(可変長と思います)、その後にペイロードデータという構成となります。
しかし、Opera及びSafariはバイナリデータの送受信には対応していません。(これはChromeFirefoxがまだhybi-00を実装していた頃も同様にテキストデータの送受信のみにしか対応していませんでした。)
ですので、データ送受信処理の実装は簡単です。
C#コードでの実装例
// クライアントからのデータ受信
int len = socket.EndReceive(asyncResult);
string receiveData = Encoding.GetString(1, クライアントからの送信データバイト配列, len - 2);

// 送信データをフレームに変換(先頭1バイトは0x00固定にします。)
List<byte> sendData = new List<byte>(new byte[]{0x00, 0xff});
sendData.InsertRange(1, Encoding.UTF8.GetBytes(receiveData));
byte[] sendDataBin = sendData.ToArray();
// すべてのクライアントにブロードキャスト送信
foreach(Member m in members)
{
    m.Socket.Send(sendDataBin);
}

Ping(Pong)処理

hybi-00の仕様には、Ping(Pong)に関することがかかれていません。ですので必要な場合は自分でPing送信文字列を決めてそれを送受信するといった処理を追加します。

クローズ処理

仕様においては接続を切るときもハンドシェイクを行うことになっています。
切断時のハンドシェイクは以下の方法で行います。
クライアントから接続を切りたい場合のハンドシェイク
  1. クライアントから 0xFF 0x00 の2バイトのデータを(サーバーに)送信します。
  2. サーバーは 0xFF 0x00 を受信すると 0xFF 0x00 をクライアントに送り返します。
  3. クライアントが 0xFF 0x00 を受信するとクライアント側から接続を切ります。
サーバーから接続を切りたい場合のハンドシェイクも同様に
  1. サーバーから 0xFF 0x00 の2バイトのデータを(クライアントに)送信します。
  2. クライアントは 0xFF 0x00 を受信すると 0xFF 0x00 をサーバーに送り返します。
  3. サーバーが 0xFF 0x00 を受信するとサーバー側から接続を切ります。
このハンドシェイク中はWebSocketクライアント(JavaScript)のWebSocketオブジェクトのreadyStateプロパティがCLOSING(=2)になるようです。
ハンドシェイクを行わなくても、SocketオブジェクトのCloseメソッドを実行するだけでもとりあえずは問題ないかと思います。


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