WebSocket サーバの実装とプロトコル解説
intro
なんだかんだ WebSocket を使ってるのに、 WebSocket サーバを自分で書いたことが無かったので、RFC も落ち着いてきたここらで、仕様を読みながら実装してみようと思いました。
"WebSocket サーバ 実装" とかでググると、 Socket.IO とか pywebsocket で WebSocket アプリ作って、「WebSocket サーバを実装」みたいなタイトルになってることが多いみたいですが、
(Apache に PHP で HelloWorld して、「HTTP サーバ実装しました」とは言わないよね。)
この記事では、 WebSocket プロトコルをしゃべるサーバ自体を実装します。
といっても、全部やるのはちょっと大変だったので、基本的なテキストメッセージのやりとりの部分だけやって、エコーサーバができるところまでやりました。
完成版のソースは以下です。本文を読みながら、合わせて見ていただけると良いと思います。
仕様
今回実装するのは、IETF RFC6455 に準拠したサーバと思ったのですが、全部やるのは大変だったので、サーバとクライアントでそれぞれ単純な
テキストをやり取りできるところまでにします。
RFC 6455 - The WebSocket Protocol
具体的には C->S, S->C それぞれ 'test' という文字列をやり取りします。
// Client var ws = new WebSocket("ws://localhost:3000/", ["test", "chat"]); ws.onopen = function() { ws.send("test"); ws.onmessage = function(message) { console.log(message.data); // test }; }
使ったのは以下
- Node.js 0.8.3
- Chrome v20
(FireFox でも動いたみたい)
HTTP Server
まず、今回は HTTP サーバで配信した HTML に含まれる JS から、同じサーバの上にある WebSocket サーバにコネクションを要求、確立する
感じにします。
そのため、まずは Node.js で HTTP サーバを立て、HTML を配信します。
var clientScript = function () { var ws = new WebSocket("ws://localhost:3000/", ["test", "chat"]); // var ws = new WebSocket("ws://localhost:3000/", "test"); ws.onopen = function() { console.log(ws); ws.send("test"); ws.onmessage = function(message) { console.log(message.data); }; } } var server = http.createServer(function(req, res) { res.writeHead(200, {'Content-Type': 'text/html'}); var html = '<html><head><title>wsserver</title>' + '<script type="text/javascript">' + '(' + clientScript + ')();' + '</script>' + '</head>' + '<body>hello world</body>' + '<html>'; res.end(html); });
シンプルな HTML の中に、生の WebSocket を使う JS を埋めています。
ブラウザで WebSocket オブジェクトを new すると、引数の WebSocket サーバに接続しにいきます。
この時、ブラウザは HTTP1.1 で upgrade リクエストを投げます。
そこで WebSocket を指定することで、通信プロトコルを WebSocket に upgrade する要求をができるので、サーバはこのリクエストを受け取り、ヘッダを解析します。
HTTP Upgrade Header
upgrade リクエストのヘッダは以下のようなものです。
1.2. Protocol Overview
また、 HTTP のヘッダ周りはこちらを見ると参考になると思います。
studyinghttp.net -
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Origin: http://example.com Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13
- Host
- 接続先の HOST です。
- Upgrade
- HTTP1.1 を別のプロトコルへ切り替える要求です。
- Connection
- 「HTTP/1.1 では、"Upgrade が HTTP/1.1 メッセージに存在する時は常に、upgrade というキーワードを Connection ヘッダフィールド (section 14.10) の中に与えなければならない" とされている事に注意せよ。」だそうです([http://www.studyinghttp.net/security#SwitchingProtocolFromHTTP:title=link)。
- Sec-Websocket-Key
- クライアントの認証や、セキュリティのためにクライアントで生成されるキーです。
- Origin
- 接続を要求するオリジンを示します。
- Sec-WebSocket-Protocol
- サブプロトコルのリストです。
- Sec-WebSocket-Version
- プロトコルのバージョンです。最新は13。
で、これらの解析なんですが、実は Node.js の場合は先程立てた HTTP サーバが勝手に解析してくれます。(なんというチートw)
サーバに Upgrade リクエストが来ると、Server オブジェクトで upgrade イベントが発生し、コールバックの第一引数に解析されたヘッダがオブジェクトとして渡されます。
server.on('upgrade', function(req, socket, head) { console.log(req); // { host: 'localhost:3000', // upgrade: 'websocket', // connection: 'Upgrade', // origin: 'http://localhost:3000', // 'sec-websocket-protocol': 'test, chat', // 'sec-websocket-extensions': 'x-webkit-deflate-frame', // 'sec-websocket-key': 'NblXHeIwGDpoQ2GFAGzwzw==', // 'sec-websocket-version': '13' } });
現在、 Chrome では Sec-WebSocket-Extensions ヘッダも送られています。
このへんかな
が、これはあくまで拡張なので、今回は無視します。
gist に貼ったソースでは、このあと、わかってる範囲で古いヘッダでの条件分岐などを書いていますが、最新のプロトコルのヘッダだけに対応するならこの時点で半分は終わりですw
HTTP Upgrade Response Header
リクエストからレスポンスを生成します。
レスポンスヘッダはこのようになります。
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocol: chat
レスポンスは HTTP1.1 Switching Protocol を返すことで、upgrade が受け入れられたことを示します。
この時、 Sec-WebSocket-Accept フィールドは、リクエストにあった、 Sec-WebSocket-Key の値から以下のように算出された値を使用します。
- Sec-WebSocket-Key(key) の末尾の空白を覗いた値を準備
- key に固定値 "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" を連結
- sha1 を取得
- base64 に変換
です。
Node.js では標準の Crypto モジュールを使います。
key = require('crypto') .createHash('sha1') .update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11') .digest('base64');
Sec-WebSocket-Protocol は、クライアントから提示されたサブプロトコルからどれか(もしくは無し)を選んで返します。
そもそもサブプロトコルはオプションで利用できるアプリケーションレイアのプロトコルなので、今回はネゴシエーションしてるけど、使いません。
Data Frame(C->S)
ヘッダを返したら、 WebSocket 通信が確立します。
まずは、クライアントからメッセージを送ってみましょう。
ws.send('test');
送られるメッセージは 4byte の ASCII 文字列です。
この文字列はバイナリ形式のデータフレームでサーバに送られます。
Node.js の場合は、メッセージが届くと 'data' イベントが Socket オブジェクトで発生し、コールバックに Buffer オブジェクトでこのデータが渡ります。
Buffer オブジェクトは、 Node.js でオクテットストリーム(要するにバイナリデータを 8bit ごとの配列っぽく)扱うためのオブジェクトです。
このフレームの形式は以下のようになっています。
RFC 6455 - The WebSocket Protocol
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 ... | +---------------------------------------------------------------+
では、 Buffer の中身をコツコツ解析しながら、各値を解説していきます。
FIN
最後のパケットなら 1, 続くなら 0
今回は 1 ('test' というデータは一回で送れるから)
var firstByte = receivedData[0]; /** * fin * axxx xxxx first byte * 1000 0000 mask with 0x80 >>> 7 * --------- * 1 is final frame * 0 is continue after this frame */ var fin = (firstByte & 0x80) >>> 7;
RSV1-3
extention を使わないなら0
今回は拡張を使わないので無視。
opcode
Payload Data の説明
Payload Data とはヘッダ等じゃない実データ(ここでは 'test')
- %x0:continuation frame
- %x1:text frame
- %x2:binary frame
- %x3-7:reserved for further
- %x8:connection close
- %x9:ping
- %xA:pong
- %xB-F:reserved for further
今回は 0x1
/** * opcode * xxxx aaaa first byte * 0000 1111 mask with 0x0f * --------- * 0000 0001 is text frame */ var opcode = firstByte & 0x0f;
MASK
1 ならPayload Data がマスクされている。されていなければ 0。
Payload はブラウザが送るときは "必ずマスクする"
サーバが送るときは "絶対にマスクしない"
という決まりがある。
今はブラウザからだから 1
var secondByte = receivedData[1]; /** * mask * axxx xxxx second byte * 1000 0000 mask with 0x80 * --------- * 1000 0000 is masked * 0000 0000 is not masked */ var mask = (secondByte & 0x80) >>> 7; if (mask === 0) { assert.fail('browse should always mask the payload data'); }
Payload Length
Payload Data の長さ
最初の 7 bit を読んだとき
- 0-125:そのままそれが Payload の長さ
- 126:それより長いから、後続の 16bit が UInt16 として Payload の長さを表す
- 127:それよりも長いから、後続の 64bit が UInt64 として Payload の長さを表す
ここでは、処理するデータは 'test' と決め打ちにして、 7bit だけ読む実装にした。
だから、 Pyload Length = 4 になる。
/** * Payload Length * xaaa aaaa second byte * 0111 1111 mask with 0x7f * --------- * 0000 0100 4(4) * 0111 1110 126(next UInt16) * 0111 1111 127(next UInt64) */ var payloadLength = (secondByte & 0x7f); if (payloadLength === 0x7e) { assert.fail('next 16bit is length but not supported'); } if (payloadLength === 0x7f) { assert.fail('next 64bit is length but not supported'); }
Masking Key
Payload をマスクしているキーデータ
Payload Length の後に続く、 32bit のデータがこれになる。
MASK=1 だった場合は必ず付与される、後で Payload を複合するのに使う。
/** * masking key * 3rd to 6th byte * (total 32bit) */ var maskingKey = receivedData.readUInt32BE(2);
Payload Data
実データの部分、実際は Extention Data + Application Data
以降、末尾までのデータが Payload Data になっている。
しかし、実際これは Extention Data + Application Data となっている。
Extention Data とは、ネゴシエーションの過程で、 Extention を使うと決めた
場合に付与されるデータだから、今回は使わない。(というか Payload に含まれない)
Application Data は今回で言う 'test' のこと。
だから、 Payload Data == Application Data と考えていい。
Payload Length が 4byte とわかっているため、
32bit を Big Endian で読んであげればいい(TCP=BigEndian だからだと思ってるけどあってるのかな?)
var applicationData = receivedData.readUInt32BE(6);
読みだしたデータを、先ほどの Masking Key で unmask する。
(Masking Key との XOR をとってあげればいい)
var unmasked = applicationData ^ maskingKey;
この値を UTF-8 でエンコードしてあげればいいんだけど、
今の時点では Buffer じゃないから、一旦バッファにしてから、
Buffer.toString() をした。 (もっといい方法あるかも)
Buffer Node.js v0.8.26 Manual & Documentation
var unmaskedBuf = new Buffer(4); unmaskedBuf.writeInt32BE(unmasked, 0); var encoded = unmaskedBuf.toString(); console.log(encoded)' // test
これで無事、クライアントが投げた値が取り出せました。
Data Frame(S->C)
次はクライアントにデータ 'test' を送ります。
といっても、さっきの逆をやればいいだけです。
大きな違いは、
「サーバからクライアントに送る場合は、マスクしない」
という点です。
各値は以下のようになります。
- FIN:1
- OPCODE: 1
- MASK: 0
- Payload Length: 4
- Payload: 'test'
変換を手を抜いて、普通に書き込んだらこんな感じ。
一旦 Buffer オブジェクトに貯めて、 socket.end() に渡すと、クライアントに送ってくれます。
String からは charCodeAt() が使えます。
/** * Sending data to client * data must not mask */ var sendData = new Buffer(6); // FIN:1, opcode:1 // 0x81 = 10000001 sendData[0] = 0x81; // MASK:0, len:4 // 0x4 = 100 sendData[1] = 0x4; // payload data // send data "test" sendData[2] = 'test'.charCodeAt(0); sendData[3] = 'test'.charCodeAt(1); sendData[4] = 'test'.charCodeAt(2); sendData[5] = 'test'.charCodeAt(3); // send to client socket.end(sendData);
うまくいけば、クライアントでは onmessage イベントが発生し、data フィールドに送ったデータが格納されます。
ws.onmessage = function(message) { console.log(message.data); // test };
まとめ
WebSocket の仕様自体は、全部実装しようとするとちょっと大変だけど、今はまだ読もうと思えば読める程度の量だと思います。
より大きな問題は、ここまでに生まれては消えたプロトコルの実装を持った、古いブラウザとの互換性でしょう。そこを考えなければ、頑張れば実装できなくはないかも。
しかし、現在の RFC にしても、まだまだ仕様が変わる可能性は、無くはないし、途中で出てきた Extention や Subprotocol, また別で進んでる multiplexing などによって、今後もう少し変わる可能性があります。
WebSocket をアプリで使う場合は、おとなしくメンテナンスされている実装を使うのが吉ですが、こうして自分でちょっとでも実装すると得られるものも多いのでお勧めです。
JS はもともとバイナリを処理する文化が無かったため、Node.js は独自に Buffer モジュールを作っていますが、JavaScript には TypedArray という仕様が最近出てきているので、いずれ Node もそっちを使うことになると思います。
もしかしたら、仕様の読み違いなどあるかもしれません、指摘、質問などフィードバック歓迎です。