FastAPIのWebSocketで利用するデコレータを間違えた
今回は小ネタです。凡ミスをして時間を潰してしまったので、その想いを供養するためブログに投稿しました。
きっかけ
普段、仕事ではSlackを利用していますが、そういえばチャットアプリってどうやって作っているんだろう?とふと思いました。
チャットアプリで検索してみると簡易サンプルではWebSocketを利用しているケースが多かったです。
WebSocketをきちんと触ったことがなかったので、勉強がてらコードを書いていました。
サーバーサイドの実装も必要そうだけど、どうせなら使ったことのないFastAPIを利用してみるかーと軽い気持ちで触ったことで凡ミスが生まれてしまいました。
FastAPIの説明はしないので、詳しくはドキュメントを見てください。
チュートリアルをやってみた
最近のチュートリアルはとても充実していますね。
難なく実施できました。
FastAPIでWebSocket完全に理解したってやつです(←わかっていない)
過ちの始まり
チュートリアルをやっていくと、一つのmain.pyにコードを全てを書いているので、アプリとして作るときどうするんだろう?という疑問が湧きました。
当然、この問いに対する説明もきちんと説明がされています。流石ですね。
詳細を省く&若干加工していますが、チュートリアルでは
# main.py from fastapi import FastAPI app = FastAPI() fake_items_db = {"plumbus": {"name": "Plumbus"}} @app.get("/") async def read_items(): return fake_items_db
というFastAPIのオブジェクト(app)に対してパスを追加する実装を
# routers/other.py from fastapi import APIRouter router = APIRouter() fake_items_db = {"plumbus": {"name": "Plumbus"}} @router.get("/") async def read_items(): return fake_items_db # main.py from fastapi import FastAPI from .routers import other app = FastAPI() app.include_router(other.router)
APIRouterにパスを追加し、APIRouterをFastAPIのオブジェクトに追加するという対応に変更しています。
なるほど。これでroutingしたい単位でモジュールを分けることができるのか!と理解した私は、WebSocketでどのようになるかを試してみようと思い、チャレンジしてみました。
Bigger ApplicationのWebSocket版
いくつかの要素を混ぜ込んでチャレンジしてみました。要素は以下の通りです。
- チュートリアルのBigger ApplicationのWebSocket版を実装してみる
- prefixを利用する
- パスパラメータを利用する
403エラーで接続できない
そんなに大きいコードではないので、ささっと書いて試しみましたが、
# main.py import uvicorn from fastapi import FastAPI from fastapi.responses import HTMLResponse import other app = FastAPI() app.include_router(other.router) html = """ <!DOCTYPE html> <html> <head> <title>Chat</title> </head> <body> <h1>WebSocket Chat</h1> <h2>Your ID: <span id="ws-id"></span></h2> <form action="" onsubmit="sendMessage(event)"> <input type="text" id="messageText" autocomplete="off"/> <button>Send</button> </form> <ul id='messages'> </ul> <script> var client_id = Date.now() document.querySelector("#ws-id").textContent = client_id; var ws = new WebSocket(`ws://localhost:8000/ws/hello/${client_id}`); ws.onmessage = function(event) { var messages = document.getElementById('messages') var message = document.createElement('li') var content = document.createTextNode(event.data) message.appendChild(content) messages.appendChild(message) }; function sendMessage(event) { var input = document.getElementById("messageText") ws.send(input.value) input.value = '' event.preventDefault() } </script> </body> </html> """ @app.get("/") async def get(): return HTMLResponse(html) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000)
# other.py from fastapi import APIRouter, WebSocket router = APIRouter(prefix="/ws") @router.websocket_route("/hello/{client_id}") async def hello(websocket: WebSocket, client_id: int): await websocket.accept() await websocket.send_text(f"Router Hello! {client_id}") response = await websocket.receive_text() print(response) await websocket.close() print("Router Closed")
そしていざ動かしてみると
403エラーでつながらない。。。
チュートリアルのWebSocketのコードだと問題なく動作するのになぜか、Bigger Applicationのコードを真似てみたら動かない。
そんなに複雑なコードでもないので、もしやそもそもAPIRouterでWebSocketの組み合わせは悪いのでは?と思い検索したら、Issueが上がっていました。
どうやら、バグのようでWebSocketでAPIRouterを利用する際、prefixがうまく機能していないようです。
仕方ないので、prefixを利用するのは諦めました。
今度はパスパラメータの解析ができない
other.pyのprefixを外し、デコレータにprefixのパスを追加してみて、今度こそうまく動くと思いました。
# other.py from fastapi import APIRouter, WebSocket router = APIRouter() @router.websocket_route("/ws/hello/{client_id}") async def hello(websocket: WebSocket, client_id: int): await websocket.accept() await websocket.send_text(f"Router Hello! {client_id}") response = await websocket.receive_text() print(response) await websocket.close() print("Router Closed")
パスパラメータのclient_idが存在していなくて、エラーになっています。
チュートリアルのhttpのgetメソッドでは上手くいっているのに、なんでWebSocketで動かないのか。
これもprefixと同じで上手く動かないのか。。。
当初考えたBigger ApplicationのWebSocket版は無理なのかなーと思いました。
ここで諦めるのは悔しいので、デバッグしてみて何かおかしなところないか調べてみようと一念発起しました。
バグ爆誕
地道なステップ実行して、何が原因でエラーになっているのか調べてみました。
そして、怪しそうな箇所に到達しました。
pathも正しいな。
pathフォーマットも正しいな。
WebSocketのオブジェクトなのにscopeがhttpになっているぞ?
後続の処理で、ここがhttpになっているから、パス解析の処理に移動していないっぽいぞ。。。
あれっ?ASGIサーバの情報を正しく処理されていない?
出ているログからもここで例外をキャッチしているな。。。
そして、もう一度該当のコード:starlette/routing.pyを見てみる。
そっか。怪しそうな箇所のコードってStarletteのコードなのか。
まぁ継承しているから変ではないんだけど。。。
starlette/routing.pyか。気になるな。。。
長くなるので割愛しますが、正常に動作するコードではfastapi/routing.pyでした。
fastapi/routing.py!?
んっ!?
んっ!?んっ!?
もしかして
これをこうして
えいやっ!!
動いた!!!!
と、同時に私の感情↓
alu.jp学んだこと
FastAPIは必要条件に書いてある通り、Starletteを利用しています。
FastAPI は巨人の肩の上に立っています。 Web の部分はStarlette データの部分はPydantic
FastAPIでStarletteを利用しているので、当然、FastAPIが想定した通りに利用しないと動作しません。
パッとみた感じ、Starletteのクラスなどを継承しているため、今回のデコレータなど参照できてしまいますが、動作しない可能性もあります。
FastAPIのデコレータやメソッドを利用する場合は、 FastAPIのモノかきちんと確認した方が良さそうです。(私は)
とういうことで、凡ミスで1週間時間を潰してしまいましたーー。
これにて凡ミス供養とします。