WebSocket Draft 76でechoサーバーを作ってみた

もう3ヵ月近く前ですが、WebSocketプロトコルのDraft76が公開されました。このDraft76では、それまでのDraft75とハンドシェイクの内容が変わっていました。
draft-hixie-thewebsocketprotocol-76 - The WebSocket protocol

ということで、新しい仕様とPythonの勉強がてらにechoサーバーを書いてみました!実行環境は以下の通りです。

以下、サーバー側のソースコード(WSP76_echo.py)です。githubとか使えよ!って感じはしますが、ただのサンプルなので面倒くさ(ry

#!/usr/bin/env python
#-*- coding:UTF-8 -*-

from optparse import OptionParser
import SocketServer
import struct
import hashlib

class MyTCPHandler(SocketServer.BaseRequestHandler):
    def handle(self):
        '''クライアントとTCP接続したときに呼び出される
        '''
        self.ishandshake = False
        self.CLIENT_TERMINATED = False
        while True:
            self.data = self.request.recv(1024)
            if self.ishandshake:  # 既にハンドシェイクに成功している場合
                if len(self.data):
                    self.checkDataFrame()
                else:   # Chrome 6用の処理
                    self.CLIENT_TERMINATED = True
                if self.CLIENT_TERMINATED:
                    print '/client terminated/フラグがセットされたのでコネクションを切断します'
                    self.closeConnection()
                    break
                else:
                    print '[%s:%s] %s' % (self.client_address[0], self.client_address[1], self.RAW_DATA)
                    if len(self.RAW_DATA) != 0:
                        self.request.send('\x00' + self.RAW_DATA + '\xFF')
                        print self.RAW_DATA
                        self.RAW_DATA = ''
            else:  # まだハンドシェイクに成功していない場合
                if self.checkHandshake() and self.sendHandshake():  # クライアントとハンドシェイクを試みる
                    self.ishandshake = True
                    print '%s:%s とのハンドシェイクに成功' % (self.client_address[0], self.client_address[1])
                else:
                    print '%s:%s とのハンドシェイクに失敗' % (self.client_address[0], self.client_address[1])
                    self.data = ''
                    self.closeConnection()


    def checkDataFrame(self):
        '''ハンドシェイク後に受信したデータを解釈する関数

        受信したデータフレームの/type/を調べtext-frameかbinary-frame, closing-frameを
        判断してデータをRAW_DATAに入れる。
        '''
        self.TYPE = self.data[0]
        if ord(self.TYPE) & 0x80:  # /type/の上位ビットがセットされている場合
            if self.TYPE != '\xFF':
                self.CLIENT_TERMINATED = True
            else:  # /type/が0xFFの場合
                self.LENGTH = 0
                tmp = 1  # 既に読み込んだバイト数を保持する変数
                for self.B in self.data[1:]:  # 最初の1バイトは/type/に入れたので無視する
                    tmp += 1
                    if self.B != '\x00':  # binary-frameのLengthを求める
                        self.CLIENT_TERMINATED = True
                        self.B_V = ord(self.B) & 0x7F
                        self.B_V += self.LENGTH / 128
                        self.LENGTH = self.B_V
                        if self.B & 0x80:
                            continue
                        else:
                            self.RAW_DATA = self.data[tmp:tmp+self.LENGTH]
                            print '%dバイトのデータを読み込んだ' % len(self.RAW_DATA)
                            break
                    elif self.LENGTH == 0:  # 0xFF 0x00のclosing-frameを受信したとき
                        print '%s:%s からclosing-frameを受信' % (self.client_address[0], self.client_address[1])
                        self.CLIENT_TERMINATED = True
                        break
        elif self.TYPE != '\x00':
            self.CLIENT_TERMINATED = True
        else:  # text-frameの場合
            self.RAW_DATA = ''
            for self.B in self.data[1:]:
                if ord(self.B) != 0xFF:  # text-frameの終わりでなければ
                    self.RAW_DATA += self.B
                else:  # text-frameの終わりなら
                    break


    def closeConnection(self):
        '''クライアントに対してclosing-frameを送信する関数
        '''
        print '%s:%s にclosing-frameを送信' % (self.client_address[0], self.client_address[1])
        self.request.send('\xFF\x00')
        self.finish()

                    
    def sendHandshake(self):
        '''クライアントにハンドシェイクを送信する関数
        '''
        # Sec-WebSocket-Locationフィールドの値を作成
        self.LOCATION = 'wss://' if SECURE_FLAG else 'ws://'
        self.LOCATION += HOST
        self.LOCATION += ':' + str(PORT) if PORT else ''
        self.LOCATION += RESOURCE_NAME

        # チャレンジ・レスポンス方式
        tmp = ''
        for c in self.KEY_1:
            tmp += c if c.isdigit() else ''
        self.KEY_NUMBER_1 = int(tmp)  # /key_1/内の[0-9]だけを取り出して/key-number_1/に入れる
        tmp = ''
        for c in self.KEY_2:
            tmp += c if c.isdigit() else ''
        self.KEY_NUMBER_2 = int(tmp)  # /key_2/内の[0-9]だけを取り出して/key-number_2/に入れる
        self.SPACES_1 = self.KEY_1.count(' ')  # /key_1/内の空白文字の数を/spaces_1/に入れる
        self.SPACES_2 = self.KEY_2.count(' ')  # /key_2/内の空白文字の数を/spaces_2/に入れる
        if self.SPACES_1 == 0 or self.SPACES_2 == 0:
            print 'Sec-WebSocket-Keyフィールドの値に空白文字が無かった'
            return False
        # /key-number_1/を/spaces_1/で除算したものを/part_1/に入れる
        self.PART_1, tmp = divmod(self.KEY_NUMBER_1, self.SPACES_1)
        if tmp != 0:  # 割りきれなかった場合
            print 'Sec-WebSocket-Key1フィールドの値が不適切'
            return False
        # /key-number_2/を/spaces_2/で除算したものを/part_2/に入れる
        self.PART_2, tmp = divmod(self.KEY_NUMBER_2, self.SPACES_2)
        if tmp != 0:  # 割りきれなかった場合
            print 'Sec-WebSocket-Key2フィールドの値が不適切'
            return False
        # /part_1/, /part_2/, /key_3/をくっつけたものを/chalenge/に入れる
        self.CHALLENGE = struct.pack('>I', self.PART_1)  # 値を32bitのビッグエンディアンのバイナリーにする
        self.CHALLENGE += struct.pack('>I', self.PART_2) # 値を32bitのビッグエンディアンのバイナリーにする
        self.CHALLENGE += self.KEY_3
        self.RESPONSE = hashlib.md5(self.CHALLENGE).digest() # /chalenge/のMD5 fingerprintを/response/に入れる

        # 送信するハンドシェイクデータの作成
        handshakeData = 'HTTP/1.1 101 WebSocket Protocol Handshake\r\n'
        handshakeData += 'Upgrade: WebSocket\r\n'
        handshakeData += 'Connection: Upgrade\r\n'
        handshakeData += 'Sec-WebSocket-Origin: ' + ORIGIN + '\r\n'
        handshakeData += 'Sec-WebSocket-Location: ' + self.LOCATION + '\r\n'
        if PROTOCOL:
            handshakeData += 'Sec-WebSocket-Protocol: ' + PROTOCOL + '\r\n'
        handshakeData += '\r\n' + self.RESPONSE
        self.request.send(handshakeData)  # ハンドシェイクデータの送信

        print '>' * 10, '%s:%s にハンドシェイクデータを送信した' % (self.client_address[0], self.client_address[1])
        print handshakeData
        print '>' * 40

        return True
            
    def checkHandshake(self):
        '''クライアントから受信したハンドシェイクデータが正しいか調べる関数
        '''
        print '<' * 10, '%s:%s からハンドシェイクデータを受信した' % (self.client_address[0], self.client_address[1])
        print self.data
        print '<' * 40

        # ハンドシェイクの1行目を調べる
        fields = self.data.split('\r\n')
        field = fields[0].split()
        if field[0] != 'GET':
            print 'ハンドシェイクの1行目が GET から始まっていないので不適切'
            return False
        if field[1] != RESOURCE_NAME:
            print 'ハンドシェイクの1行目(/resource name/)が不適切'
            return False
        # 2行目以降の連続したフィールドをバラす
        blankLine = False
        d = dict()
        for field in fields[1:]:
            if field == '':
                blankLine = True
                continue
            if not blankLine:
                fieldName, fieldValue = field.split(': ', 1)
                d[fieldName.lower()] = fieldValue  # フィールド名は大文字と小文字を区別しない
            else:
                self.KEY_3 = field
                break
        # それぞれのフィールドを調べる
        if not 'upgrade' in d.keys() or d['upgrade'].lower() != 'websocket':
            print 'ハンドシェイク内のUpgradeフィールドが不適切'
            return False
        if not 'connection' in d.keys() or d['connection'].lower() != 'upgrade':
            print 'ハンドシェイク内のConnectionフィールドが不適切'
            return False
        tmp = HOST + ':' + str(PORT) if PORT != 80 else HOST
        if not 'host' in d.keys() or not d['host'] == tmp:
            print 'ハンドシェイク内のHostフィールドが不適切'
            return False
        if not 'origin' in d.keys() or d['origin'].lower() != ORIGIN:
            print 'ハンドシェイク内のOriginフィールドが不適切'
            return False
        if PROTOCOL:
            if not 'sec-websocket-protocol' in d.keys() or d['sec-websocket-protocol'] != PROTOCOL:
                print 'ハンドシェイク内のSec-WebSocket-Protocolが不適切'
                return False
        if not 'sec-websocket-key1' in d.keys() or not d['sec-websocket-key1']:
            print 'ハンドシェイク内のSec-WebSocket-Key1が不適切'
            return False
        else:
            self.KEY_1 = d['sec-websocket-key1']
        if not 'sec-websocket-key2' in d.keys():
            print 'ハンドシェイク内のSec-WebSocket-Key2が不適切'
            return False
        else:
            self.KEY_2 = d['sec-websocket-key2']

        return True


if __name__ == "__main__":
    usage = u'%prog [-p ポート番号] オリジン [-s サブプロトコル名] [-r リソース]'
    parser = OptionParser(usage=usage)
    parser.add_option('-p', '--port', dest='port', type='int', default=8080,help=u'ポート番号(デフォルトは8080)')
    parser.add_option('-s', '--subprotocol', dest='subprotocol', help=u'サブプロトコル名')
    parser.add_option('-r', '--resource', dest='resource', default='/', help=u'リソース')
    options, args = parser.parse_args()
    if len(args) < 1:
        parser.error('引数を1つ入力してください')
    elif len(args) > 1:
        parser.error('引数が多いです')

    HOST = 'localhost'
    PORT = options.port
    ORIGIN = args[0]
    PROTOCOL = options.subprotocol
    RESOURCE_NAME = options.resource
    SECURE_FLAG = False

    server = SocketServer.ThreadingTCPServer((HOST, PORT), MyTCPHandler)
    print 'Ctrl-cで終了します'
    server.serve_forever()

次に、クライアント側のソースコード(clientSample.html)です。

<html>
    <head>
        <style type="text/css">
            .log {
                color: red;
            }
        </style>
        <script>
            ws = new WebSocket("ws://localhost:8080");
            ws.onopen = function (e) {
                var resultAreaObj = document.getElementById('result');
                resultAreaObj.innerHTML += '<span class="log">onopen</span>' + '<br>'
            };
            ws.onclose = function (e) {
                var resultAreaObj = document.getElementById('result');
                resultAreaObj.innerHTML += '<span class="log">onclose</span>' + '<br>'
            };
            ws.onmessage = function (e) {
                var resultAreaObj = document.getElementById('result');
                resultAreaObj.innerHTML += e.data + '<br>'
            };
            ws.onerror = function () {
                var resultAreaObj = document.getElementById('result');
                resultAreaObj.innerHTML += '<span class="log">onerror</span>' + '<br>'
            };
            send = function () {
                var textFieldObj = document.getElementById('textField');
                var data = textFieldObj.value;
                if (data) {
                    ws.send(data);
                    textFieldObj.value = '';
                }
            };
        </script>
    </head>
    <body>
        <input type='text' id='textField'/>
        <button onclick='send();'>send</button><br>
        <button onclick='ws.close();'>close</button>
        <hr>
        <div id='result'></div>
    </body>
</html>

これらのサンプルを動かすにはWebサーバーを立てて行う方法と、Webサーバーを立てずに行う2つの方法があります。

Webサーバーを立てて行う方法

まずは、上記のクライアント側のソースコードをclientSample.htmlという名前でDocumentRoot以下に置きます。そして、サーバー側のソースコードをWSP76_echo.pyという名前で保存し、以下のように実行します。第一引数にはハンドシェイク内のOriginフィールドで使用する文字列を指定します。

 % ./WSP76_echo.py "http://localhost"
Ctrl-cで終了します

echoサーバーが実行できたらWebSocket Draft76に対応したWebブラウザーからclientSample.htmlにアクセスします。

ハンドシェイクに成功すると、上のスクリーンショットのようにonopenと表示されます。

Webサーバーを立てずに行う方法

いちいちhttpd起動するの('A`)マンドクセっという方は、以下の方法でも動かすことができます。
まずはechoサーバーを起動します。Webブラウザーによって第一引数の値が変わるので注意して下さい。

Firefox 4の場合
 % ./WSP76_echo.py "file://"
Ctrl-cで終了します
Chrome 6の場合
 % ./WSP76_echo.py "null"
Ctrl-cで終了します

次に、clientSample.htmlを(ドラック&ドロップなどで)直接Webブラウザーから開いて下さい。ブラウザーのロケーションバーはfile://ファイルへのパス/clientSample.htmlという表示になっていると思います。このとき、onopenと表示されていればハンドシェイク成功です。

echoサーバーの使い方

上記の2つのどちらかの方法を行い、Webブラウザー上でonopenと表示されればハンドシェイクに成功しているので、入力フィールドに適当にメッセージを入力してsendボタンを押してみて下さい。以下のように、入力したメッセージと同じモノが表示されるはずです。また、closeボタンを押すとclose()メソッドを呼び出してWebSocketコネクションを閉じようとします。

このとき、echoサーバーを実行しているターミナルの画面は以下のような出力結果になっていると思います。

 % ./WSP76_echo.py "http://localhost"
Ctrl-cで終了します
<<<<<<<<<< 127.0.0.1:51688 からハンドシェイクデータを受信した
GET / HTTP/1.1
Host: localhost:8080
Connection: Upgrade
Sec-WebSocket-Key1: 36 7  95   56 48i9
Upgrade: WebSocket
Origin: http://localhost
Sec-WebSocket-Key2: (1wj61W97 74=- ! -&65%2

@=&#65533;&#65533;p&#65533;f[
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
>>>>>>>>>> 127.0.0.1:51688 にハンドシェイクデータを送信した
HTTP/1.1 101 WebSocket Protocol Handshake
Upgrade: WebSocket
Connection: Upgrade
Sec-WebSocket-Origin: http://localhost
Sec-WebSocket-Location: ws://localhost:8080/

a&#65533;{&#65533;^&x&#65533;&#65533;&#65533;K\&#65533;&#65533;V&#65533;
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
127.0.0.1:51688 とのハンドシェイクに成功
[127.0.0.1:51688] hello
hello
[127.0.0.1:51688] ハッピー
ハッピー
[127.0.0.1:51688] うれピー
うれピー
[127.0.0.1:51688] よろピクねーーーー
よろピクねーーーー
127.0.0.1:51688 からclosing-frameを受信
/client terminated/フラグがセットされたのでコネクションを切断します
127.0.0.1:51688 にclosing-frameを送信

もし、何らかの理由によりハンドシェイクが失敗するとWebブラウザーには何もメッセージが表示されませんが、echoサーバーを実行しているターミナルの画面には失敗した理由が出力されているはずです。

echoサーバー実行時のオプションについて

このechoサーバーは実行時にオプションを指定することで使用するポート番号、サブプロトコル、リソースを指定することができます。と言っても、ポート番号変更以外のオプションはハンドシェイク内の値が変わるだけで、echoサーバーとしての動作は何も変わりませんが(^^;)

 % ./WSP76_echo.py -h
Usage: WSP76_echo.py [-p ポート番号] オリジン [-s サブプロトコル名] [-r リソース]

Options:
  -h, --help            show this help message and exit
  -p PORT, --port=PORT  ポート番号(デフォルトは8080)
  -s SUBPROTOCOL, --subprotocol=SUBPROTOCOL
                        サブプロトコル名
  -r RESOURCE, --resource=RESOURCE
                        リソース

Chrome 6とFirefox 4の挙動の違いについて

echoサーバーのMyTCPHandlerクラスのhandle()メソッド内では、以下のような処理を行っています。

class MyTCPHandler(SocketServer.BaseRequestHandler):
    def handle(self):
        '''クライアントとTCP接続したときに呼び出される
        '''
        self.ishandshake = False
        self.CLIENT_TERMINATED = False
        while True:
            self.data = self.request.recv(1024)
            if self.ishandshake:  # 既にハンドシェイクに成功している場合
                if len(self.data):
                    self.checkDataFrame()
                else:   # Chrome 6用の処理      ← ココの部分!
                    self.CLIENT_TERMINATED = True
                if self.CLIENT_TERMINATED:
                    print '/client terminated/フラグがセットされたのでコネクションを切断します'
                    self.closeConnection()
                    break
                else:
                    以下省略

この部分では、既にハンドシェイク済みのクライアントから受信したデータ(self.request.recv()メソッドの返り値)のサイズが0なら、Chrome 6用の処理としてself.CLIENT_TERMINATEDフラグを立てています。クライアントのChrome 6がWebSocketクラスのclose()メソッドを呼び出したとき、self.request.recv()メソッドは0を返します(クライアントがTCP接続を切断したって意味?)。
IETFdraft-hixie-thewebsocketprotocol-76 - The WebSocket protocolには、コネクションを閉じるときはclosing-frame(0xFFと0x00)を送信するって書いているので、クライアントがclose()メソッドを呼び出したときはrecv()メソッドの返り値のサイズが2になるはずなんですが、Chrome 6の挙動はそうじゃないので上記のような処理を行っています。ちなみに、Firefox 4ではちゃんとclosing-frameを送信してくれるので、checkDataFrame()メソッド内で/client terminated/フラグを立てることができます。

その他

今回作ったechoサーバーでは、Draft 76から追加されたチャレンジ・レスポンスの部分を以下のように書きました。

        # /part_1/, /part_2/, /key_3/をくっつけたものを/chalenge/に入れる
        self.CHALLENGE = struct.pack('>I', self.PART_1)  # 値を32bitのビッグエンディアンのバイナリーにする
        self.CHALLENGE += struct.pack('>I', self.PART_2) # 値を32bitのビッグエンディアンのバイナリーにする
        self.CHALLENGE += self.KEY_3
        self.RESPONSE = hashlib.md5(self.CHALLENGE).digest() # /chalenge/のMD5 fingerprintを/response/に入れる

/part_1/, /part_2/は"expressed as a big-endian 32 bit integer"、/response/は"the MD5 fingerprint of /challenge/ as a big-endian 128 bit string"と書かれていました。Pythonでバイナリデータの扱い方がよく分からなかったのでいろいろググった結果、上記のようなコードになりましたが、こんなコードで良いんですかね。ちゃんと環境に依存しないコードを書きたいんですが(^_^;)