Chapter 04
自测与辨析
前三章建立了概念、架构和机制——这一章靠主动回忆把它们焊牢,并强迫你在多个方案间做判别。
这一章怎么用
- 三层题库:概念层(回忆 01)、原理层(理解 02+03)、应用判别层(迁移,综合三章)。
- 每题先合上教程、把答案写在纸上或编辑器里,写完再展开文末答案对照。
- 提前展开 = 把这一章当再读一遍,回忆练习的效果归零。
先写后看
全部答案集中在本页最末的一个折叠块里。判别层尤其要先写出自己的选择和理由——抄一眼正确答案,得到的是"我都会"的错觉,不是迁移能力。三个真实的自测信号:能在脑子里把请求生命周期 7 阶段顺着复述出来、能说清同一个类型注解在哪两个阶段被用到、能在选型题里讲出*被放弃*方案的代价。
下面三节是题目,不含答案。每题的 提示 链接指向相关章节的锚点,卡住时去复习对应小节,而不是直接翻答案。全部答案在本页最末的折叠块里。
§1概念层(对应 01 章)
这一层考词汇表:每个部件叫什么、为什么存在。一题问一件事,答案应当是一两句话。
- path operation 这个术语,官方拆成三个名字:path operation、path operation function、path operation decorator。这三者分别指代代码里的哪个东西? 提示:参考 01 章 §c-pathop
- 装饰器
@app.get("/items/{id}")在程序的哪个时刻把函数注册进路由表——导入时,还是第一个请求到达时? 提示:参考 01 章 §c-pathop - 在
def read_item(item_id: int, q: str | None = None)里,item_id和q分别被框架当成哪种参数来源(路径 / 查询 / 请求体)? 提示:参考 01 章 §c-typehint - 框架靠什么规则,把一个注解为 Pydantic 模型的参数(如
item: Item)判定成请求体,而不是查询参数? 提示:参考 01 章 §c-typehint response_model为什么能阻止把 ORM 对象上的password_hash字段泄漏到 JSON 响应里?它做的是过滤还是鉴权? 提示:参考 01 章 §c-response-modelDepends(get_db)里存进去的是get_db的调用结果,还是未调用的函数本身? 提示:参考 01 章 §c-depends- "FastAPI 是三层薄封装"指的是哪三层?路由表和
Request对象属于其中哪一层? 提示:参考 01 章 §c-layers
§2原理层(对应 02 + 03 章)
这一层考机制:某件事*怎么*发生、代价是什么、何时失效。答案要落到实现层,不是复述"是什么"。
- ASGI 的
send为什么能在一条连接上被调用多次,而 WSGI 的"一次同步调用、一进一出"做不到?这一点和流式输出有什么关系? 提示:参考 02 章 §a-asgi - 路由表、
Request/Response、中间件、线程池——这些到底是 FastAPI 自己实现的,还是从 Starlette 继承来的?FastAPI 真正新增的是哪一层? 提示:参考 02 章 §a-fastapi - 一个缺字段的 JSON 请求体,为什么能在你的处理函数还没执行之前就返回 422?这发生在请求生命周期 7 阶段的第几阶段? 提示:参考 02 章 §a-lifecycle
- Pydantic v2 的校验器在哪个时刻被编译成 Rust 的
SchemaValidator——每次请求时,还是class Item(BaseModel)这行被执行(类定义)时? 提示:参考 03 章 §p-pydantic-core - Pydantic v2 比 v1 快 5–50× 的根本原因是什么?为什么"每次请求只是一次 Rust 调用"是关键? 提示:参考 03 章 §p-pydantic-core
- 依赖默认缓存的粒度是什么——同一个可调用对象出现在 5 个子依赖里,一个请求内被调用几次?什么参数能关掉这个缓存? 提示:参考 03 章 §p-di
- 带
yield的依赖,它yield之后的清理代码在什么时候、以什么顺序执行?为什么父依赖的清理仍能用到子依赖的资源? 提示:参考 03 章 §p-di - 为什么在
def路由里写time.sleep(10)不会冻住整个服务,而在async def路由里写同一行会?两者分别跑在哪里? 提示:参考 03 章 §p-async
§3应用判别层(综合 01 + 02 + 03)
这一层没有标准 API 可背。每题给一个场景,逼你在来自不同章节的方案之间做一次选择,并讲出被放弃方案的代价。这是这份教程里最接近真实工作的部分——先写下你的选择和完整理由,再看答案。
- 同步驱动 + 低 QPS。要写一个端点,内部要调用一个只有同步驱动的老数据库库,预计 QPS 不高。这个处理函数用
async def还是def?为什么?
用到:03 章 §p-async(并发模型)+ 02 章 §a-lifecycle(第 ⑤ 阶段 def 走线程池) - 带后台管理的内容站。团队要做一个带 Django 风格后台管理界面 + 关系型 CRUD 的内容站,没有高并发流式需求。选 FastAPI 还是 Django?为什么?FastAPI 在这个场景缺了什么开箱能力?
用到:03 章 §p-tradeoffs(生态定位、何时不要用 FastAPI)+ 01 章 §c-layers(FastAPI 只加 per-endpoint 封装,不含 admin/ORM) - LLM token 流式 + 上千并发。要服务一个 LLM,把生成的 token 边产边流式吐给前端,同时并发上千连接。选 FastAPI 还是 Flask?是哪个 ASGI 机制让"边产边流"得以实现?
用到:02 章 §a-asgi(send可多次调用)+ 03 章 §p-async(事件循环下的高并发)+ §p-tradeoffs(WSGI vs ASGI 分水岭) - 纯转发,不要校验和文档。一个需求只是把进来的请求体原样转发到另一个服务,不需要类型校验、不需要 OpenAPI 文档。裸 Starlette 够吗?反过来问:FastAPI 多加的那层封装,要到什么时候才值回它的启动开销和耦合代价?
用到:01 章 §c-layers + 02 章 §a-fastapi(FastAPI 加的那层到底加了什么)+ 03 章 §p-typesystem(类型即规约的代价) - CPU 密集的端点。一个端点要做大量纯 CPU 计算(图像缩放、压缩),单次耗时几百毫秒。把它写成
async def能靠事件循环扛住并发吗?正确的做法应该往哪个方向走?
用到:03 章 §p-async(async 不解决 CPU 密集、GIL)+ §p-tradeoffs(CPU 密集该用进程池/任务队列)
亲手画一张图
合上教程,在纸上画出一个 POST /items 请求从 Uvicorn 到 JSON 响应穿过的 7 个阶段——只画 7 个方框 + 箭头就行。画完回到 02 章 §a-lifecycle 对照:你有没有把 Pydantic 校验画在你的处理函数执行之前?如果你把校验画到了函数里面或之后,说明 7 阶段的顺序还没真正记牢——这正是 422 能先于你的代码返回的原因。
答案(三层全部在这里,先把你的答案写完再展开)
概念层(对应 01 章)
- path operation = 一个 (URL 路径 + HTTP 方法) 到一个函数的绑定这件事本身;path operation function = 被绑定的那个 Python 函数(如
read_item);path operation decorator = 完成绑定的装饰器(如@app.get(...))。官方刻意不用 route / endpoint / handler 这些别名。 - 导入时。装饰器在模块被 import、这行代码被执行的那一刻就运行:它把函数注册进路由表,并立即用
inspect.signature()读出函数签名。注册不是等第一个请求才发生——请求到来时路由表早已建好。 item_id是路径参数(名字出现在路径模板的{}里),q是查询参数(普通标量、带默认值、名字不在路径里)。这套推断完全由参数名 + 类型注解驱动。- 规则按注解类型分流:注解是 Pydantic 模型 → 请求体(body);名字出现在路径
{}里 → 路径参数;普通标量(int/str 等)→ 查询参数。可用Path()/Query()/Body()显式覆盖默认推断。 - 因为返回值会被再跑一遍
response_model的序列化器,序列化器只输出模型里声明过的字段,模型没声明的(如password_hash)被静默丢弃。所以它是输出过滤,不是鉴权——它管"哪些字段能出去",不管"这个用户能不能看"。 - 存的是未调用的函数本身。
Depends(get_db)只是把get_db这个可调用对象登记下来;真正的调用由框架在请求时完成——它递归读get_db的签名、解析它的子依赖、再调用它,把结果注入你的参数。 - 三层是 FastAPI → Starlette → ASGI(FastAPI 架在 Starlette 上,Starlette 架在 ASGI 上)。路由表和
Request对象属于 Starlette 层——它们是继承来的,不是 FastAPI 新增的。FastAPI 新增的只有每个 path operation 外面那层"读类型 → 校验 → 序列化 → 生成文档"。
原理层(对应 02 + 03 章)
- ASGI 把应用定义成
async def app(scope, receive, send),靠消息传递而非返回值通信:send是个 async 可调用对象,可以被await调用任意多次,每次推一个事件 dict 回去。WSGI 是app(environ, start_response) -> iterable,一次同步调用、一进一出,建模不了一条连接上的多次收发。send能多次调用正是流式输出(SSE)和 WebSocket 的根基——可以先发响应头、再分多次发 body 块。 - 路由表、
Request/Response、中间件栈、WebSocket、BackgroundTasks、lifespan、线程池调度全是 Starlette 的(FastAPI 直接继承 Starlette)。FastAPI 真正新增的只有每个路由处理函数外面那一层 per-endpoint 封装:签名自省、Pydantic 校验、response_model序列化、Depends系统、自动 OpenAPI。 - 因为校验发生在第 ④ 阶段(参数解析 + Pydantic 校验),而你的处理函数执行是第 ⑤ 阶段。框架先从 path/query/headers/body 取值喂给编译好的校验器,校验失败立刻返回自动生成的 422,根本不进入第 ⑤ 阶段——所以你的函数永远不会拿到一个没通过校验的请求体。
- 在 类定义时。
class Item(BaseModel)触发元类:GenerateSchema遍历字段产出 "core schema",交给 Rust 的pydantic-core编译成SchemaValidator+SchemaSerializer,缓存在__pydantic_validator__/__pydantic_serializer__上。编译只在定义时发生一次(代价是启动稍慢),不是每次请求重新解析。 - 根本原因是校验逻辑被预编译进 Rust,每次请求只是一次 Rust 调用,没有 Python 层的逐字段循环。v1 在解释执行的 Python 里逐字段校验,慢在解释器开销上。把"遍历字段、推断规则"的工作从每次请求挪到了仅一次的定义时,是 5–50× 的来源。
- 粒度是每个唯一可调用对象、每请求一次(
use_cache=True是默认)。同一个依赖出现在 5 个子依赖里,一个请求内也只调用一次,结果存进请求级缓存 dict 供其余位置复用。传Depends(fn, use_cache=False)可关掉缓存、强制每处都重跑——有副作用的依赖(生成随机 token、开新连接)就需要这样。 yield依赖被当作(async)上下文管理器压进AsyncExitStack:yield前是 setup,yield出的值被注入,yield后的清理在栈展开时按 LIFO(后进先出)逆序执行,默认在响应发出之后跑。LIFO 保证了父依赖的清理早于子依赖被建立、晚于子依赖被清理——所以父的清理代码运行时,它依赖的子资源还没拆,仍然可用。def路由被框架用await run_in_threadpool(fn)丢进 AnyIO 工作线程池(默认 40 线程),time.sleep(10)只阻塞那一个线程,事件循环照常服务别的连接。async def路由直接跑在单线程事件循环上,time.sleep(10)是同步阻塞且没有await让出点,会卡住整个循环 10 秒——所有其它连接一起停摆。反直觉结论:同步阻塞代码放def里更安全。
应用判别层(综合 01 + 02 + 03)
- 选
def。同步驱动是阻塞调用;写进def路由后,框架自动run_in_threadpool把它丢到线程池(第 ⑤ 阶段),阻塞只发生在工作线程里,事件循环不受影响。若写成async def又在里面跑同步驱动,会阻塞循环、冻住整个服务(且同步 DB 驱动配async def常直接报MissingGreenlet)。QPS 不高意味着不会打满 40 线程的线程池上限,def完全够用。放弃async def的代价:失去单连接内的await让出能力,但这个场景用不上。 - 选 Django。需求的核心是后台管理 + 关系型 CRUD,Django 的 admin 后台和 ORM 开箱即用,能省下大量样板。FastAPI 缺这两样:它只在 Starlette 之上加了 per-endpoint 的校验/序列化/文档封装,不自带 admin、不自带 ORM,这些都要自己拼(SQLAlchemy + 第三方 admin)。没有高并发流式需求时,FastAPI 在 I/O 并发上的优势用不到,强行选它等于放弃 Django 的开箱能力去换一个不需要的特性。放弃 FastAPI 的代价:失去自动 OpenAPI 和类型校验,但内容站对此需求弱。
- 选 FastAPI。关键机制是 ASGI 的
send可被多次调用:配合StreamingResponse/ SSE,可以每生成一个 token 就send一块出去,而不必等全部生成完。Flask 是 WSGI,一次同步调用、一进一出,无法在一条连接上分多次推送,天然做不到边产边流。上千并发则靠事件循环:流式端点大部分时间在await等待模型产出下一个 token,每个await都让出循环去服务别的连接,单线程就能扛住大量并发等待。放弃 Flask 的代价:成熟的同步生态,但它在这个场景的两个硬需求(流式 + 高并发 I/O)上都不行。 - 纯转发:裸 Starlette 就够。路由、
Request/Response、流式都在 Starlette 层,转发不需要校验和文档,FastAPI 多加的那层(签名自省、Pydantic 校验、序列化、OpenAPI)在这里没有产物可生成,只剩定义时的编译开销和对类型注解的耦合。FastAPI 的封装要到你需要"类型即规约"的产出时才值回票价:请求体要按 schema 校验、响应要按response_model过滤、要自动 OpenAPI 文档、要Depends依赖注入——有这些需求时,那层封装替你省掉的手写胶水(手动request.args.get()+ 手写校验 + 手写文档)远超它的代价;纯转发没有这些需求,所以裸 Starlette 更合适。 - 不能靠
async def扛。事件循环只在任务等待 I/O(有await让出点)时才能切去服务别的连接;纯 CPU 计算没有 I/O 等待、不会让出,写成async def会让这几百毫秒独占单线程事件循环,期间所有连接停摆——比写成def更糟(def至少被丢到线程池)。但即便丢线程池,CPU 密集仍受 GIL 限制,多线程并不能真正并行计算。正确方向是把计算移出请求路径:进程池(ProcessPoolExecutor)或任务队列(Celery / RQ)——用多进程绕开 GIL,端点只负责派发任务、立即返回。