PythonとJavascriptでWebsocket通信を実装する



基本的にwebサーバーがデータを送信するのはクライアントであるブラウザからリクエストを受け付けた時ですが、
リクエストなしにサーバーからブラウザへデータを送りたいときもあると思います。

これを実現するためにいろいろな仕組みが用意されているようですが、
その中でも期待されてるのがWebSocketらしいです。

そこでWebSocketがどんなことしているのかプログラムを交え見ていきたいと思います。

ハンドシェイク


接続を確立してデータを送受信するためにクライアントとサーバーはハンドシェイクを行う必要があります。


クライアントが以下のようなハンドシェイクリクエストを送信します。

GET / HTTP/1.1
Host: 192.168.11.8:9999
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.117 Mobile Safari/537.36
Upgrade: websocket
Origin: http://192.168.11.8
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Sec-WebSocket-Key: QM9LlNTkaJ2ytQ5w4v4Y0w==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits


これを受け取ったサーバーは以下の形式のレスポンスをします。

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: z7AQHuYX5rEzNHac/56FiVv4dV0=


「z7AQHuYX5rEzNHac/56FiVv4dV0=」という文字列は
リクエストにあったSec-WebSocket-Keyと258EAFA5-E914-47DA-95CA-C5AB0DC85B11というマジックナンバーのハッシュをbase64エンコードしたものです。

レスポンスの内容に特に問題がなければ接続が確立します。
確立が完了すればフレームという決まったパケット形式に従ってデータの送受信が行えます。

フレーム


WebSocketでのやり取りされるパケットの形式は以下のページに定義されています。

https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers#Exchanging_data_frames

さらに非公式とされてはいますが、日本語でかかれたマニュアルもありました。
https://triple-underscore.github.io/RFC6455-ja.html

スクラッチでサーバーを実装する場合はこの形式を意識してパケットを構築する必要があります。

実装


実際にやってみましょう。

クライアント

<html>
    <head>
        <script>
            var socket = new WebSocket("ws://192.168.11.8:9999/");

            socket.onopen = function(e) {
                console.log("onopen");
            }

            socket.onmessage = function(e) {
                console.log(e.data)
            }
        </script>
    </head>
    <body>
    </body>
</html>


WebSocketのインスタンスを作成するわけですが、引数に以下の形式の文字列を渡します。

プロトコルにwsが指定されていますが、ここがwssの場合セキュアな通信をするという意味になります。

httpsで運用されているシステムであればwssを指定するようです。

onopenは接続が確立されたときに実行される関数で、
onmessageはデータを受信したときに実行される関数です。

ws://{ホスト}:{ポート}/

サーバー


WebSocketサーバーはどんな言語で実装しても問題ありません。
今回はスクラッチでPythonを使って実装します。
ライブラリーを使えばハンドシェイクやパケットの構築など手間なことをやってくれますが、
それが使えない環境の場合は実装する必要があります。

from socket import *
from base64 import b64encode
from hashlib import sha1
from struct import pack
from time import sleep

magicsockey = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

soc = socket(AF_INET, SOCK_STREAM)
soc.setsockopt(SOL_SOCKET, SO_REUSEADDR,1)
soc.bind(("192.168.11.8", 9999))
soc.listen(10)
print("waiting for connection")

def create_frame(data):
    status = 129
    dlen = len(data)
    pack_f = "BB"
    return pack(pack_f, status, dlen)+data

def extract_hreq(req):
    reqdic = {}
    lines = req.split("\r\n")
    for line in lines:
        if ':' in line:
            sep = line.split(":")
            reqdic[sep[0]] = sep[1].replace(" ","")
    return reqdic

def send_recv(acc):
    num = 1
    while True:
        data = "echo {0}".format(num)
        num+=1
        frame = create_frame(data)
        acc.send(frame)
        sleep(1)

def handshake(acc, addr):
    req = acc.recv(4096)
    req = extract_hreq(req)
    key = b64encode(sha1(req['Sec-WebSocket-Key']+magicsockey).digest())
    res = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {0}\r\n\r\n"
    res = res.format(key)
    acc.send(res)

while True:
    acc, addr = soc.accept()
    print(addr)
    handshake(acc, addr)
    send_recv(acc)


先述したハンドシェイクはhandshake関数内で行われています。

そしてフレームの構築はcreate_frame関数が行います。

フレームについてはそこそこボリュームがあるので今回の実装に関係のある部分について触れておきます。
https://triple-underscore.github.io/RFC6455-ja.html#section-5.6

statusという変数に129という数字が割り振られていますが、
これによりフレームのFINbitに1が指定され、opecodeにも1が指定されます。
bit列にすると10000001です。
これはこのフレームはメッセージの最後であること、メッセージの内容がテキストであることを表します。
ちなみにopecodeに2を指定するとメッセージがバイナリを表すことになります。

今回の場合以下の内容になります。

FIN(1bit)予約1(1bit) 予約2(1bit) 予約3(1bit)オペコード(4bit)
10001


opecodeの次にMASKフィールドとpayload長フィールドが続きます。
サーバーからのフレームはマスクできないらしいためMASKには0を指定します。
そのためdlenに送るデータの長さを格納するだけで問題ありません。

ただしデータの長さが125より長い場合、ペイロード長フィールドに入る値が変わってきます。
データの長さが0xffff以下なら126が入り、それよりも大きい場合は127が入ります。

何故かというとこのフィールドに126の場合後続の2byteがペイロード長を表し、
127の場合は後続の8byteがペイロード長を表すようになるからです。
※長さはいずれも符号なしです。

ですからクライアントから送られてきたフレームを見て、
このフィールドに書くのされている値が126の場合後続の2byteを、
127の場合後続の8byteを取り出しそれをペイロード長とすればいいということになります。

今回は簡単にするため長さが125以下のデータを送受信するようにしています。

今回の場合以下の内容になります。

MASK(1bit)オペコード(7bit)
0データの長さ(dlenの内容)


以上を踏まえてstruct.packのフォーマットはBBをしています。

動作確認


まずサーバーを起動します。

python wss.py


そしえブラウザからWebSocketクライアントを実装したページにアクセスします。
この例ではハンドシェイクの確立後サーバーからエコーメッセージが一秒ごとに送信されてきます。


この例ではサーバーからのデータを受信するだけですが、
もちろんサーバーデータを送信することもできます。

例えば先のhtmlファイルを以下のように修正します。

socket.onmessage = function(e) {
                console.log(e.data)
                socket.send("client: "+e.data)
            }


データを受信したらその内容にサーバーに送り返します。
内容は「client: echo {数字}」です。

そしてサーバーは受信するわけですが、
クライアントから送られてきたメッセージがエンコードされているため、
キー(4byte)とペイロードをxorデコードしてメッセージを取り出します。

サーバープログラムに以下の関数を追加します。
どこかのタイミングでデータを受信して以下の関数を呼び出します。

この関数によってクライアントが送ってくる「client: echo 1」のようなメッセージを取り出すことができます。

def get_memssage(frame):
    enc = frame[6:]
    key = frame[2:6]
    dec = []
    for i in range(len(enc)):
        dec.append(chr(ord(enc[i]) ^ ord(key[i%4])))
    return "".join(dec)


ペイロード長が125より大きい場合はキーの開始オフセットが変わります。
今回は話を単純にするため送受信するペイロード長は125以下にします。
そのためマスクを解除するためのキーを3バイト目から4byte分取り出しています。


もちろんやり取りするデータやシステムによって内容は変わりますが、
今回作ったサンプルではクライアントから送られてくるフレームの内容は以下のようになります。


1byte目

FIN(1bit)予約1(1bit) 予約2(1bit) 予約3(1bit)オペコード(4bit)
10001


2byte目
MASKが1の場合メッセージがエンコードされていることを表している。

MASK(1bit)オペコード(7bit)
1データの長さ(データの長さ)


3byte目

Masking-Key(16bit)
キー


7byte目以降はデータが格納されています。

実際には


WebSocketで通信するには少しだけ面倒なことをしなければいけないようです。

ライブラリーを使えばこれらのほとんどをやってくれますので、本格的なシステムを構築をする場合はライブラリーを使ったほうが簡単に実装できると思います。

スクラッチで実装した理由は「どんなやり取りしているかを知りたかった」というのは建前で、実際はライブラリーの存在の実装の途中まで認識していなかったことです。

まあ勉強になったからいいんですけどねぇ。