【WebSocket】uvicorn0.13.4を利用してweb chatを作成して、room毎のchatを実現する。(sample codeはgithubで公開)
- 本記事で行うこと
- モチベーション
- 環境
- sample code
- 参考サイト
- 環境構築
- 簡単なechoサーバの例
- scopeの中身を覗いてみる
- scopeのheaderをparseする
- WebSocket Serverのclassを作り汎用化する
- echoをweb画面に表示する。
- WebSocket Clientにブロードキャストする
- chet room毎にブロードキャストする
- 後書き
本記事で行うこと
- uvicorn0.13.4を利用し、web chatを作成する。
- web chatはroom毎にchet可能とし、例えばroom1のchat内容がroom2に表示されないようにする。
モチベーション
- WebSocketを利用したアプリケーション作成するため、簡単な例を作成したかったため。
- 社内のPythonの勉強会での説明用に利用したかったため。
- ASGIの仕様把握したいため。
環境
- python3.9.1
- macbook air M1
$ sw_vers ProductName: macOS ProductVersion: 11.2.1 BuildVersion: 20D74
sample code
- 本記事のsample codeは以下で公開しています。
github.com
参考サイト
- ASGIの説明は以下の資料がわかりやすいので一読しておくと良いです。
ASGI(非同期サーバゲートウェイインターフェース)の概要 - Speaker Deck - uvicornの公式サイト
Uvicorn
環境構築
$ 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
この状態でChromeで http://127.0.0.1:8000つなぐと、scope['type'] == 'http'となり以下のように表示されます。
今度はChromeのDeveloper Toolsを開き、Consoleで以下のように実行し、Network Tabを覗くとechoしていることがわかります。
Console上で実行したコマンドは以下になります
- また、
ws://127.0.0.1:8000/hoge
のhoge
とした部分は他の文字列でも大丈夫ですが、この文字列が後の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
- 先ほどと同じように Chromeでhttp://127.0.0.1:8000に接続し、Console側で以下を実行します。
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/hoge
のhoge
部分が入ります。 - この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
- 先ほどと同じように Chromeでhttp://127.0.0.1:8000に接続し、Console側で以下を実行します。
const ws = new WebSocket(`ws://127.0.0.1:8000/hoge`) ws.send("hello websocket")
同じようにechoできていることがわかります。
ここまでできたので、いよいよ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"
に表示します。
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 = ""; }
解説を書いておきますが、動かしてみた方が早いです。
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
関数は呼び出された時に、引数のmessage
をmessageTextArea.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っぽくなりましたね。
chet room毎にブロードキャストする
今のままでは全てのClientにメッセージが送信されてしまうので、room毎にchatできるように修正します。
まずWebSocket Serverに接続する時のpath、
ws://127.0.0.1:8000/hoge
のhoge
部分をroom1
とroom2
に分けたいので、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できるようになりました。
後書き
- 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を実現する。でした。