TT Lab
はじめる
学ぶ 学習パス コース

FastAPI — 型がそのまま契約だ

型ヒント一つが生む四つのもの

TT Labで続きを見る

一言でいうと

FastAPIでは、型ヒントはコメントではなく、実行される契約です。1つ書くだけで、検証・シリアライズ・ドキュメント・エディターの自動補完が一度にできます。

なぜ契約をコードに書くのか

ドキュメントだけにあるAPI仕様は、必ずコードとずれます。フィールドを1つオプションに変えるときに、ドキュメントも一緒に直す人はいないからです。

そのため、フロントエンドは「このフィールドはnullで来ることもありますか」とチャットで尋ねるようになり、サーバーは予想していなかった本文を受け取って500で落ちます。ログにはKeyErrorの1行だけが残るので、誰が何を間違えて送ったのかもわかりません。検証をハンドラーの中に手で書き始めると、今度はそのコードがエンドポイントごとに少しずつ違ってきます。

型ヒントで契約をコードの中に書いておけば、ドキュメント・検証・エラーレスポンス・クライアントの型が、すべて1か所から出てきます。ずれる場所そのものがなくなります。

で、何が違うのか

@app.post("/items")
def create(item: Item) -> ItemOut: ...

この1行がすることは、次のとおりです。

  1. リクエストの検証: 本文がItemの形でなければ、ハンドラーに入る前に422で拒否します
  2. シリアライズ: 戻り値をItemOutでフィルタリングして、JSONにします
  3. ドキュメント: /openapi.jsonと/docsが自然に生まれます
  4. 型チェック: mypyとエディターが実際に検査します

Flaskなら、request.jsonを取り出してif "name" not in body:を手で書き、それをドキュメントにも別に書いて、2つがずれ始めます。

422は400とは違う

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つです。

初心者が「速くなるように」と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は万能ではなく、クッションにすぎません。