はじめに
Pythonで使う、WebフレームワークといえばFastAPIですよね!!
起動コマンドはおなじみ、uvicorn main:appです。
しかし、uvicorn main:appはFastAPIのコマンドではありません。
本記事ではFastAPIの動作を実際のFastAPIのコードを追いながら実際の動作を解剖していく記事になります。
興味がある方は最後まで読んでいってください!!
FastAPIを支えるuvicornって??
uvicorn main:app はなにをしているのでしょうか?
まず大前提としてuvicornはFastAPIと同様にOSSです。
Uvicornは、Python用のASGIウェブサーバー実装です。
最近まで、Pythonには非同期フレームワーク向けの最小限の低レベルサーバー/アプリケーションインターフェースが欠けていました。ASGI仕様はこのギャップを埋め、すべての非同期フレームワークで使用できる共通のツールセットの構築を開始できることを意味します。
Uvicornは現在、HTTP/1.1とWebSocketをサポートしています。
と説明されていますが、ざっくり言うと以下を実施しています。
- ポートを開けて、TCP接続を待ち受ける
- リクエストを、指定されたアプリ(main:app)に渡す
それぞれ簡単に解説します。
ポートを開けて、TCP接続を待ち受ける
uvicorn main:appを実行すると、uvicornは指定されたポート(デフォルト8000番)でソケットを開き、HTTPリクエストが飛んでくるのをひたすら待ちます。
ここでやってることは、実はFastAPIとは一切関係ありません。
HTTPメッセージを受け取って、HTTPとして解析するというサーバーとしての仕事だけです。
リクエストを、指定されたアプリ(main:app)に渡す
リクエストが来たら、uvicornは内部でこう実行しています。
result = await app(self.scope, self.receive, self.send)
このappはFastAPIのアプリケーションで良く見る以下の部分とつながっています。
from fastapi import FastAPI
app = FastAPI() # この部分!!!!!!!
@app.get("/")
def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}")
def read_item(item_id: int, q: str | None = None):
return {"item_id": item_id, "q": q}
そのため、uvicorn main:appの main:appはモジュール名:変数名を満たしていれば動きます。
実際のuvicornのコードでも、以下のようなシンプルなコードで実装されています。
module_str, _, attrs_str = import_str.partition(":")
module = importlib.import_module(module_str)
instance = getattr(module, attrs_str)
しかし、変数名がappであること、ファイル名がmain.pyであることは慣習なのでしたがっておいたほうが良いでしょう。
解剖!!FastAPI
ここまで、uvicornの動作を解説してきました。
ではFastAPIインスタンスが作られたあと、どのようにリクエストが処理されるのでしょうか?
実際の流れを見ていきましょう。
FastAPIの構造
FastAPIのインスタンス作成時に、何が行われているのか見てみましょう。
まず、FastAPIクラスの定義を見てみます。
class FastAPI(Starlette):
"""
`FastAPI` app class, the main entrypoint to use FastAPI.
Read more in the
[FastAPI docs for First Steps](https://fastapi.tiangolo.com/tutorial/first-steps/).
## Example
```python
from fastapi import FastAPI
app = FastAPI()
```
"""
FastAPIクラスは、Starletteというクラスを継承しています。
Starletteは軽量なASGIフレームワークで、FastAPIはこれをベースに、型ヒントによるバリデーションやOpenAPIドキュメントの自動生成といった機能を上乗せしたものです。
ちなみにStarletteもOSSです。
つまりapp = FastAPI()と書いたとき、実体はStarletteに、便利な機能を追加したものが作られている、というイメージを持っておくと以降の説明がわかりやすくなります。
APIRouterについて
FastAPIクラスの初期化処理(__init__)を見てみると、以下の処理を確認することができます。
self.router: routing.APIRouter = routing.APIRouter(
routes=routes,
redirect_slashes=redirect_slashes,
dependency_overrides_provider=self,
on_startup=on_startup,
on_shutdown=on_shutdown,
lifespan=lifespan,
default_response_class=default_response_class,
dependencies=dependencies,
callbacks=callbacks,
deprecated=deprecated,
include_in_schema=include_in_schema,
responses=responses,
generate_unique_id_function=generate_unique_id_function,
strict_content_type=strict_content_type,
)
ここでself.routerという属性に、APIRouterのインスタンスが作られています。
@app.get("/")
def read_root(): #この関数の中に処理を書く
return {"Hello": "World"}
これは、どのpathにどの処理を紐づけるのかを定義するためのものです。
上記のようにpathを登録すると、その情報はこのself.routerにどんどん追加されていきます。
つまり、app = FastAPI()を実行した時点で作られるのは
- Starletteを継承したFastAPIインスタンス自体
- その中に格納された、ルート情報を管理するAPIRouterインスタンス
というのがここでのポイントです。
ここまでは、前セクションで説明したuvicorn main:appを実行するタイミングの話です。
では、実際にリクエストが来たらどのように処理されるのか見ていきましょう。
実際にリクエストが来たらどうなるか
まず、uvicornがHTTPメッセージを受け取ります。
受け取った生のHTTPメッセージはそのままでは使えないので、uvicornが内部で解析し、scope・receive・sendという3つの要素に変換します。(ASGIという規約に沿う形で解析します)
準備ができたら、uvicornは、指定されたアプリ(main:appのapp)を呼び出します。
ここで、
appはFastAPI()で作ったインスタンス(オブジェクト)のはずだから関数のようには呼べないのでは?
と思った方は熟練のPython使いの方と見えます![]()
実は、FastAPIには__call__が定義されています。
Pythonには、クラスに__call__というメソッドを定義しておくと、そのインスタンスを関数のように()で呼び出せる、という仕組みがあるため、リクエスト時にはこのメソッドが起点になり処理がスタートします。
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if self.root_path:
scope["root_path"] = self.root_path
await super().__call__(scope, receive, send) #Starlette側を呼び出し
この処理を見ると、super().__call__...なのでStarlette側に処理が遷移します。
Starlette側の動作
FastAPIの__call__から呼ばれた、Starlette側の__call__は以下のようになっています。
async def __call__(self, scope, receive, send):
scope["app"] = self
if self.middleware_stack is None:
self.middleware_stack = self.build_middleware_stack()
await self.middleware_stack(scope, receive, send)
ポイントは二つあります。
-
self.middleware_stackがNoneの場合だけ、build_middleware_stack()が実行される- つまり最初のリクエストが来た時に1回だけ組み立てられ、以降はキャッシュされたものが再利用される
- 組み立てが終わったら、それを(scope, receive, send)付きで呼び出す
ではミドルウェアを作る部分を見てみましょう。
実際のコードでは以下のようになっています。
def build_middleware_stack(self):
# 省略
middleware = [Middleware(ServerErrorMiddleware, ...)]
middleware += self.user_middleware
middleware.append(Middleware(ExceptionMiddleware, ...))
app = self.router
for cls, args, kwargs in reversed(middleware):
app = cls(app, *args, **kwargs)
return app
やっていることを一言で言うと、Routerを中心に、外側へ外側へとミドルウェアを重ねていく処理です。
self.appはポインタの役割を果たしており、次のクラスはどこを見に行くのかを表しています。
最初のループでは、app(この時点ではself.router)を引数にしてExceptionMiddleware(self.router, ...)が実行され、ExceptionMiddlewareインスタンスが作られます。
このインスタンスのself.appにはRouterが入っている状態です。
さらに、そのappがuser_middlewareに渡され、マトリョーシカのような構造になります。
しかし、この処理はFastAPI側で拡張されており、独自の1層が追加されています。
def build_middleware_stack(self) -> ASGIApp:
# Duplicate/override from Starlette to add AsyncExitStackMiddleware
# 省略
middleware = (
[Middleware(ServerErrorMiddleware, ...)]
+ self.user_middleware
+ [
Middleware(ExceptionMiddleware, ...),
Middleware(AsyncExitStackMiddleware), # ← FastAPI独自の追加層
]
)
app = self.router
for cls, args, kwargs in reversed(middleware):
app = cls(app, *args, **kwargs)
return app
なおAsyncExitStackMiddlewareはリストの最後に追加されるため、reversed()によって最初にラップされるのはExceptionMiddlewareではなくAsyncExitStackMiddlewareになります。
図としてまとめると、以下のようになります。
ビジネスロジックはどこでよばれるか?
いよいよ大詰めです。
最後、Routerは何をやっているのか見てみましょう。
async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
# 省略
for route in self.routes:
# Determine if any route matches the incoming scope,
# and hand over to the matching route if found.
match, child_scope = route.matches(scope)
if match == Match.FULL:
scope.update(child_scope)
await route.handle(scope, receive, send)
return
elif match == Match.PARTIAL and partial is None:
partial = route
partial_scope = child_scope
諸々省略しますが、本質としてはリクエストのパスに一致するrouteを探すことだけです。
しかし、これも前セクションと同じパターンです。実はRouterのappメソッドも、FastAPI側(APIRouter)で上書きされています。
async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
# 省略(内容はStarlette版とほぼ同じ)
for route in self.routes:
match, child_scope = route.matches(scope)
if match == Match.FULL:
scope.update(child_scope)
await route.handle(scope, receive, send)
return
if match == Match.PARTIAL and partial is None:
partial = (route, child_scope)
やっていることはStarlette版とほぼ同じ(パスを探すだけ)ですが、コードの実体はFastAPI側に複製されています。
また、ここで渡されるrouteは、実はStarletteのRouteではなく、記事冒頭で記載したAPIRouteです。
raw_response = await run_endpoint_function(
dependant=dependant,
values=solved_result.values,
is_coroutine=is_coroutine,
)
このrun_endpoint_functionが、@app.get("/")等で登録した関数を実行します。
ここまでが一連の処理の流れでした!!
お疲れさまでした!!
最後に
FastAPIはルーティング定義された関数を実行してくれるんでしょ?くらいの理解しかなかったんですが、実際にコードを追ってみて、理解が深まったと思います。
また、処理の流れが入れ子構造になっていたり、FastAPIを支える様々なOSSのおかげで便利に開発できているんだなぁと痛感しました。(OSSのメンテナー様ありがとう!!!!!)
この記事をきっかけにFastAPIについてもっと知りたい!!や理解が深まった!!って人がいてくれたら大変、嬉しく思います。
ここまで読んでいただきありがとうございました![]()
