WebSocket(hybi-07)でechoサーバを作ってみた

はじめに

以前、WebSocket Draft 76でechoサーバーを作ってみましたが、今回はhybi-07に対応したバージョンを作ってみました。しばらく見ない間に大きく仕様が変わっていて驚きました(*_*;

今回の実行環境は以下の通りです。

  • サーバ
  • クライアント

最新のWebSocketプロトコルを調べるために作成したプログラムなんですが、以下の機能については未実装です。

  • バイナリデータと断片化されたフレームの送受信
    • クライアントからこれらを送信する方法がわからなかったので
  • 拡張機能(Sec-WebSocket-Extensions)

実行の手順

まず、以下からプログラムをダウンロードします。

ダウンロードしたフォルダのechoServer.pyがechoサーバの実体で、htmlフォルダ以下にクライアント側のHTMLとJavaScriptファイルがあります。
echoサーバが使用するポート番号などの設定はechoServer.pyの以下の部分を直接編集して下さい。

class TCPHandler(SocketServer.BaseRequestHandler):

    def setup(self):
        """ 初期化処理を行うメソッド

        SocketServer.BaseRequestHandlerのsetup()メソッドを上書きする。
        handle()メソッドより前に呼び出される。
        """
        self.configObj = {
            'host': '',
            'port': self.server.server_address[1],
            'resource': '/',
            'origin': '',      # 何も指定しなければすべてのoriginを許可する
            'protocol': [''],
            #'protocol': ['video', 'talk', 'chat'],  # 複数のサブプロトコルをサポートする場合
            'pingInterval': 0  # pingする間隔(単位:秒)。0ならpingしない
        }

以下省略

実行時の様子

以下は、ローカルにechoサーバとWebサーバを立てhybi-07に対応しているFirefox v6(もしくは7)を用いてechoサーバとやり取りしたときの様子です。

次に、実際にechoサーバを書いてみてわかったことなどをまとめてみます。

クライアントハンドシェイク

hybi-07に対応したFirefoxからエコーサーバに接続しようとすると、以下のようなハンドシェイクデータが送信されます。

GET / HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:7.0a1) Gecko/20110605 Firefox/7.0a1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive, Upgrade
Sec-WebSocket-Version: 7
Sec-WebSocket-Origin: http://localhost
Sec-WebSocket-Key: bl3e0R+mnGJ9KkCFJw0rOA==
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket

ヘッダがいろいろありますが、ハンドシェイクに必要なのは以下の行だけです。

GET / HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive, Upgrade
Sec-WebSocket-Version: 7
Sec-WebSocket-Origin: http://127.0.0.1
Sec-WebSocket-Key: bl3e0R+mnGJ9KkCFJw0rOA==
Upgrade: websocket

Draft 76と違ってSec-WebSocket-Versionフィールドが新たに増え、Sec-WebSocket-Keyは一つに減りました。また、クライアントハンドシェイク内のSec-WebSocket-Keyフィールドの値はサーバハンドシェイク内のSec-WebSocket-Acceptフィールドの値を作成されるために用いられます。

サーバハンドシェイク

クライアントから上記のハンドシェイクデータを受信したサーバは、以下のようなハンドシェイクデータをクライアントに送信します。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: YjV0s2D16GYJYuGy1aIZF0g/bIs=

Sec-WebSocket-Acceptフィールドの値は、クライアントハンドシェイク内のSec-WebSocket-Keyの値(上記の例の"bl3e0R+mnGJ9KkCFJw0rOA=="という文字列)の後に"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"という文字列を加えたもの(文字列"bl3e0R+mnGJ9KkCFJw0rOA==258EAFA5-E914-47DA-95CA-C5AB0DC85B11")をSHA-1でハッシュ化して得た20バイトのハッシュ("0x62 0x35 0x74 0xb3 0x60 0xf5 0xe8 0x66 0x9 0x62 0xe1 0xb2 0xd5 0xa2 0x19 0x17 0x48 0x3f 0x6c 0x8b")をbase64変換することで求めることができます。

データフレーム

ハンドシェイクに成功するとサーバ・クライアント間で双方向に通信することができます。現在の仕様(Section 4.2)では以下のようなフレームを用いてデータの送受信を行います。

 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/63)           |    
|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ビットがセットされ、クライアントからのデータは4バイトのMaking-keyによってマスクされています。もし、サーバがクライアントから以下のようなMasking-keyとPayload Dataを持つテキストフレームを受信したと仮定します。

Masking-key : 0x37 0xfa 0x21 0x3d
Payload Data: 0x7f 0x9f 0x4d 0x51 0x58

このとき、サーバは以下のような処理を行ってマスクされる前のデータを求めます。

maskingKey  = [0x37, 0xfa, 0x21, 0x3d]
payloadData = [0x7f, 0x9f, 0x4d, 0x51, 0x58]
unmaskedData = []
for i in range(len(payloadData)):
    unmaskedData.append(payloadData[i] ^ maskingKey[i%4])  # XOR
# unmaskedData => [0x48, 0x65, 0x6c, 0x6c, 0x6f]
print [chr(x) for x in unmaskedData]
['H', 'e', 'l', 'l', 'o']

このようにMasking-keyとPayload Dataを1バイトづつXORを取ることでマスクされる前のデータ(この例では"Hello"という文字列)を得ることができます。
また、送受信できるデータの種類はテキスト以外にもあり、opcodeの値を用いて指定することができます。

frame-opcode            = %x0 ; 継続フレーム
                        / %x1 ; テキストフレーム
                        / %x2 ; バイナリフレーム
                        / %3-7 ; 非制御フレームとして予約されている
                        / %x8 ; 接続を閉じる
                        / %x9 ; ping
                        / %xA ; pong
                        / %xB-F ; 制御フレームとして予約されている
シングルフレーム

FINビットがセットされたフレームで、データが一つのフレームの中に収まっている。

継続フレーム

FINビットがセットされていないフレーム。テキストフレームやバイナリフレームでは、ある程度の大きさのデータは断片化されて複数のフレームに分けて送受信されることがある。現在の仕様(Section 4.7)には、"Hello"という文字列を"Hel"と"lo"に分けたときの例として、以下のように書かれています。

   o  A fragmented unmasked text message
      *  0x01 0x03 0x48 0x65 0x6c (contains "Hel")
      *  0x80 0x02 0x6c 0x6f (contains "lo")

これを上記のデータフレームの構造に当てはめると以下のようになります。

*1フレーム目
 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
+-+-+-+-+-------+-+-------------+-------+-------+-------+-------+
|0|0|0|0|0 0 0 1|0|0 0 0 0 0 1 1|0 1 0 0|1 0 0 0|0 1 1 0|0 1 0 1|
+ - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - +
|               |               |               |               |    
|      0x01     |      0x03     |   0x48("H")   |   0x65("e")   |    
|               |               |               |               |    
+---------------+---------------+---------------+---------------+
|0 1 1 0|1 1 0 0|
+ - - - + - - - +
|               |
|   0x6c("l")   |
|               |
+---------------+

*2フレーム目
 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
+-+-+-+-+-------+-+-------------+-------+-------+-------+-------+
|1|0|0|0|0 0 0 0|0|0 0 0 0 0 1 0|0 1 1 0|1 1 0 0|0 1 1 0|1 1 1 1|
+ - - - + - - - + - - - + - - - + - - - + - - - + - - - + - - - +
|               |               |               |               |    
|      0x80     |      0x02     |   0x6c("l")   |   0x6f("o")   |    
|               |               |               |               |    
+---------------+---------------+---------------+---------------+

実際にFirefoxで大きなサイズ(70000バイトくらい)のテキストを送信してみましたが、仕様のように断片化されたフレームの送信を確認することが出来ませんでした。

クローズフレーム

テキストやバイナリフレームとは異なり、WebSocket接続状態に関するフレームがあります。
クローズフレームは接続を閉じる際にサーバ・クライアントの両方から送信されるフレームで、内部に2バイトのステータスコードを含んでいます。これらのコードの値を用いることで接続を閉じる原因を相手に通知することが出来ます。いくつかのコードは仕様の中(Section 7.4)で定義されていますが、2バイト以降のデータにに関しては特に定義されていないので、デバッグ用途としてUTF-8文字列を埋め込むこともできるそうです(Section 4.5.1参照)。

Pingフレーム

これまでのWebSocketではサーバ・クライアント間で接続が確立したあと一定時間通信がない場合、セッションテーブルが切れ通信が出来なくなる可能性があったそうですが、現在の仕様ではPingフレームを送信することで解決できるようです。Firefox v6, v7ではabout:configでnetwork.websocket.timeout.ping.requestの値を変更することでPingフレームを送信させることができます。その際、フレーム内のPayload Dataには"PING"という文字列が埋めこまれていました。

Pongフレーム

接続先からPingフレームを受信したら、ただちにPongフレームを返さなければなりません。その際、フレーム内のPayload Dataの値はPingフレームの値と同じものを埋め込む必要があるそうです(Section 4.5.3参照)。

おわりに

今回、実装できなかった機能は、今後新たに情報が入り次第対応していこうかなーっと思ってます。