Chapter 04

自测与辨析

前三章建立了概念、架构和机制——这一章靠主动回忆把它们焊牢,并强迫你在多个方案间做判别。

这一章怎么用

  • 三层题库:概念层(回忆 01)、原理层(理解 02+03)、应用判别层(迁移,综合三章)。
  • 每题先合上教程、把答案写在纸上或编辑器里,写完再展开文末答案对照。
  • 提前展开 = 把这一章当再读一遍,回忆练习的效果归零。
先写后看

全部答案集中在本页最末的一个折叠块里。判别层尤其要先写出自己的选择和理由——抄一眼正确答案,得到的是"我都会"的错觉,不是迁移能力。三个真实的自测信号:能在脑子里把请求生命周期 7 阶段顺着复述出来、能说清同一个类型注解在哪两个阶段被用到、能在选型题里讲出*被放弃*方案的代价。

概念层 · 回忆 对应 01 章 · 词汇表 原理层 · 理解 对应 02 + 03 章 判别层 · 迁移 综合 01+02+03 更难 更易 认知层级
图 4.1三层题库是一道难度梯度:从回忆事实,到解释机制,再到在没见过的场景里做选择。注意:越往顶端,单纯背诵越没用——判别层考的是把三章的概念迁移到一个新决策上。

下面三节是题目,不含答案。每题的 提示 链接指向相关章节的锚点,卡住时去复习对应小节,而不是直接翻答案。全部答案在本页最末的折叠块里。

§1概念层(对应 01 章)

这一层考词汇表:每个部件叫什么、为什么存在。一题问一件事,答案应当是一两句话。

  1. path operation 这个术语,官方拆成三个名字:path operation、path operation function、path operation decorator。这三者分别指代代码里的哪个东西? 提示:参考 01 章 §c-pathop
  2. 装饰器 @app.get("/items/{id}") 在程序的哪个时刻把函数注册进路由表——导入时,还是第一个请求到达时? 提示:参考 01 章 §c-pathop
  3. 在 def read_item(item_id: int, q: str | None = None) 里,item_id 和 q 分别被框架当成哪种参数来源(路径 / 查询 / 请求体)? 提示:参考 01 章 §c-typehint
  4. 框架靠什么规则,把一个注解为 Pydantic 模型的参数(如 item: Item)判定成请求体,而不是查询参数? 提示:参考 01 章 §c-typehint
  5. response_model 为什么能阻止把 ORM 对象上的 password_hash 字段泄漏到 JSON 响应里?它做的是过滤还是鉴权? 提示:参考 01 章 §c-response-model
  6. Depends(get_db) 里存进去的是 get_db 的调用结果,还是未调用的函数本身? 提示:参考 01 章 §c-depends
  7. "FastAPI 是三层薄封装"指的是哪三层?路由表和 Request 对象属于其中哪一层? 提示:参考 01 章 §c-layers

§2原理层(对应 02 + 03 章)

这一层考机制:某件事*怎么*发生、代价是什么、何时失效。答案要落到实现层,不是复述"是什么"。

  1. ASGI 的 send 为什么能在一条连接上被调用多次,而 WSGI 的"一次同步调用、一进一出"做不到?这一点和流式输出有什么关系? 提示:参考 02 章 §a-asgi
  2. 路由表、Request/Response、中间件、线程池——这些到底是 FastAPI 自己实现的,还是从 Starlette 继承来的?FastAPI 真正新增的是哪一层? 提示:参考 02 章 §a-fastapi
  3. 一个缺字段的 JSON 请求体,为什么能在你的处理函数还没执行之前就返回 422?这发生在请求生命周期 7 阶段的第几阶段? 提示:参考 02 章 §a-lifecycle
  4. Pydantic v2 的校验器在哪个时刻被编译成 Rust 的 SchemaValidator——每次请求时,还是 class Item(BaseModel) 这行被执行(类定义)时? 提示:参考 03 章 §p-pydantic-core
  5. Pydantic v2 比 v1 快 5–50× 的根本原因是什么?为什么"每次请求只是一次 Rust 调用"是关键? 提示:参考 03 章 §p-pydantic-core
  6. 依赖默认缓存的粒度是什么——同一个可调用对象出现在 5 个子依赖里,一个请求内被调用几次?什么参数能关掉这个缓存? 提示:参考 03 章 §p-di
  7. 带 yield 的依赖,它 yield 之后的清理代码在什么时候、以什么顺序执行?为什么父依赖的清理仍能用到子依赖的资源? 提示:参考 03 章 §p-di
  8. 为什么在 def 路由里写 time.sleep(10) 不会冻住整个服务,而在 async def 路由里写同一行会?两者分别跑在哪里? 提示:参考 03 章 §p-async

§3应用判别层(综合 01 + 02 + 03)

这一层没有标准 API 可背。每题给一个场景,逼你在来自不同章节的方案之间做一次选择,并讲出被放弃方案的代价。这是这份教程里最接近真实工作的部分——先写下你的选择和完整理由,再看答案。

  1. 同步驱动 + 低 QPS。要写一个端点,内部要调用一个只有同步驱动的老数据库库,预计 QPS 不高。这个处理函数用 async def 还是 def?为什么?
    用到:03 章 §p-async(并发模型)+ 02 章 §a-lifecycle(第 ⑤ 阶段 def 走线程池)
  2. 带后台管理的内容站。团队要做一个带 Django 风格后台管理界面 + 关系型 CRUD 的内容站,没有高并发流式需求。选 FastAPI 还是 Django?为什么?FastAPI 在这个场景缺了什么开箱能力?
    用到:03 章 §p-tradeoffs(生态定位、何时不要用 FastAPI)+ 01 章 §c-layers(FastAPI 只加 per-endpoint 封装,不含 admin/ORM)
  3. LLM token 流式 + 上千并发。要服务一个 LLM,把生成的 token 边产边流式吐给前端,同时并发上千连接。选 FastAPI 还是 Flask?是哪个 ASGI 机制让"边产边流"得以实现?
    用到:02 章 §a-asgi(send 可多次调用)+ 03 章 §p-async(事件循环下的高并发)+ §p-tradeoffs(WSGI vs ASGI 分水岭)
  4. 纯转发,不要校验和文档。一个需求只是把进来的请求体原样转发到另一个服务,不需要类型校验、不需要 OpenAPI 文档。裸 Starlette 够吗?反过来问:FastAPI 多加的那层封装,要到什么时候才值回它的启动开销和耦合代价?
    用到:01 章 §c-layers + 02 章 §a-fastapi(FastAPI 加的那层到底加了什么)+ 03 章 §p-typesystem(类型即规约的代价)
  5. 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 章)

  1. path operation = 一个 (URL 路径 + HTTP 方法) 到一个函数的绑定这件事本身;path operation function = 被绑定的那个 Python 函数(如 read_item);path operation decorator = 完成绑定的装饰器(如 @app.get(...))。官方刻意不用 route / endpoint / handler 这些别名。
  2. 导入时。装饰器在模块被 import、这行代码被执行的那一刻就运行:它把函数注册进路由表,并立即用 inspect.signature() 读出函数签名。注册不是等第一个请求才发生——请求到来时路由表早已建好。
  3. item_id 是路径参数(名字出现在路径模板的 {} 里),q 是查询参数(普通标量、带默认值、名字不在路径里)。这套推断完全由参数名 + 类型注解驱动。
  4. 规则按注解类型分流:注解是 Pydantic 模型 → 请求体(body);名字出现在路径 {} 里 → 路径参数;普通标量(int/str 等)→ 查询参数。可用 Path()/Query()/Body() 显式覆盖默认推断。
  5. 因为返回值会被再跑一遍 response_model 的序列化器,序列化器只输出模型里声明过的字段,模型没声明的(如 password_hash)被静默丢弃。所以它是输出过滤,不是鉴权——它管"哪些字段能出去",不管"这个用户能不能看"。
  6. 存的是未调用的函数本身。Depends(get_db) 只是把 get_db 这个可调用对象登记下来;真正的调用由框架在请求时完成——它递归读 get_db 的签名、解析它的子依赖、再调用它,把结果注入你的参数。
  7. 三层是 FastAPI → Starlette → ASGI(FastAPI 架在 Starlette 上,Starlette 架在 ASGI 上)。路由表和 Request 对象属于 Starlette 层——它们是继承来的,不是 FastAPI 新增的。FastAPI 新增的只有每个 path operation 外面那层"读类型 → 校验 → 序列化 → 生成文档"。

原理层(对应 02 + 03 章)

  1. ASGI 把应用定义成 async def app(scope, receive, send),靠消息传递而非返回值通信:send 是个 async 可调用对象,可以被 await 调用任意多次,每次推一个事件 dict 回去。WSGI 是 app(environ, start_response) -> iterable,一次同步调用、一进一出,建模不了一条连接上的多次收发。send 能多次调用正是流式输出(SSE)和 WebSocket 的根基——可以先发响应头、再分多次发 body 块。
  2. 路由表、Request/Response、中间件栈、WebSocket、BackgroundTasks、lifespan、线程池调度全是 Starlette 的(FastAPI 直接继承 Starlette)。FastAPI 真正新增的只有每个路由处理函数外面那一层 per-endpoint 封装:签名自省、Pydantic 校验、response_model 序列化、Depends 系统、自动 OpenAPI。
  3. 因为校验发生在第 ④ 阶段(参数解析 + Pydantic 校验),而你的处理函数执行是第 ⑤ 阶段。框架先从 path/query/headers/body 取值喂给编译好的校验器,校验失败立刻返回自动生成的 422,根本不进入第 ⑤ 阶段——所以你的函数永远不会拿到一个没通过校验的请求体。
  4. 在 类定义时。class Item(BaseModel) 触发元类:GenerateSchema 遍历字段产出 "core schema",交给 Rust 的 pydantic-core 编译成 SchemaValidator + SchemaSerializer,缓存在 __pydantic_validator__ / __pydantic_serializer__ 上。编译只在定义时发生一次(代价是启动稍慢),不是每次请求重新解析。
  5. 根本原因是校验逻辑被预编译进 Rust,每次请求只是一次 Rust 调用,没有 Python 层的逐字段循环。v1 在解释执行的 Python 里逐字段校验,慢在解释器开销上。把"遍历字段、推断规则"的工作从每次请求挪到了仅一次的定义时,是 5–50× 的来源。
  6. 粒度是每个唯一可调用对象、每请求一次(use_cache=True 是默认)。同一个依赖出现在 5 个子依赖里,一个请求内也只调用一次,结果存进请求级缓存 dict 供其余位置复用。传 Depends(fn, use_cache=False) 可关掉缓存、强制每处都重跑——有副作用的依赖(生成随机 token、开新连接)就需要这样。
  7. yield 依赖被当作(async)上下文管理器压进 AsyncExitStack:yield 前是 setup,yield 出的值被注入,yield 后的清理在栈展开时按 LIFO(后进先出)逆序执行,默认在响应发出之后跑。LIFO 保证了父依赖的清理早于子依赖被建立、晚于子依赖被清理——所以父的清理代码运行时,它依赖的子资源还没拆,仍然可用。
  8. def 路由被框架用 await run_in_threadpool(fn) 丢进 AnyIO 工作线程池(默认 40 线程),time.sleep(10) 只阻塞那一个线程,事件循环照常服务别的连接。async def 路由直接跑在单线程事件循环上,time.sleep(10) 是同步阻塞且没有 await 让出点,会卡住整个循环 10 秒——所有其它连接一起停摆。反直觉结论:同步阻塞代码放 def 里更安全。

应用判别层(综合 01 + 02 + 03)

  1. 选 def。同步驱动是阻塞调用;写进 def 路由后,框架自动 run_in_threadpool 把它丢到线程池(第 ⑤ 阶段),阻塞只发生在工作线程里,事件循环不受影响。若写成 async def 又在里面跑同步驱动,会阻塞循环、冻住整个服务(且同步 DB 驱动配 async def 常直接报 MissingGreenlet)。QPS 不高意味着不会打满 40 线程的线程池上限,def 完全够用。放弃 async def 的代价:失去单连接内的 await 让出能力,但这个场景用不上。
  2. 选 Django。需求的核心是后台管理 + 关系型 CRUD,Django 的 admin 后台和 ORM 开箱即用,能省下大量样板。FastAPI 缺这两样:它只在 Starlette 之上加了 per-endpoint 的校验/序列化/文档封装,不自带 admin、不自带 ORM,这些都要自己拼(SQLAlchemy + 第三方 admin)。没有高并发流式需求时,FastAPI 在 I/O 并发上的优势用不到,强行选它等于放弃 Django 的开箱能力去换一个不需要的特性。放弃 FastAPI 的代价:失去自动 OpenAPI 和类型校验,但内容站对此需求弱。
  3. 选 FastAPI。关键机制是 ASGI 的 send 可被多次调用:配合 StreamingResponse / SSE,可以每生成一个 token 就 send 一块出去,而不必等全部生成完。Flask 是 WSGI,一次同步调用、一进一出,无法在一条连接上分多次推送,天然做不到边产边流。上千并发则靠事件循环:流式端点大部分时间在 await 等待模型产出下一个 token,每个 await 都让出循环去服务别的连接,单线程就能扛住大量并发等待。放弃 Flask 的代价:成熟的同步生态,但它在这个场景的两个硬需求(流式 + 高并发 I/O)上都不行。
  4. 纯转发:裸 Starlette 就够。路由、Request/Response、流式都在 Starlette 层,转发不需要校验和文档,FastAPI 多加的那层(签名自省、Pydantic 校验、序列化、OpenAPI)在这里没有产物可生成,只剩定义时的编译开销和对类型注解的耦合。FastAPI 的封装要到你需要"类型即规约"的产出时才值回票价:请求体要按 schema 校验、响应要按 response_model 过滤、要自动 OpenAPI 文档、要 Depends 依赖注入——有这些需求时,那层封装替你省掉的手写胶水(手动 request.args.get() + 手写校验 + 手写文档)远超它的代价;纯转发没有这些需求,所以裸 Starlette 更合适。
  5. 不能靠 async def 扛。事件循环只在任务等待 I/O(有 await 让出点)时才能切去服务别的连接;纯 CPU 计算没有 I/O 等待、不会让出,写成 async def 会让这几百毫秒独占单线程事件循环,期间所有连接停摆——比写成 def 更糟(def 至少被丢到线程池)。但即便丢线程池,CPU 密集仍受 GIL 限制,多线程并不能真正并行计算。正确方向是把计算移出请求路径:进程池(ProcessPoolExecutor)或任务队列(Celery / RQ)——用多进程绕开 GIL,端点只负责派发任务、立即返回。