Chapter 03

底层机制与设计权衡

上一章追踪了请求穿过的四层——这一章钻进其中最关键的几个机制,看它们各自怎么实现、代价是什么,以及把 FastAPI 放进整个生态里该何时选、何时别选。

本章你将建立的 schema

  • 类型注解如何在导入时被读出,派生出校验、转换、序列化、OpenAPI 四个产物
  • Pydantic v2 在类定义时把字段编译进 Rust 内核,每次请求只是一次 Rust 调用
  • 依赖注入图的解析顺序(DFS 先子后父)、每请求缓存、yield 依赖的 LIFO 清理
  • 单线程事件循环:async def 跑在循环上、def 被丢进线程池——以及由此而来的反直觉结论
  • WSGI 与 ASGI 的分水岭,FastAPI / Starlette / Flask / Django / Litestar 的取舍

把"底层机制"讲清楚的标准只有一个:能说出被放弃的备选方案,并解释代价落在哪里。下面五个机制,每一个都配一张备选方案对比表——表里被放弃的两三行,正是这个设计存在的理由。读完任何一节,工程师都应能回答"换成另一种做法会怎样"。

3.1类型注解 → 四个产物

一份函数签名被读一次,同时驱动校验、类型转换、OpenAPI 文档和编辑器补全。

为什么需要它

在没有这套机制的框架里,工程师要分三处重复声明同一份"数据形状":在处理函数里手动从请求取值并转型、单独写一个校验层、再手写一份接口文档。三份描述同一件事,任何一处改动都要同步另外两处,漏掉就出 bug 或文档失真。01 章把这条命题命名为"类型注解即规约";这一节看它在机制层面怎么落地。

运行方式

程序导入时,@app.get(...) 装饰器(这是 01 章定义的 path operation decorator,路径操作装饰器)就调用 inspect.signature() 读出处理函数每个参数的注解与默认值。来源按三条规则推断——这三条是 FastAPI 的核心约定:

  • 参数名出现在路径模板的 {} 里 → 路径参数(path parameter);
  • 注解是一个 Pydantic 模型(用类型注解声明数据形状的类)→ 请求体(request body);
  • 注解是普通标量(int、str、bool 等)且名字不在路径里 → 查询参数(query parameter)。

用 Path() / Query() / Body() 标注可以显式覆盖默认推断。规则定下来源后,同一个注解被分发给四个去处:Pydantic 校验器(值合法吗)、类型转换(URL 里的 "5" 转成 int 的 5)、OpenAPI schema 生成器(写进 /openapi.json)、以及静态类型检查器与编辑器。没有第二份 schema 文件——签名本身就是规约。

inference_rules.py Python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Item(BaseModel):       # 一个 Pydantic 模型
    name: str
    price: float

@app.put("/items/{item_id}")
def update_item(item_id: int, q: str | None = None, item: Item = None):
    # item_id —— 名字在路径 {item_id} 里      → 路径参数
    # q       —— 普通标量、名字不在路径里      → 查询参数
    # item    —— 注解是 Pydantic 模型          → 请求体
    return {"item_id": item_id, "q": q, "item": item}
item_id同一规则把它定位成路径参数并转成 int; item同一规则把它认成请求体,请求时 JSON 会被喂进 Item 的校验器。三个参数、三种来源,全部从一行签名推出,不需要任何 request.get(...) 调用。

这条推断链的全貌见下图:一个注解进去,四个产物出来。

item: Item 一行签名注解 请求校验 响应序列化 OpenAPI 文档 编辑器补全
图 3.1一行类型注解被 inspect.signature() 读出后,派生出四个互相一致的产物。注意:四个产物来自同一份声明,所以它们永远不会互相脱节——这正是"没有第二份 schema"的含义。
表 3.1 · 「数据形状声明在哪里」的三种做法
方案优势为什么没选
手写取值 + 手动校验(Flask 风格 request.args.get()) 零抽象、零依赖,所见即所得 每个参数手动取、手动转型、手动校验;文档要另写,与代码必然脱节;改一处要追三处
单独的 Serializer 类(DRF 风格) 校验逻辑集中、可复用、可继承 函数签名与 Serializer 是两份 schema,必须人工保持同步;漏改一处就出静默 bug
类型注解即规约(FastAPI) 一份签名派生校验/转换/序列化/文档,无法脱节 选中
带来的代价

整套机制强耦合于 Python 的类型注解系统:注解写得越精确,框架能做的越多;注解一旦缺失或写成 Any,校验、转换、文档同时退化。动态参数(运行时才知道字段名、数量可变的场景)无法用静态注解直接表达,要绕到 Annotated 或自定义依赖里处理。换言之,便利来自一个前提——你愿意把规约写成类型。当数据形状本身是动态的,这个前提就成了约束。

想一想

把签名改成 def f(q: str)——q 没有默认值、注解是标量、名字不在路径里。请求 GET /items/5(没带 ?q=...)会发生什么?

展开答案(先停 10 秒再点)

q 被推断为查询参数;没有默认值意味着它是必填的。缺失时框架在你的函数执行之前就返回 422 Unprocessable Entity,错误体里精确指出 query.q: field required。

设计点:必填/可选不是单独的开关,而是"有没有默认值"这一个 Python 语法事实推出来的——又一次,签名即规约。

3.2Pydantic v2 在定义时编译进 Rust

模型类一被定义,字段就被编译成 Rust 校验器并缓存;每次请求只是一次 Rust 调用,没有逐字段的 Python 循环。

为什么需要它

校验是热路径——每个带请求体的请求都要跑一遍。若校验逻辑用解释执行的 Python 在每次请求时逐字段循环,开销会随字段数线性堆叠。把"解析模型定义"这件事从每请求挪到每类定义一次,再把热路径下沉到编译后的本地代码,是 Pydantic v2 比 v1 快 5–50× 的根因。

运行方式

class Item(BaseModel) 这行触发 Pydantic 的元类(metaclass,控制类如何被创建的类)。元类里的 GenerateSchema 遍历每个字段的注解,产出一份 core schema——一个带 type 键的嵌套 dict(例如 {'type': 'model', 'fields': {...}}),存在类属性 __pydantic_core_schema__ 上。这份 dict 随即交给 Rust 写的 pydantic-core,编译成两个分开的本地对象:

  • SchemaValidator——一棵校验器树,缓存为 __pydantic_validator__,负责 validate_python() / validate_json()(输入 → 模型实例);
  • SchemaSerializer——序列化器,缓存为 __pydantic_serializer__,负责 to_python() / to_json()(模型实例 → 可发送的数据)。

校验与序列化是两个不同的编译产物,方向相反、独立编译。这解释了 01 章 response_model 的行为:请求进来走 SchemaValidator,响应出去走 SchemaSerializer,是两段独立的本地代码,不是同一个对象正反跑。运行时每次请求只调用一次编译好的校验器,把整批数据交给 Rust,没有 Python 层的逐字段 for 循环。

compiled_at_definition.py Python
from pydantic import BaseModel

class Item(BaseModel):       # ← 类定义这一刻:元类触发,编译已发生
    name: str
    price: float

# 编译产物在类定义后立刻就存在,无需先校验一次:
print(type(Item.__pydantic_validator__))   # SchemaValidator
print(type(Item.__pydantic_serializer__))  # SchemaSerializer
print(Item.__pydantic_core_schema__["type"])  # 'model'

# 每次校验只是一次 Rust 调用,不是 Python 逐字段循环:
obj = Item.model_validate({"name": "pen", "price": 2.5})
print(obj.model_dump())  # {'name': 'pen', 'price': 2.5}
class Item这一行执行完,三个 __pydantic_*__ 属性已经存在——证明编译发生在定义时,不是首次校验时; model_validate这是一次进入 Rust 的调用,校验器树在那边遍历数据。
运行环境:FastAPI 0.135.2 / Pydantic 2.12.4 / Python 3.13(本机实测)。

注:示例输出基于 Pydantic 2.12.4;SchemaValidator / SchemaSerializer 是 pydantic_core 暴露的类型名,跨小版本稳定。

表 3.2 · 校验/序列化引擎的几种实现路线
方案优势为什么没选
Pydantic v1(纯 Python 校验) 纯 Python、易读易改、无编译步骤 每次校验在解释器里逐字段循环,热路径慢;v1 写法已在 FastAPI 0.136.x 移除
marshmallow(独立校验/序列化库) 成熟、灵活、与框架解耦 schema 要单独声明(又是两份),且同样是 Python 层执行,无类型注解驱动
attrs + 手写校验 轻量、对数据类友好 校验逻辑全要自己写,无自动 JSON schema、无 OpenAPI 输出
pydantic-core(Rust 内核) 定义时编译、运行时一次本地调用,快 5–50× 选中
带来的代价

三笔代价。其一,定义时有编译开销——模型多的程序启动会稍慢,因为所有 SchemaValidator 都在导入时建好。其二,错误信息由 Rust 产生,格式与 v1 不同,旧的按字符串匹配错误的代码会失效。其三,自定义校验器(field_validator 等)是 Python 函数,运行时要跨 Python/Rust 边界来回调用,频繁触发会吃掉一部分本地代码的速度优势。换句话说,把热路径搬进 Rust 的收益,在大量自定义 Python 校验逻辑面前会打折。

想一想

一个有 30 个字段的模型,和一个有 3 个字段的模型,单次校验的 Python 端开销差多少?

展开答案(先停 10 秒再点)

从 Python 端看几乎一样——两者都只是一次进入 Rust 的调用。字段遍历发生在编译好的校验器树内部(Rust 里),不在 Python 解释器里。字段数主要影响的是定义时的编译量,而不是每请求的 Python 调用开销。

设计点:这正是 v2 把循环下沉到 Rust 的目的——让每请求成本对字段数不敏感。v1 里这个差距会随字段数明显拉大。

3.3依赖注入图:解析、缓存、清理

Depends 存的是未调用的函数;框架递归读签名连成一张图,请求时深度优先解析,yield 依赖按 LIFO 逆序清理。

为什么需要它

多个 path operation 常常需要同一批前置准备:拿数据库连接、解析当前用户、读配置。手动在每个函数里调用这些准备步骤,会导致顺序写死、共享逻辑复制多份、且无法表达"准备好后还要在请求结束时清理"。Depends 把这些前置声明成函数,由框架负责调用顺序、去重和清理。

运行方式

Depends(get_db) 存进去的是未被调用的函数对象 get_db 本身(注意没有那对括号)。框架对它做递归签名自省:get_db 的参数若也是 Depends(...),就继续往下读,直到所有依赖连成一张有向图。请求到来时,框架对这张图做深度优先遍历(DFS)——先解析子依赖,再解析父依赖,因为父依赖的执行需要子依赖的结果作为入参。

两个机制决定行为细节:

  • 每请求缓存:默认 use_cache=True,每个唯一可调用对象在一次请求内只执行一次,结果存进请求级的字典。同一个 get_db 出现在五个子依赖里,也只调用一次、五处共享同一结果。传 use_cache=False 才强制每次重跑。
  • yield 依赖的清理:用 yield 而非 return 的依赖,被当作(异步)上下文管理器压入一个 AsyncExitStack(一个能按栈序统一关闭多个上下文的对象)。yield 之前是 setup、yield 出的值被注入处理函数、yield 之后的清理代码在栈展开时按 LIFO(后进先出)逆序执行。默认清理发生在响应已经发出之后。

LIFO 顺序不是细节:它保证父依赖的清理代码运行时,它依赖的子资源还没被关。下图把一张三层依赖图的解析顺序与清理顺序并排画出。

依赖图(谁需要谁) 解析与清理时序 handler 依赖 A 依赖 B 依赖 C 需要 需要 需要 解析 DFS → ① C ② A ③ B ④ handler 执行 响应发出 清理 LIFO → ⑤ B 清理 ⑥ A 清理 ⑦ C 清理 逆序
图 3.2handler 需要 A 和 B,A 需要 C:解析按 DFS 先子后父(C→A→B→handler),清理按 LIFO 逆序(B→A→C)。注意:清理顺序是解析顺序的镜像,这保证 A 的清理代码运行时它用的 C 还没关。
yield_dependency.py Python
from typing import Annotated
from fastapi import Depends, FastAPI

app = FastAPI()

def get_db():
    db = open_connection()   # yield 之前:setup
    try:
        yield db             # yield 出的值被注入处理函数
    finally:
        db.close()           # yield 之后:清理,默认在响应发出后跑

@app.get("/items")
def list_items(db: Annotated[Connection, Depends(get_db)]):
    return db.query("select * from items")
    # 函数返回后,响应先发给客户端,然后才执行上面的 db.close()
yield db这一行把控制权交回框架;db 注入处理函数,finally 块成为压进 AsyncExitStack 的清理动作; Annotated这是现代写法(FastAPI 文档默认),取代旧的 db = Depends(get_db) 默认值写法。
表 3.3 · 「共享前置资源」的几种组织方式
方案优势为什么没选
全局单例 / 模块级变量 写法最简单,随处可取 无法做每请求隔离,难测试(替换不掉),生命周期与请求脱节,并发下易串数据
装饰器手动包裹处理函数 显式、就近可见 多个依赖嵌套时装饰器叠成洋葱,顺序与清理要自己管,无自动去重
函数参数层层透传 完全显式、无魔法 深层调用链里每一层都要重复声明同一个参数,签名污染、改动放大
Depends 依赖图 自动解析顺序、每请求去重缓存、yield 统一清理 选中
带来的代价

解析顺序是隐式的——它由图结构推出,不在代码里显式写出,读代码时要在脑子里重建这张图才知道谁先跑。更尖锐的代价在缓存:默认每请求缓存对有副作用的依赖是陷阱。一个本意"每次生成新随机 token"或"每次开新连接"的依赖,若在一次请求里被多处引用,默认只会执行一次、各处拿到同一个值。要它每次重跑必须显式传 use_cache=False,忘了就得到一个安静的错误。

想一想

一个依赖 new_request_id() 每次调用返回一个新 UUID。它被三个子依赖各引用一次。一次请求里它实际执行几次?三处拿到的 ID 一样吗?

展开答案(先停 10 秒再点)

执行一次。默认 use_cache=True,三处拿到的是同一个 UUID。若意图是"每处一个独立 ID",结果就错了——要在那个 Depends 上写 use_cache=False 才会执行三次、拿到三个不同 ID。

设计点:缓存默认开是为了让"取数据库连接"这类幂等依赖天然只跑一次;但它对"每次都该不同"的副作用型依赖是反的。判断标准是这个依赖幂等吗,不是它看起来该不该缓存。

3.4async 并发模型(第二个反直觉转折)

事件循环单线程:async def 直接跑在循环上,普通 def 被框架丢进线程池——所以在 def 里写阻塞调用反而更安全。

为什么需要它

Web 服务的瓶颈通常不是 CPU,而是等待——等数据库、等下游 API、等磁盘。传统"每请求一线程"模型下,上千个等待中的连接就是上千个几乎全程闲置却各占内存的线程,到一定规模内存就爆(经典的 C10k 问题:单机一万并发连接)。事件循环用一个线程在多个等待点之间切换,把闲置的等待时间利用起来。

运行方式

事件循环(event loop,一个在单线程里轮流推进多个协程的调度器)是整个 ASGI 栈的心脏。两类路由进入它的方式完全不同:

  • async def 路由直接跑在事件循环这一个线程上。函数里每个 await 是一个让出点(yield point):协程在此暂停、把线程交回循环,循环趁机去服务别的连接,等这个 await 的 I/O 就绪再回来续跑。
  • 普通 def 路由被框架自动包成 await run_in_threadpool(fn),丢进 AnyIO 的工作线程池(默认 40 个线程)。它在另一个线程里跑,不占用事件循环那个线程。

"阻塞循环"的确切含义:在 async def 里执行 CPU 密集计算或同步 I/O(time.sleep()、同步数据库驱动)且中途没有 await。因为整个事件循环就一个线程,这段不让出的代码一旦占住它,循环无法切去服务任何其它连接——所有并发请求一起卡住,不只是当前这个。

第二个反直觉转折

同步阻塞代码放在普通 def 路由里更安全——它被丢进线程池,卡住的是 40 个工作线程中的一个,事件循环照常服务其他连接。把同一段阻塞代码放进 async def 却不 await,是灾难:它冻住整个服务。"async 一定更快"是直觉,但用错位置时 async def 比 def 危险得多。这就是 index 与本章开头预告的那个反转。

请求到达 async def def 事件循环(单线程) 每个 await 让出 单线程轮流推进 多连接共享一个线程 await 时切到别的连接 run_in_threadpool 框架自动包裹 AnyIO 线程池(40 线程) 阻塞只卡住其中一个线程 循环不受影响 阻塞放这里 全服务冻住 阻塞放这里 只占一个工作线程,安全
图 3.3同一个请求按路由类型分两路:async def 进单线程事件循环,def 进 40 线程池。注意:把阻塞调用放在左路(async def 且不 await)会冻住整个服务;放在右路(def)只占用一个工作线程——这就是"def 更安全"的机制。
blocking_placement.py Python
import time
from fastapi import FastAPI

app = FastAPI()

@app.get("/sync-blocking")
def sync_blocking():
    time.sleep(10)        # 阻塞——但被丢进线程池,只卡住一个工作线程
    return {"ok": True}   # 其它请求照常被事件循环服务

@app.get("/async-blocking")
async def async_blocking():
    time.sleep(10)        # 灾难:在事件循环线程上同步阻塞、没有 await
    return {"ok": True}   # 这 10 秒内,整个服务的所有连接都冻住

@app.get("/async-correct")
async def async_correct():
    import asyncio
    await asyncio.sleep(10)  # 正确:await 让出,循环可服务别的连接
    return {"ok": True}
sync_blockingdef + 阻塞:被 run_in_threadpool 隔离,安全; async_blockingasync def + 同步阻塞:冻结整个循环; async_correctasync def + await:让出点让循环继续工作。
常见错误

在 async def 路由里用同步数据库驱动(如普通 psycopg2、同步 SQLAlchemy 会话),常以 MissingGreenlet 异常炸出来。根因是同步驱动期望在一个真实线程里阻塞等待,而 async def 把它放在了事件循环上、没有可阻塞的 greenlet 上下文。修法二选一:要么用异步驱动(asyncpg + SQLAlchemy 2.0 async),要么把这个端点改成普通 def,让框架替你丢进线程池。

表 3.4 · 高并发 I/O 下的几种并发模型
方案优势为什么没选
每请求一线程 编程模型简单,阻塞代码随便写 上千等待连接 = 上千闲置线程,内存爆(C10k);线程切换开销随并发上升
每请求一进程 隔离彻底、绕开 GIL 进程比线程更重,内存与启动成本更高,并发规模更受限
回调式异步(callback) 单线程、无线程开销 嵌套回调难读难维护(callback hell),错误处理与控制流割裂
async 事件循环(async/await) 单线程高并发、await 让出点显式、代码读起来像同步 选中
带来的代价

async 是协作式调度:循环依赖每个协程主动在 await 处让出。一个不让出的协程(CPU 密集或同步阻塞)就能破坏整体公平,让其他连接饿死。第二笔代价是那 40 个线程是 def 路由的硬上限——超过 40 个并发的阻塞 def 请求,后来的就得排队等线程。第三,CPU 密集任务用 async def 不但没好处,反而更糟:Python 的 GIL(全局解释器锁,同一时刻只允许一个线程执行 Python 字节码)让计算无法真正并行,而占住循环又冻住所有 I/O 连接——这类任务该交给进程池或任务队列。

想一想

一个端点要做 5 秒的纯 CPU 计算(图像处理、压缩)。写成 async def 还是 def?两者各自的后果是什么?哪个其实都不够好?

展开答案(先停 10 秒再点)

两者都不理想,但程度不同。写成 async def:这 5 秒占住事件循环线程、没有 await 让出点,整个服务冻 5 秒——最坏。写成 def:被丢进线程池,至少不冻循环;但 GIL 让这段 Python 计算无法与其他线程真正并行,且会占满一个工作线程。

设计点:真正的解法是把 CPU 密集任务移出请求路径——交给进程池(ProcessPoolExecutor)或任务队列(Celery/RQ)。这条结论直接喂给下一节的"何时不要用 FastAPI"。

3.5设计权衡与生态定位

WSGI 与 ASGI 是 Python web 生态的根本分水岭;FastAPI 的真正优势是 I/O 等待下的并发,不是裸速度。

为什么需要它

选型不是"哪个最快",而是"哪个匹配你的瓶颈"。把 FastAPI 放进生态坐标里——它在哪一侧的分水岭、相比邻居强在哪、哪些场景反而是别的框架的主场——才能在真实项目里做出不后悔的决定。这一节为 04 章的判别题铺垫。

运行方式:两条协议血脉

02 章讲过 ASGI 协议把应用定义成一个 async 可调用对象,靠 receive/send 收发消息,所以 send 能调用多次——这是流式输出和 WebSocket 的根基。它的前身 WSGI 是一次同步调用、一进一出,无法在一条连接上多次收发,也无法在空闲时让出。这条协议差异把整个生态切成两半:

  • WSGI 一侧:Flask、传统同步 Django。成熟、生态厚、同步编程模型直观,但天生不擅长长连接与高并发 I/O。
  • ASGI 一侧:FastAPI、Starlette、Litestar、现代异步 Django。原生支持 async、流式、WebSocket。
表 3.5 · 五个框架的定位与选择时机
框架协议 / 定位何时选它
Flask WSGI,极简微框架 小型同步服务、服务端渲染 HTML、团队已熟悉、不需要高并发 I/O
Django(+ DRF) WSGI 为主,全家桶 要 admin 后台 + ORM + 模板开箱、内容站、CRUD 重、团队要"电池全包"
Starlette ASGI,轻量工具箱 只要路由 + Request/Response + 中间件,不需要校验/DI/自动文档的薄转发层
Litestar ASGI,电池较全 想要类 FastAPI 的体验但偏好其内置 DI/ORM 集成与项目结构约定
FastAPI ASGI(基于 Starlette),API 优先 要类型驱动的校验 + DI + 自动 OpenAPI 文档 + 高并发 I/O 的 JSON/流式 API

下面这棵决策树把"何时选谁"压成四个问题。它直接对应 04 章的判别题。

选型开始 CPU 密集为主? 是 进程池 / 任务队列 不是框架问题 否 要 admin + ORM? 是 Django 后台/ORM 开箱 否 要校验/DI/自动文档? 是 FastAPI 类型即规约 否 裸 Starlette 薄 ASGI 转发
图 3.4四个问题决定选型落点:CPU 密集走进程池/队列,要后台走 Django,要校验/DI/文档走 FastAPI,只要转发走裸 Starlette。注意:第一个问题先把"这其实不是框架能解决的问题"筛掉——CPU 密集换框架没用。

何时不要用 FastAPI

  • CPU 密集型负载:GIL 让 async 帮不上忙,放进 async def 还会阻塞循环(见 3.4)。该用进程池或任务队列,框架选择无关紧要。
  • 重服务端渲染 HTML:要返回大量模板渲染的页面而非 JSON,Django 或 Flask + 模板引擎是更顺的主场,FastAPI 的类型/校验优势用不上。
  • 需要开箱的 admin 后台 / 成熟 ORM:Django 的 admin 和 ORM 是几乎无可替代的生产力来源;FastAPI 不自带这些,要自己拼装。
基准测试警告

TechEmpower 这类 hello-world 基准(FastAPI 约 15–20k RPS、Flask 约 2–4k RPS)剥离了真实应用必有的鉴权、ORM、中间件和校验,把框架开销放大成了主角。真实应用里约 93% 的延迟发生在框架之外(业务逻辑、网络、序列化),而 DB-bound 端点的瓶颈在数据库——框架选择对总性能的影响很小。FastAPI 真正的优势是 I/O 等待下的并发能力(事件循环把等待时间利用起来),不是单请求的裸速度。拿 hello-world RPS 当选型依据会得出错误结论。

表 3.6 · WSGI vs ASGI:分水岭的两侧
维度WSGI(Flask、传统 Django)ASGI(FastAPI、Starlette、Litestar)
应用形态同步可调用,一进一出async 可调用,receive/send 收发消息
一条连接上多次收发不支持支持(send 可多次调用)
WebSocket / 流式 / SSE原生不支持一等公民
高并发 I/O 等待靠多线程/多进程堆事件循环单线程复用
同步阻塞代码天然适配要丢线程池或改异步驱动
带来的代价

站在 ASGI 一侧的代价是:整个调用链要"异步感知"。一个不小心的同步阻塞调用就能拖垮事件循环(3.4);异步生态虽已成熟,但仍比 WSGI 那套久经考验的同步库少一些选择,且异步代码的调试与心智模型门槛更高。选 FastAPI 等于承诺团队理解事件循环——这是它的能力来源,也是它的入门成本。

想一想

一个端点只是把收到的请求体原样转发给另一个内部服务,不需要校验、不需要文档、不需要 DI。裸 Starlette 够用吗?什么时候 FastAPI 那层封装才值回它的开销?

展开答案(先停 10 秒再点)

裸 Starlette 够用——这个端点用不到 FastAPI 在每个路由外加的"读类型→校验→序列化→生成文档"那层(02 章 #a-fastapi)。FastAPI 的封装在出现结构化输入输出时才值回票价:需要校验请求体、需要自动文档、需要依赖注入。纯转发场景这些全为零,封装是净开销。

设计点:FastAPI = Starlette + 一层 per-endpoint 封装。封装的价值与"你的端点有多少结构化数据契约"成正比;契约为零时,封装的价值也趋近零。

3.6跨机制综合:一个请求如何同时用上四者

前五节把机制拆开看。真实的一个请求会在几毫秒内同时用上类型系统、Pydantic 内核、依赖图和并发模型。把它们串成一条时间线,机制之间的接缝才显出来。

设想一个 async def 处理函数:它声明了一个 item: Item 请求体、一个 user: Annotated[User, Depends(get_current_user)] 依赖,而 get_current_user 又依赖 get_db(一个 yield 依赖)。一次 POST /items 进来:

  1. 导入时(早已发生):inspect.signature() 读出这个函数的签名(3.1),把 item 定位成请求体、user 认成依赖;同时 Item 类定义触发元类,SchemaValidator 已编译进 Rust 并缓存(3.2);get_current_user→get_db 的依赖图也已连好(3.3)。这些都不在请求路径上发生。
  2. 依赖解析:请求到来,框架对依赖图做 DFS——先解析子依赖 get_db(运行到它的 yield,连接被压入 AsyncExitStack),再用其结果解析 get_current_user(3.3)。
  3. 请求体校验:框架把 JSON body 喂给 Item 那个编译好的 SchemaValidator——一次 Rust 调用(3.2);不合法就在处理函数执行之前返回 422(3.1 的推断决定了"哪些字段必填")。
  4. 函数执行:因为是 async def,处理函数直接跑在事件循环线程上;函数体里每个 await(比如 await 数据库)都让出,循环趁机服务别的连接(3.4)。若这里误用同步阻塞调用,整个循环会冻住。
  5. 响应序列化:返回值过 response_model 对应的 SchemaSerializer——另一个 Rust 产物,方向与校验相反(3.2),多余字段被过滤掉。
  6. 清理:响应发出之后,AsyncExitStack 按 LIFO 展开,get_db 的 yield 之后那段关闭连接(3.3)。

四个机制不是四个独立特性,而是同一份类型注解在请求生命周期不同阶段被反复使用的结果:Item 这一个注解,在第 3 步被校验、第 5 步被序列化;Depends 声明的图在第 2 步解析、第 6 步清理。这正是 index 那句"类型签名就是规约"在机制层的全貌——也接上了 02 章 请求生命周期 7 阶段的同一条链。

洞察 · 接缝在哪

四个机制的接缝都落在同一份类型注解上。类型系统(3.1)决定注解被读成什么;Pydantic 内核(3.2)决定注解被编译成校验/序列化两个产物;依赖图(3.3)决定带 Depends 的注解何时解析与清理;并发模型(3.4)决定这一切跑在循环上还是线程池里。改一行注解,四处行为同时变——便利与耦合是同一枚硬币的两面。

§本章 self-check

先合上教程,把你能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。

  1. FastAPI 用哪个标准库函数在导入时读出处理函数的参数注解?把 item: Item 推断成请求体、把不在路径里的标量推断成查询参数,分别依据哪条规则?
  2. Pydantic v2 的 SchemaValidator 在什么时刻被编译?为什么这让单次校验的 Python 端开销对字段数不敏感?校验器和序列化器是同一个对象吗?
  3. 一个依赖出现在三个子依赖里,默认执行几次?要让它每次都重跑该传什么参数?yield 依赖的清理代码按什么顺序、在什么时间点跑?
  4. 为什么把 time.sleep(10) 放在普通 def 路由里不会冻住整个服务,放在 async def 路由里却会?(设计层面,不只是"因为线程池")
  5. 跨机制综合:一次 POST /items 请求里,item: Item 这同一个注解在哪两个不同阶段被用到、分别用的是哪个编译产物?把这两个阶段相对于"你的函数执行"的先后位置说清楚。
答案(先做完再展开)
  1. inspect.signature()。item: Item 的注解是 Pydantic 模型 → 请求体;不在路径 {} 里的普通标量 → 查询参数。用 Path()/Query()/Body() 可显式覆盖。
  2. 在类定义时由元类触发 GenerateSchema → core schema → Rust 编译,缓存为 __pydantic_validator__。因为字段遍历在编译好的 Rust 校验器树内部进行,Python 端每次只是一次调用,与字段数无关。校验器(SchemaValidator)和序列化器(SchemaSerializer)是两个分开的编译产物,方向相反。
  3. 默认 use_cache=True,一次请求内只执行一次,三处共享同一结果。要每次重跑传 use_cache=False。yield 依赖的清理被压入 AsyncExitStack,按 LIFO 逆序、默认在响应发出之后执行。
  4. def 路由被框架 run_in_threadpool 丢进 AnyIO 线程池(40 线程),阻塞只占住其中一个工作线程,事件循环照常服务其它连接。async def 直接跑在单线程事件循环上,同步阻塞且无 await 让出点会占死这唯一的线程,所有连接一起冻住。设计层面:安全性取决于阻塞代码跑在哪个线程上,而路由的 def/async def 决定了这一点。
  5. item: Item 在请求体校验阶段(你的函数执行之前)走 SchemaValidator;在响应序列化阶段(你的函数执行之后)走 SchemaSerializer。同一份注解,两个方向相反的 Rust 产物,分居函数执行的前后两侧。
进阶挑战 · 刚好够不着

自定义校验器会把"快 5–50×"吃掉多少?

3.2 说每次请求只是一次 Rust 调用,所以快。但若给模型的某个字段加一个 Python field_validator,校验过程就要在 Rust 校验器树跑到那个字段时回调进 Python,跑完再回 Rust。一个有 10 个字段、其中 8 个带 Python 自定义校验器的模型,它相比纯 Rust 校验的模型,速度优势还剩多少?为什么?这对"什么时候该用自定义校验器、什么时候该换成 Annotated 内置约束"有什么指导?

提示(卡住再展开)

想清楚跨边界调用的成本:每次 Rust→Python→Rust 往返都有固定开销,且 Python 段本身是解释执行。8 个字段各一次回调,意味着 8 次往返 + 8 段 Python 执行——本地代码的优势按"被 Python 回调打断的比例"递减。内置约束(如 Annotated[int, Field(gt=0)])则完全在 Rust 里执行,不跨边界。结论方向:能用内置约束表达的,别写 Python 校验器。