haku-maiのブログ

インフラエンジニアですが、アプリも作ります。

【WebSocket】uvicorn0.13.4を利用してweb chatを作成して、room毎のchatを実現する。(sample codeはgithubで公開)

f:id:n-guitar:20210306232715j:plain:w800

本記事で行うこと

  • uvicorn0.13.4を利用し、web chatを作成する。
  • web chatはroom毎にchet可能とし、例えばroom1のchat内容がroom2に表示されないようにする。

モチベーション

  • WebSocketを利用したアプリケーション作成するため、簡単な例を作成したかったため。
  • 社内のPythonの勉強会での説明用に利用したかったため。
  • ASGIの仕様把握したいため。

環境

$ sw_vers
ProductName:    macOS
ProductVersion: 11.2.1
BuildVersion:   20D74

sample code

  • 本記事のsample codeは以下で公開しています。
    github.com

参考サイト

環境構築

$ python -m venv env
$ python -V
Python 3.9.1
$ source ./env/bin/activate
  • package
$ pip install uvicorn==0.13.4
$ pip install websockets==8.1

簡単なechoサーバの例

  • 簡単な例です。このmain.pyをuvicornで起動し、ブラウザで動作を見ていきます。
  • main.py
# httpとwebsocketの条件分岐
async def app(scope, receive, send):
    if scope['type'] == 'http':
        await http_applciation(scope, receive, send)
    elif scope['type'] == 'websocket':
        await websocket_applciation(scope, receive, send)

# httpの処理
async def http_applciation(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [
            [b'content-type', b'text/plain'],
        ],
    })
    await send({
        'type': 'http.response.body',
        'body': b'Hello, world!',
    })

# websocketの処理
async def websocket_applciation(scope, receive, send):
    while True:
        event = await receive()

        if event['type'] == 'websocket.connect':
            await send({
                'type': 'websocket.accept'
            })

        if event['type'] == 'websocket.disconnect':
            break

        if event['type'] == 'websocket.receive':
            await send({
                'type': 'websocket.send',
                'text': event['text']
            })
  • uvicorn起動
$ uvicorn main:app --host 127.0.0.1 --port 8000
  • この状態でChromehttp://127.0.0.1:8000つなぐと、scope['type'] == 'http'となり以下のように表示されます。
    f:id:n-guitar:20210306163551p:plain:w200

  • 今度はChromeのDeveloper Toolsを開き、Consoleで以下のように実行し、Network Tabを覗くとechoしていることがわかります。
    f:id:n-guitar:20210306164810p:plain:w600
    f:id:n-guitar:20210306164846p:plain:w600

  • Console上で実行したコマンドは以下になります

  • また、ws://127.0.0.1:8000/hogehogeとした部分は他の文字列でも大丈夫ですが、この文字列が後のroom毎のchatを実現する方法のミソになります。
const ws = new WebSocket(`ws://127.0.0.1:8000/hoge`)
ws.send("hello websocket")
ws.send("hello websocket")
ws.readyState
ws.close()
ws.readyState
  • ChromeのConsole側でjavascriptを実行しましたが、これがWebSocketのclient側になります。
  • new WebSocket(WebSocket ServerのURL)とすることで、WebSocket Serverに接続し、sendすることでメッセージを送信、後で使いますがWebSocket Server側からのメッセージはonmessageを使うことで受け取ることができます。
  • readyStateはWebSocketの接続状態を表すステータスです。今回は簡単な例なので使いませんが覚えておくと状態毎に処理をかき分けることができます。接続状態の直については以下を参照ください。

WebSocket.readyState - Web API | MDN

scopeの中身を覗いてみる

  • echoサーバの例で作成したように、ASGIは3つの引数でscope, receive, sendを使って処理をします。
  • async def app(scope, receive, send)
  • 以下のASGIのdocumentをみると、scopeに接続してきたclientからのリクエスト情報が含まれているので、scopeの情報を上手く使えばweb chatが作れです。
    HTTP & WebSocket ASGI Message Format — ASGI 3.0 documentation

  • ですのでとりあえず、scopeの中身を全部printして見ようと思います。

async def app(scope, receive, send):
    await print_websocket_item(scope, receive, send)
    if scope['type'] == 'http':
        await http_applciation(scope, receive, send)
    elif scope['type'] == 'websocket':
        await websocket_applciation(scope, receive, send)


async def http_applciation(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [
            [b'content-type', b'text/plain'],
        ],
    })
    await send({
        'type': 'http.response.body',
        'body': b'Hello, world!',
    })


async def websocket_applciation(scope, receive, send):
    while True:
        event = await receive()

        if event['type'] == 'websocket.connect':
            await send({
                'type': 'websocket.accept'
            })

        if event['type'] == 'websocket.disconnect':
            break

        if event['type'] == 'websocket.receive':
            await send({
                'type': 'websocket.send',
                'text': event['text']
            })


async def print_websocket_item(scope, receive, send):
    print('-------------scope--------------')
    print('scope: {}'.format(scope))
    print('-------------receive--------------')
    print('receive: {}'.format(receive))
    print('-------------send--------------')
    print('send: {}'.format(send))
  • uvicorn起動
$ uvicorn main:app --host 127.0.0.1 --port 8000
const ws = new WebSocket(`ws://127.0.0.1:8000/hoge`)
  • python側のprint結果をみると、scopeはdict型情報を持っていて、特にheadersはList型でさらにtuple型でvalueが入っていて、さらにその中の直はbyte型で情報を持っています。
  • また、headersのsec-websocket-key格納されている直がclient側で一意に決まり、pathにws://127.0.0.1:8000/hogehoge部分が入ります。
  • このscopeのheaderがList型、tuple型、byte型でどうみても扱いにくいのでparseすることを考えます。
-------------scope--------------
scope: {'type': 'websocket', 'asgi': {'version': '3.0', 'spec_version': '2.1'}, 'scheme': 'ws', 'server': ('127.0.0.1', 8000), 'client': ('127.0.0.1', 58415), 'root_path': '', 'path': '/hoge', 'raw_path': '/hoge', 'query_string': b'', 'headers': [(b'host', b'127.0.0.1:8000'), (b'connection', b'Upgrade'), (b'pragma', b'no-cache'), (b'cache-control', b'no-cache'), (b'user-agent', b'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36'), (b'upgrade', b'websocket'), (b'origin', b'http://127.0.0.1:8000'), (b'sec-websocket-version', b'13'), (b'accept-encoding', b'gzip, deflate, br'), (b'accept-language', b'ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7'), (b'cookie', b'csrftoken=oFQAJkBQrkkzavO3Jh7sQ6gGAgJxQhChslEyOcURSqnpldaJkOiQCZUjGf6eokkB'), (b'sec-websocket-key', b'gccXCEU6HtT5WDbvVdip6w=='), (b'sec-websocket-extensions', b'permessage-deflate; client_max_window_bits')], 'subprotocols': []}
scope_type: <class 'dict'>

scopeのheaderをparseする

  • headersをparseしていきます。
async def app(scope, receive, send):
    await print_websocket_item(scope, receive, send)
    if scope['type'] == 'http':
        await http_applciation(scope, receive, send)
    elif scope['type'] == 'websocket':
        await websocket_applciation(scope, receive, send)


async def http_applciation(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [
            [b'content-type', b'text/plain'],
        ],
    })
    await send({
        'type': 'http.response.body',
        'body': b'Hello, world!',
    })


async def websocket_applciation(scope, receive, send):
    while True:
        event = await receive()

        if event['type'] == 'websocket.connect':
            await send({
                'type': 'websocket.accept'
            })

        if event['type'] == 'websocket.disconnect':
            break

        if event['type'] == 'websocket.receive':
            await send({
                'type': 'websocket.send',
                'text': event['text']
            })


async def print_websocket_item(scope, receive, send):
    print('-------------header--------------')
    header = HeaderParse(scope)
    print('header.keys: {}'.format(header.keys))
    print('header.keys_demo: {}'.format(header.keys_demo))
    print('header.as_dict: {}'.format(header.as_dict))
    print('header.as_dict_demo: {}'.format(header.as_dict_demo))


class HeaderParse:
    def __init__(self, scope):
        self._scope = scope

    @property
    def keys(self):
        return [header[0].decode() for header in self._scope["headers"]]

    @property
    def keys_demo(self):
        header_keys = []
        for header in self._scope["headers"]:
            header_keys.append(header[0].decode())
        return header_keys

    @property
    def as_dict(self):
        return {header[0].decode(): header[1].decode() for header in self._scope["headers"]}

    @property
    def as_dict_demo(self):
        header_as_dict = {}
        for header in self._scope["headers"]:
            header_as_dict[header[0].decode()] = header[1].decode()
        return header_as_dict
  • HeaderParse classではscopeを受け取って、headersをparseしています。
  • keysとkeys_demo、as_dictとas_dict_demoは全く同じ結果を返すのですが、リスト内包表記に慣れていないと逆にわかりにくいと思ったので用意しました。
  • keysとas_dictの結果をみると以下のようになっており、いい感じで処理できそうです。
  • おいおい、ちょっと待ってくれ、@propertyってなんやねんという方は以下の議論をみておくと良いです。
    Python 3.x - python3 プロパティの必要性が分かりません。|teratail
  • @propertyをちゃんと理解しようとするとそれなりに大変なのですが、ここでは例えばas_dictか関数ですが今回、値のようなものとして見せたいので@propertyをつけています。
  • その結果、関数であれば、header.as_dict()として呼び出さないといけないのですが、header.as_dictあたかも直のように呼び出すことができています。

  • keys

['host', 'connection', 'pragma', 'cache-control', 'user-agent', 'upgrade', 'origin', 'sec-websocket-version', 'accept-encoding', 'accept-language', 'cookie', 'sec-websocket-key', 'sec-websocket-extensions']
  • as_dict
{'host': '127.0.0.1:8000', 'connection': 'Upgrade', 'pragma': 'no-cache', 'cache-control': 'no-cache', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36', 'upgrade': 'websocket', 'origin': 'http://127.0.0.1:8000', 'sec-websocket-version': '13', 'accept-encoding': 'gzip, deflate, br', 'accept-language': 'ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7', 'cookie': 'csrftoken=oFQAJkBQrkkzavO3Jh7sQ6gGAgJxQhChslEyOcURSqnpldaJkOiQCZUjGf6eokkB', 'sec-websocket-key': 'ocErEZ3UeNRQRwh8dVedoQ==', 'sec-websocket-extensions': 'permessage-deflate; client_max_window_bits'}

WebSocket Serverのclassを作り汎用化する

  • headersをいい感じでparseできたのですが、これを使ってwebsocket_applciation関数で処理していくのはやや複雑になってきそうです。
  • なのでWebSocket Serverの部分はheadersのparse部分も含めて、classでまとめておこうと思います。
async def app(scope, receive, send):
    if scope['type'] == 'http':
        await http_applciation(scope, receive, send)
    elif scope['type'] == 'websocket':
        await websocket_applciation(WebSocket(scope, receive, send))
        return


async def http_applciation(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [
            [b'content-type', b'text/plain'],
        ],
    })
    await send({
        'type': 'http.response.body',
        'body': b'Hello, world!',
    })


async def websocket_applciation(ws):
    await ws.accept()
    try:
        while True:
            data = await ws.receive()
            await ws.send_text(data['text'])
    except:
        await ws.close()


class HeaderParse:
    def __init__(self, scope):
        self._scope = scope

    @property
    def keys(self):
        return [header[0].decode() for header in self._scope["headers"]]

    @property
    def as_dict(self):
        return {header[0].decode(): header[1].decode() for header in self._scope["headers"]}


class WebSocket:
    def __init__(self, scope, receive, send):
        self._scope = scope
        self._receive = receive
        self._send = send

    @property
    def headers(self):
        return HeaderParse(self._scope)

    @property
    def path(self):
        return self._scope["path"]

    async def accept(self):
        await self.receive()
        await self.send({
            "type": "websocket.accept"
        })

    async def close(self,):
        await self.send({
            "type": "websocket.close"
        })

    async def send(self, message):
        await self._send(message)

    async def receive(self):
        message = await self._receive()
        return message

    async def send_text(self, text):
        await self.send({
            "type": "websocket.send",
            "text": text
        })
  • WebSocket classを作り、基本的な振る舞いをまとめました。
  • Clientのstatusによってエラー処理をしたいところですが、今回は煩雑になるため入れないでおきます。 (例えばdisconnectイベントがきているのにserver側でreceiveを実行するなど) *WebSocket classにしたことで、async def websocket_applciation(ws)はWebSocketオブジェクト引数で受け取るように変更しています。
  • data = await ws.receive()部分でメッセージを受け取りawait ws.send_text(data['text'])でechoします。

  • uvicorn起動

$ uvicorn main:app --host 127.0.0.1 --port 8000
const ws = new WebSocket(`ws://127.0.0.1:8000/hoge`)
ws.send("hello websocket")
  • 同じようにechoできていることがわかります。 f:id:n-guitar:20210306210047p:plain:w600 f:id:n-guitar:20210306210106p:plain:w600

  • ここまでできたので、いよいよweb chatを作成していきます。

echoをweb画面に表示する。

  • いちいちChromeのConsoleでWebSocketするのは面倒なので、web画面からそうさできるようにします。

  • web画面側

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>websocket sample</title>
    </head>
    <body>
        <h1>websocket sample</h1>
        <textarea id="messageTextArea" rows="10" cols="50"></textarea>
        <form>
            <input id="textMessage" type="text" />
            <input id="sendMessage" value="Send" type="button" />
        </form>
        <script src="main.js"></script>
    </body>
</html>
  • 以下のような画面を作って、id="textMessage"に入れたメッセージをWebSocket Server側に送信し、送信したメッセージと、WebSocket Server側から帰ってきたecho メッセージをid="messageTextArea"に表示します。 f:id:n-guitar:20210306210629p:plain:w600
const webSocket = new WebSocket("ws://127.0.0.1:8000/hoge");
const text_message = document.querySelector("#textMessage");
const send_button = document.querySelector("#sendMessage");

webSocket.onopen = () => {
    messageTextArea.value += "Server connect...\n";
};

webSocket.onmessage = (message) => {
    messageTextArea.value += "Recieve From Server => " + message.data + "\n";
};

send_button.addEventListener("click", () => {
    sendMessage(text_message.value);
});

function sendMessage(message) {
    messageTextArea.value += "Send to Server => " + message + "\n";
    webSocket.send(message);
    text_message.value = "";
}
  • 解説を書いておきますが、動かしてみた方が早いです。 f:id:n-guitar:20210306212852g:plain:w600

  • const text_message = document.querySelector("#textMessage");id="textMessage"を探し、オブジェクトをtext_messageに格納します。

  • const send_button = document.querySelector("#sendMessage");も同じです。
  • webSocket.onopenは WebSocketのコネクションのreadyStateが1に変化したときに呼び出され、id="messageTextArea"messageTextArea.valueで直を表示します。この状態になれば、データの送受信できる準備ができたことを示します。
  • send_button.addEventListenerはsend_buttonにclickイベントを追加し、clickされた時にsendMessage関数を実行します。
  • sendMessage関数は呼び出された時に、引数のmessagemessageTextArea.valueで直を表示し、webSocket.send(message);でWebSocket Serverに送信。また最後に、text_message.value = ""でinputエリアをクリアします。

WebSocket Clientにブロードキャストする

  • 画面に表示することはできたので、これをWebSocket Serverに接続しているClient全体に送信します。
async def app(scope, receive, send):
    if scope['type'] == 'http':
        await http_applciation(scope, receive, send)
    elif scope['type'] == 'websocket':
        await websocket_applciation(WebSocket(scope, receive, send))
        return


async def http_applciation(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [
            [b'content-type', b'text/plain'],
        ],
    })
    await send({
        'type': 'http.response.body',
        'body': b'Hello, world!',
    })

clients = {}


async def websocket_applciation(ws):

    await ws.accept()
    key = ws.headers.as_dict['sec-websocket-key']
    clients[key] = ws
    try:
        while True:
            data = await ws.receive()
            for client in clients.values():
                await client.send_text("ID: {} => {}".format(key, data['text']))
    except:
        await ws.close()
        del clients[key]


class HeaderParse:
    def __init__(self, scope):
        self._scope = scope

    @property
    def keys(self):
        return [header[0].decode() for header in self._scope["headers"]]

    @property
    def as_dict(self) -> dict:
        return {h[0].decode(): h[1].decode() for h in self._scope["headers"]}


class WebSocket:
    def __init__(self, scope, receive, send):
        self._scope = scope
        self._receive = receive
        self._send = send

    @property
    def headers(self):
        return HeaderParse(self._scope)

    @property
    def path(self):
        return self._scope["path"]

    async def accept(self):
        await self.receive()
        await self.send({
            "type": "websocket.accept"
        })

    async def close(self,):
        await self.send({
            "type": "websocket.close"
        })

    async def send(self, message):
        await self._send(message)

    async def receive(self):
        message = await self._receive()
        return message

    async def send_text(self, text):
        await self.send({
            "type": "websocket.send",
            "text": text
        })
  • clients = {}でdist型のclientsを準備してき、key = ws.headers.as_dict['sec-websocket-key']で接続してきたClientで一意になる情報を取り出し、clients[key] = ws{各Clientのkey: WebSocket object }のペアを作るのがここのポイントです。
  • data = await ws.receive()でWebSocket Serverから受信したdataをfor client in clients.values()で各ClientのWebSocket objectを取り出し、各Client毎にawait client.send_text("ID: {} => {}".format(key, data['text']))でメッセージを送信します。

  • ここまでくればChatっぽくなりましたね。 f:id:n-guitar:20210306223111g:plain:w600

chet room毎にブロードキャストする

  • 今のままでは全てのClientにメッセージが送信されてしまうので、room毎にchatできるように修正します。

  • まずWebSocket Serverに接続する時のpath、ws://127.0.0.1:8000/hogehoge部分をroom1room2に分けたいので、htmlとjsを2つに分けます。

room1

  • 表示を変えたいのでh1の部分だけ変更します。
  • また対応するjsをmain_room1.jsとします。

index_room1.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>websocket sample</title>
    </head>
    <body>
        <h1>websocket sample room1</h1>
        <textarea id="messageTextArea" rows="10" cols="50"></textarea>
        <form>
            <input id="textMessage" type="text" />
            <input id="sendMessage" value="Send" type="button" />
        </form>
        <script src="main_room1.js"></script>
    </body>
</html>
  • WebSocket Serverに接続する時のpathを変えたいだけなのでconst webSocket = new WebSocket("ws://127.0.0.1:8000/room1"); とします。

main_room1.js

const webSocket = new WebSocket("ws://127.0.0.1:8000/room1");
const text_message = document.querySelector("#textMessage");
const send_button = document.querySelector("#sendMessage");

webSocket.onopen = () => {
    messageTextArea.value += "Server connect...\n";
};

webSocket.onmessage = (message) => {
    messageTextArea.value += "Recieve From Server => " + message.data + "\n";
};

send_button.addEventListener("click", () => {
    sendMessage(text_message.value);
});

function sendMessage(message) {
    messageTextArea.value += "Send to Server => " + message + "\n";
    webSocket.send(message);
    text_message.value = "";
}

room2

  • room1と同じようにroom2として修正します。

index_room2.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>websocket sample</title>
    </head>
    <body>
        <h1>websocket sample room2</h1>
        <textarea id="messageTextArea" rows="10" cols="50"></textarea>
        <form>
            <input id="textMessage" type="text" />
            <input id="sendMessage" value="Send" type="button" />
        </form>
        <script src="main_room2.js"></script>
    </body>
</html>

main_room2.js

const webSocket = new WebSocket("ws://127.0.0.1:8000/room2");
const text_message = document.querySelector("#textMessage");
const send_button = document.querySelector("#sendMessage");

webSocket.onopen = () => {
    messageTextArea.value += "Server connect...\n";
};

webSocket.onmessage = (message) => {
    messageTextArea.value += "Recieve From Server => " + message.data + "\n";
};

send_button.addEventListener("click", () => {
    sendMessage(text_message.value);
});

function sendMessage(message) {
    messageTextArea.value += "Send to Server => " + message + "\n";
    webSocket.send(message);
    text_message.value = "";
}

main.py

  • async def app(scope, receive, send)部分でroom = scope['path']でpath部分をroomとしてwebsocket_applciationに引き渡します。
  • websocket_applciationでは引数にroomを加え、if client.path == room:でpathがroomと一致するclientにだけメッセージを送信する判定をおこないます。
async def app(scope, receive, send):
    if scope['type'] == 'http':
        await http_applciation(scope, receive, send)
    elif scope['type'] == 'websocket':
        room = scope['path']
        await websocket_applciation(WebSocket(scope, receive, send), room)
        return


async def http_applciation(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [
            [b'content-type', b'text/plain'],
        ],
    })
    await send({
        'type': 'http.response.body',
        'body': b'Hello, world!',
    })

clients = {}


async def websocket_applciation(ws,room):
    await ws.accept()
    key = ws.headers.as_dict['sec-websocket-key']
    clients[key] = ws
    try:
        while True:
            data = await ws.receive()
            for client in clients.values():
                if client.path == room:
                    await client.send_text("ID: {} => {}".format(key, data['text']))
    except:
        await ws.close()
        del clients[key]


class HeaderParse:
    def __init__(self, scope):
        self._scope = scope

    @property
    def keys(self):
        return [header[0].decode() for header in self._scope["headers"]]

    @property
    def as_dict(self) -> dict:
        return {h[0].decode(): h[1].decode() for h in self._scope["headers"]}


class WebSocket:
    def __init__(self, scope, receive, send):
        self._scope = scope
        self._receive = receive
        self._send = send

    @property
    def headers(self):
        return HeaderParse(self._scope)

    @property
    def path(self):
        return self._scope["path"]

    async def accept(self):
        await self.receive()
        await self.send({
            "type": "websocket.accept"
        })

    async def close(self,):
        await self.send({
            "type": "websocket.close"
        })

    async def send(self, message):
        await self._send(message)

    async def receive(self):
        message = await self._receive()
        return message

    async def send_text(self, text):
        await self.send({
            "type": "websocket.send",
            "text": text
        })
  • 以下のようなにroom毎にchetできるようになりました。 f:id:n-guitar:20210306222648g:plain:w600

後書き

  • uvicornとwebsocketを利用すれば簡単にweb chatが実現しました。
  • Django3.1ではASGIと非同期Viewに対応しているため、Django3.1ではwebsocketのprotocolできた時に、上記のようなwebsocket classを作成してしてあげれば、Django Channelsを使わなくてもwebsocketアプリケーションが作成できることになります。
  • 今回の例ではわかりやすくするため、room毎にhtml,jsを分けて作成しましたが、もちろん動的に作成することが可能なので、Django3.1を利用したリッチなweb chatアプリケーションは後日作成して見ようと思います。

以上、uvicorn0.13.4を利用してweb chatを作成して、room毎のchatを実現する。でした。