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 文件——签名本身就是规约。
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}
int;
item同一规则把它认成请求体,请求时 JSON 会被喂进 Item 的校验器。三个参数、三种来源,全部从一行签名推出,不需要任何 request.get(...) 调用。
这条推断链的全貌见下图:一个注解进去,四个产物出来。
inspect.signature() 读出后,派生出四个互相一致的产物。注意:四个产物来自同一份声明,所以它们永远不会互相脱节——这正是"没有第二份 schema"的含义。| 方案 | 优势 | 为什么没选 |
|---|---|---|
手写取值 + 手动校验(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 循环。
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}
__pydantic_*__ 属性已经存在——证明编译发生在定义时,不是首次校验时;
model_validate这是一次进入 Rust 的调用,校验器树在那边遍历数据。
运行环境:FastAPI 0.135.2 / Pydantic 2.12.4 / Python 3.13(本机实测)。
注:示例输出基于 Pydantic 2.12.4;SchemaValidator / SchemaSerializer 是 pydantic_core 暴露的类型名,跨小版本稳定。
| 方案 | 优势 | 为什么没选 |
|---|---|---|
| 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 顺序不是细节:它保证父依赖的清理代码运行时,它依赖的子资源还没被关。下图把一张三层依赖图的解析顺序与清理顺序并排画出。
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()
db 注入处理函数,finally 块成为压进 AsyncExitStack 的清理动作;
Annotated这是现代写法(FastAPI 文档默认),取代旧的 db = Depends(get_db) 默认值写法。
| 方案 | 优势 | 为什么没选 |
|---|---|---|
| 全局单例 / 模块级变量 | 写法最简单,随处可取 | 无法做每请求隔离,难测试(替换不掉),生命周期与请求脱节,并发下易串数据 |
| 装饰器手动包裹处理函数 | 显式、就近可见 | 多个依赖嵌套时装饰器叠成洋葱,顺序与清理要自己管,无自动去重 |
| 函数参数层层透传 | 完全显式、无魔法 | 深层调用链里每一层都要重复声明同一个参数,签名污染、改动放大 |
| 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 进 40 线程池。注意:把阻塞调用放在左路(async def 且不 await)会冻住整个服务;放在右路(def)只占用一个工作线程——这就是"def 更安全"的机制。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}
def + 阻塞:被 run_in_threadpool 隔离,安全;
async_blockingasync def + 同步阻塞:冻结整个循环;
async_correctasync def + await:让出点让循环继续工作。
在 async def 路由里用同步数据库驱动(如普通 psycopg2、同步 SQLAlchemy 会话),常以 MissingGreenlet 异常炸出来。根因是同步驱动期望在一个真实线程里阻塞等待,而 async def 把它放在了事件循环上、没有可阻塞的 greenlet 上下文。修法二选一:要么用异步驱动(asyncpg + SQLAlchemy 2.0 async),要么把这个端点改成普通 def,让框架替你丢进线程池。
| 方案 | 优势 | 为什么没选 |
|---|---|---|
| 每请求一线程 | 编程模型简单,阻塞代码随便写 | 上千等待连接 = 上千闲置线程,内存爆(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。
| 框架 | 协议 / 定位 | 何时选它 |
|---|---|---|
| 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 章的判别题。
何时不要用 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 当选型依据会得出错误结论。
| 维度 | 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 进来:
- 导入时(早已发生):
inspect.signature()读出这个函数的签名(3.1),把item定位成请求体、user认成依赖;同时Item类定义触发元类,SchemaValidator已编译进 Rust 并缓存(3.2);get_current_user→get_db的依赖图也已连好(3.3)。这些都不在请求路径上发生。 - 依赖解析:请求到来,框架对依赖图做 DFS——先解析子依赖
get_db(运行到它的yield,连接被压入AsyncExitStack),再用其结果解析get_current_user(3.3)。 - 请求体校验:框架把 JSON body 喂给
Item那个编译好的SchemaValidator——一次 Rust 调用(3.2);不合法就在处理函数执行之前返回 422(3.1 的推断决定了"哪些字段必填")。 - 函数执行:因为是
async def,处理函数直接跑在事件循环线程上;函数体里每个await(比如 await 数据库)都让出,循环趁机服务别的连接(3.4)。若这里误用同步阻塞调用,整个循环会冻住。 - 响应序列化:返回值过
response_model对应的SchemaSerializer——另一个 Rust 产物,方向与校验相反(3.2),多余字段被过滤掉。 - 清理:响应发出之后,
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
先合上教程,把你能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。
- FastAPI 用哪个标准库函数在导入时读出处理函数的参数注解?把
item: Item推断成请求体、把不在路径里的标量推断成查询参数,分别依据哪条规则? - Pydantic v2 的
SchemaValidator在什么时刻被编译?为什么这让单次校验的 Python 端开销对字段数不敏感?校验器和序列化器是同一个对象吗? - 一个依赖出现在三个子依赖里,默认执行几次?要让它每次都重跑该传什么参数?yield 依赖的清理代码按什么顺序、在什么时间点跑?
- 为什么把
time.sleep(10)放在普通def路由里不会冻住整个服务,放在async def路由里却会?(设计层面,不只是"因为线程池") - 跨机制综合:一次
POST /items请求里,item: Item这同一个注解在哪两个不同阶段被用到、分别用的是哪个编译产物?把这两个阶段相对于"你的函数执行"的先后位置说清楚。
答案(先做完再展开)
inspect.signature()。item: Item的注解是 Pydantic 模型 → 请求体;不在路径{}里的普通标量 → 查询参数。用Path()/Query()/Body()可显式覆盖。- 在类定义时由元类触发
GenerateSchema→ core schema → Rust 编译,缓存为__pydantic_validator__。因为字段遍历在编译好的 Rust 校验器树内部进行,Python 端每次只是一次调用,与字段数无关。校验器(SchemaValidator)和序列化器(SchemaSerializer)是两个分开的编译产物,方向相反。 - 默认
use_cache=True,一次请求内只执行一次,三处共享同一结果。要每次重跑传use_cache=False。yield 依赖的清理被压入AsyncExitStack,按 LIFO 逆序、默认在响应发出之后执行。 def路由被框架run_in_threadpool丢进 AnyIO 线程池(40 线程),阻塞只占住其中一个工作线程,事件循环照常服务其它连接。async def直接跑在单线程事件循环上,同步阻塞且无await让出点会占死这唯一的线程,所有连接一起冻住。设计层面:安全性取决于阻塞代码跑在哪个线程上,而路由的def/async def决定了这一点。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 校验器。