型ヒント一つが生む四つのもの
一言でいうと
FastAPIでは、型ヒントはコメントではなく、実行される契約です。1つ書くだけで、検証・シリアライズ・ドキュメント・エディターの自動補完が一度にできます。
なぜ契約をコードに書くのか
ドキュメントだけにあるAPI仕様は、必ずコードとずれます。フィールドを1つオプションに変えるときに、ドキュメントも一緒に直す人はいないからです。
そのため、フロントエンドは「このフィールドはnullで来ることもありますか」とチャットで尋ねるようになり、サーバーは予想していなかった本文を受け取って500で落ちます。ログにはKeyErrorの1行だけが残るので、誰が何を間違えて送ったのかもわかりません。検証をハンドラーの中に手で書き始めると、今度はそのコードがエンドポイントごとに少しずつ違ってきます。
型ヒントで契約をコードの中に書いておけば、ドキュメント・検証・エラーレスポンス・クライアントの型が、すべて1か所から出てきます。ずれる場所そのものがなくなります。
で、何が違うのか
@app.post("/items")
def create(item: Item) -> ItemOut: ...
この1行がすることは、次のとおりです。
- リクエストの検証: 本文が
Itemの形でなければ、ハンドラーに入る前に422で拒否します - シリアライズ: 戻り値を
ItemOutでフィルタリングして、JSONにします - ドキュメント:
/openapi.jsonと/docsが自然に生まれます - 型チェック: mypyとエディターが実際に検査します
Flaskなら、request.jsonを取り出してif "name" not in body:を手で書き、それをドキュメントにも別に書いて、2つがずれ始めます。
422は400とは違う
- 400 Bad Request: 自分で決めたルールに違反しました(残高不足、メールアドレスの重複)
- 422 Unprocessable Entity: 形が間違っています(文字列が来るべきところに数値が来た)
FastAPIは、スキーマの違反に対して自動で422を返します。本文には、どのフィールドがなぜ間違っているかが入っています。
{"detail":[{"type":"int_parsing","loc":["body","qty"],
"msg":"Input should be a valid integer","input":"many"}]}
locをクライアントにそのまま渡せば、フォームのフィールドごとのエラー表示が、ただで手に入ります。
response_modelは「取り除く」ための仕組み
最も実務的な機能でありながら、最もよく見落とされます。
class User(BaseModel):
email: str
hashed_password: str # DB 모델에는 있다
class UserOut(BaseModel):
email: str # 나가는 쪽에는 없다
@app.get("/me", response_model=UserOut)
def me() -> User: ... # User 를 돌려줘도 UserOut 으로 걸러진다
ハンドラーが間違ってオブジェクト全体を返しても、レスポンスにはhashed_passwordがありません。これがないと、いつか誰かがreturn userと書いて、ハッシュがAPIから出ていきます。実際によく起きる事故です。
ルールを1つ守るだけで、半分は防げます。入力モデルと出力モデルを、絶対に同じクラスで使わないこと。
async defとdef: ここでサーバーが止まる
FastAPIは、どちらも受け付けます。ところが、動作はまったく違います。
| 宣言 | どこで実行されるか | 中でブロッキングすると |
|---|---|---|
async def |
イベントループ上で直接 | サーバー全体が止まります |
def |
スレッドプールに送られます | そのスレッドだけが止まります |
@app.get("/slow")
async def slow():
time.sleep(1) # ❌ 이 1초 동안 모든 요청이 대기한다
async defの中では、awaitしないブロッキング呼び出しをしてはいけません。time.sleep、requests.get、同期のDBドライバー、重いCPU演算が、すべて該当します。
直す方法は2つです。
- 単に
defで宣言します。すると、FastAPIが自動でスレッドプールに送ります - 非同期のライブラリを使います。
httpx.AsyncClient、asyncpgなどです
初心者が「速くなるように」とasyncを付けたら、かえってサーバーが直列化されてしまうことが、このフレームワークの1番目の落とし穴です。自信がなければ、defで書くほうが安全です。
依存性の注入
Dependsは、「このハンドラーにはこれが必要です」を宣言する仕組みです。
def get_db():
con = connect()
try:
yield con # 핸들러가 쓰는 동안
finally:
con.close() # 응답을 보낸 뒤 정리된다
@app.get("/items")
def items(db = Depends(get_db)): ...
yieldを使うと、後始末のコードが、レスポンスのあとに必ず実行されます。そして、本当の価値は、テストで生まれます。
app.dependency_overrides[get_db] = lambda: FakeDB()
プロダクションのコードを1行も直さずに、差し替えられます。モンキーパッチは必要ありません。
lifespan: on_eventはもう昔の話
@asynccontextmanager
async def lifespan(app):
app.state.pool = await make_pool() # 시작할 때
yield
await app.state.pool.close() # 끝날 때
app = FastAPI(lifespan=lifespan)
@app.on_event("startup")は、非推奨です。新しいコードにはlifespanを使います。コネクションプールやキャッシュクライアントを開く場所です。
実務で引っかかるもの
BackgroundTasksはキューではありません。レスポンスを送ったあとに、同じプロセスで実行されます。ワーカーが再起動すると、そのタスクは消えます。メール送信のように失ってもよいものにだけ使い、失ってはいけないものは、RedisやKafkaに送ります。
ワーカー数。uvicorn --workers Nは、プロセスをN個起動します。それぞれがメモリを別々に使い、グローバル変数を共有しません。インメモリのキャッシュをグローバルに置くと、ワーカーごとに違う値を持ちます。
同期エンドポイントのスレッドプールのサイズは有限です(既定は40)。すべてがブロッキング中なら、次のリクエストはキューで待ちます。defは万能ではなく、クッションにすぎません。