サーバを作りながら学ぶWebSocketプロトコル

WebSocketって何?

WebSocketは、Javascriptでサーバとリアルタイム双方向通信をする仕組みです。概要は第1回 WebSocket登場までの歴史:Jettyで始めるWebSocket超入門|gihyo.jp … 技術評論社によくまとまっています。

この記事ではWebSocketサーバを実装しながら、どういうプロトコルかを解説します。サンプルコードはWebSocket Draft 76でechoサーバーを作ってみた - いろいろな何かのものを参考にさせていただいています。ありがとうございます。

※WebSocketプロトコルは現在ドラフトの段階なので、そのうち仕様が変わる可能性があります。この記事は20111/23時点の情報です。

プロトコル概要

WebSocketで通信を行なうおおまかな流れは次のようになります。

  1. クライアントとサーバの間でハンドシェイクを行ない、接続を確立する。
  2. データを双方向でやりとりする。

これを順番に説明してきます。

クライアントとサーバの間でのハンドシェイク


クライアントが接続するたびに、サーバには次のようなリクエストが送信されます。

GET /demo HTTP/1.1
Host: example.com
Connection: Upgrade
Sec-WebSocket-Key2: 12998 5 Y3 1  .P00
Sec-WebSocket-Protocol: sample
Upgrade: WebSocket
Sec-WebSocket-Key1: 4 @1  46546xW%0l 1 5
Origin: http://example.com

^n:ds[4U

※ 赤字の部分はハンドシェイクに使われる情報なので、接続のたびに変化します。

このリクエストに対して、適切なレスポンスを返すことで接続が確立されます。

まず、Sec-WebSocket-Key1Sec-WebSocket-Key2は暗号化されているので復号する必要があります。復号は数字部分/スペースの個数で行なうので、例えば12998 5 Y3 1 .P00は1299853100/5=259970620です。

これをコードにすると次のようなります。

def decode(self, s):
  '''ハンドシェイク中のキーを解析する。'''
  # 数字の部分だけをとりだす
  n = filter(lambda c : c.isdigit(), s)
  # スペースだけをとりだす
  m = filter(lambda c : c == ' '   , s)
  # 数字部分 / スペースの個数
  return int(n) / len(m)

次に、復号したSec-WebSocket-Key1(32bits)、同じく復号した Sec-WebSocket-Key2(32bits) 、リクエストのボディ部(128bits) を順に並べてmd5を計算し、これをHTTPレスポンストの本体にして送り返すことでハンドシェイクを確立します。
これをコードにすると次のようになります。

part1 = self.decode(fields['sec-websocket-key1'])
part2 = self.decode(fields['sec-websocket-key2'])

# 値を32bitのビッグエンディアンのバイナリーにする
CHALLENGE = struct.pack('>I', part1)

# 値を32bitのビッグエンディアンのバイナリーにする
CHALLENGE += struct.pack('>I', part2)

CHALLENGE += key

# /chalenge/のMD5 fingerprintを/response/に入れる
RESPONSE = hashlib.md5(CHALLENGE).digest()

あとはWebSocketプロトコルにそったヘッダをつけて、クライアントに送り返します。

HTTP/1.1 101 WebSocket Protocol Handshake
Upgrade: WebSocket
Connection: Upgrade
Sec-WebSocket-Origin: http://example.com
Sec-WebSocket-Location: ws://example.com/demo
Sec-WebSocket-Protocol: sample

8jKS'y:G*Co,Wxa-

以上のことを図にまとめると次のようになります。

データ送受信

接続を確立したら、データの送受信ができます。WebSocketではデータの送受信はフレームという単位で行います。

テキストフレームは先頭に\x00、末尾に\xFFがつきます。例えば、"hoge"を送りたい場合は、"\x00hoge\xFF"になります。制御用のフレームもあるけど、こっちはなくてもとりえず動作します。

逆にフレームからテキストを取り出す場合は先頭の\x00と末尾の\xFFを除去すればいいので、次のようなコードになります。

data = self.request.recv(1024)

xs = itertools.takewhile(lambda x : ord(x) != 0xFF, data[1:])
self.RAW_DATA = "".join(list(xs))

コード

以上のことをまとめると以下のようなコードになります。

https://gist.github.com/737068

実行例

参考元の記事と同様に実行できます。

要するに

  • ハンドシェイクはちょっとややこしいけど、データの送受信は簡単
  • ライブラリ使ったほうが楽だよ。