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で終了します
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 @=��p�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�{�^&x���K\��V� >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> 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接続を切断したって意味?)。
IETFのdraft-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でバイナリデータの扱い方がよく分からなかったのでいろいろググった結果、上記のようなコードになりましたが、こんなコードで良いんですかね。ちゃんと環境に依存しないコードを書きたいんですが(^_^;)