SANDFISH FACTORY

技術ブログです。python・vuejsを愛でる日々について綴ります

FastAPIのWebSocketで利用するデコレータを間違えた

今回は小ネタです。凡ミスをして時間を潰してしまったので、その想いを供養するためブログに投稿しました。

きっかけ

普段、仕事ではSlackを利用していますが、そういえばチャットアプリってどうやって作っているんだろう?とふと思いました。
チャットアプリで検索してみると簡易サンプルではWebSocketを利用しているケースが多かったです。

WebSocketをきちんと触ったことがなかったので、勉強がてらコードを書いていました。
サーバーサイドの実装も必要そうだけど、どうせなら使ったことのないFastAPIを利用してみるかーと軽い気持ちで触ったことで凡ミスが生まれてしまいました。

FastAPIの説明はしないので、詳しくはドキュメントを見てください。

fastapi.tiangolo.com

チュートリアルをやってみた

最近のチュートリアルはとても充実していますね。

fastapi.tiangolo.com

難なく実施できました。
FastAPIでWebSocket完全に理解したってやつです(←わかっていない)

過ちの始まり

チュートリアルをやっていくと、一つのmain.pyにコードを全てを書いているので、アプリとして作るときどうするんだろう?という疑問が湧きました。
当然、この問いに対する説明もきちんと説明がされています。流石ですね。

fastapi.tiangolo.com

詳細を省く&若干加工していますが、チュートリアルでは

# 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")

そしていざ動かしてみると

f:id:sandfish03:20210530191709p:plain
WebSocketで403エラー

403エラーでつながらない。。。
チュートリアルのWebSocketのコードだと問題なく動作するのになぜか、Bigger Applicationのコードを真似てみたら動かない。

そんなに複雑なコードでもないので、もしやそもそもAPIRouterでWebSocketの組み合わせは悪いのでは?と思い検索したら、Issueが上がっていました。

github.com

どうやら、バグのようで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")

f:id:sandfish03:20210530193416p:plain
パスパラメータ解析エラー

パスパラメータのclient_idが存在していなくて、エラーになっています。
チュートリアルのhttpのgetメソッドでは上手くいっているのに、なんでWebSocketで動かないのか。

これもprefixと同じで上手く動かないのか。。。
当初考えたBigger ApplicationのWebSocket版は無理なのかなーと思いました。
ここで諦めるのは悔しいので、デバッグしてみて何かおかしなところないか調べてみようと一念発起しました。

バグ爆誕

地道なステップ実行して、何が原因でエラーになっているのか調べてみました。

f:id:sandfish03:20210530203010p:plain

そして、怪しそうな箇所に到達しました。
pathも正しいな。
pathフォーマットも正しいな。
WebSocketのオブジェクトなのにscopeがhttpになっているぞ?
後続の処理で、ここがhttpになっているから、パス解析の処理に移動していないっぽいぞ。。。

あれっ?ASGIサーバの情報を正しく処理されていない?

f:id:sandfish03:20210530203448p:plain

出ているログからもここで例外をキャッチしているな。。。

そして、もう一度該当のコード:starlette/routing.pyを見てみる。

そっか。怪しそうな箇所のコードってStarletteのコードなのか。
まぁ継承しているから変ではないんだけど。。。 starlette/routing.pyか。気になるな。。。
長くなるので割愛しますが、正常に動作するコードではfastapi/routing.pyでした。

fastapi/routing.py!?

f:id:sandfish03:20210530194612p:plain

んっ!?

f:id:sandfish03:20210530194909p:plain

んっ!?んっ!?

もしかして f:id:sandfish03:20210530200021p:plain

これをこうして

f:id:sandfish03:20210530195316p:plain

えいやっ!!

f:id:sandfish03:20210530195428p:plain

動いた!!!!

と、同時に私の感情↓

alu.jp

学んだこと

FastAPIは必要条件に書いてある通り、Starletteを利用しています。

FastAPI は巨人の肩の上に立っています。

Web の部分はStarlette
データの部分はPydantic

FastAPIでStarletteを利用しているので、当然、FastAPIが想定した通りに利用しないと動作しません。
パッとみた感じ、Starletteのクラスなどを継承しているため、今回のデコレータなど参照できてしまいますが、動作しない可能性もあります。
FastAPIのデコレータやメソッドを利用する場合は、 FastAPIのモノかきちんと確認した方が良さそうです。(私は)

とういうことで、凡ミスで1週間時間を潰してしまいましたーー。

これにて凡ミス供養とします。