Chapter 02
分层架构与请求生命周期
上一章把 FastAPI 的部件逐个命名了——path operation、类型注解即规约、Pydantic 模型、response_model、Depends、三层封装——这一章把它们装回机器里,看一个请求从网线到 JSON 响应实际穿过哪几层。
本章你将建立的 schema
- ASGI 三件套契约:
scope/receive/send,以及为什么是消息传递而非返回值 - Starlette 的职责边界:路由、Request/Response、中间件、线程池都归它,唯独不含服务器
- FastAPI 加的那一层:每个 path operation 外面的校验·DI·序列化·OpenAPI 封装
- 请求生命周期 7 阶段:从 socket 字节到 JSON,Pydantic 校验落在你的函数之前
一个 FastAPI 应用在内存里不是单一的整体,而是三段代码叠在一起:最底下是 Uvicorn(一个 ASGI 服务器,负责和操作系统的 socket 打交道),中间是 Starlette(路由与请求对象的所有者),最上面是 FastAPI(在每个 path operation 外面加校验和文档的那一层)。三者靠一份叫 ASGI 的协议(asynchronous server gateway interface,异步服务器网关接口)咬合在一起。看懂这三层的分工,才能解释 01 章那句"其余一切都是架在 ASGI 协议之上的一层薄封装"到底薄在哪、厚在哪。
2.1ASGI 协议——底下的契约
ASGI 把一个 web 应用定义成一个 async 可调用对象 async def app(scope, receive, send),三个参数就是它和服务器之间的全部约定。
服务器(Uvicorn)和框架(Starlette/FastAPI)由不同的人写。两者要协作,就需要一份双方都遵守的接口规约:服务器该怎么把一个请求交给框架、框架该怎么把响应交回去。ASGI 就是这份规约。没有它,每个框架都得绑死一个特定服务器。
三个参数各司其职:scope 是这条连接的元数据 dict——type("http" 还是 "websocket")、method、path、headers 都在里面,它在连接建立时就备好、整条连接期间不变。receive 是一个 async 可调用对象,await 它会拉取一个客户端事件 dict(例如请求体分块 {"type": "http.request", "body": b"...", "more_body": False})。send 也是一个 async 可调用对象,把事件 dict 推送回客户端——先发一个 http.response.start(带状态码和响应头),再发一个或多个 http.response.body。
关键不在三个名字,而在交互形态是消息传递,不是返回值。应用不 return 一个响应;它通过 await send(...) 把响应一块一块推出去。
# 一个最小的 ASGI 应用:不依赖任何框架,纯协议
# 这正是 Uvicorn 期待拿到的可调用对象形状
async def app(scope, receive, send):
assert scope["type"] == "http" # scope=连接元数据 dict
event = await receive() # 拉取一个客户端事件
# event 形如:{"type": "http.request", "body": b"", "more_body": False}
await send({ # 推送响应开始:状态 + 头
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/plain")],
})
await send({ # 推送响应体(可调用多次)
"type": "http.response.body",
"body": b"hello from raw ASGI",
})
为什么不沿用 WSGI
Python 上一代的同步协议 WSGI(web server gateway interface)把应用定义成 app(environ, start_response) -> iterable:一次同步函数调用,environ 进、一个可迭代的字节响应出。一进一出、调用结束即终结。这个形状无法建模一条连接上多次双向收发事件——WebSocket 的每一帧、SSE(server-sent events,服务器推送事件)的每一条消息,都是连接存活期间陆续到来的;也无法在等待 I/O 时把控制权await 让出去给别的连接。
ASGI 用"receive 可以 await 任意多次、send 可以调用任意多次"换来了这两种能力。send 能被反复调用,正是流式输出和 WebSocket 成立的根基——这一点对要把 LLM token 一段段吐给前端的后端尤其重要(03 章会展开 async 并发的代价)。
| 方案 | 形态 / 优势 | 为什么没选 |
|---|---|---|
| WSGI 同步协议 | app(environ, start_response) 一进一出;生态成熟、实现简单 |
单次同步调用建模不了一条连接上的多次收发,无 WebSocket / 原生流式;阻塞式,无让出点 |
| 框架自带专用服务器 | 框架和服务器同源,能用任意私有接口、零适配 | 框架与服务器绑死,换服务器即换框架;每个框架重复造服务器轮子 |
| ASGI 异步消息协议 | 统一 scope/receive/send 契约;同一个 app 可换任意 ASGI 服务器;原生支持流式 / WebSocket / 让出 |
选中 |
整套接口是 async 的,应用代码必须活在事件循环里。这就为 03 章那个反直觉结论埋了雷:在 async def 里写一个不让出的阻塞调用(同步 DB 驱动、time.sleep),会卡住整个单线程事件循环,所有其它连接一起停摆。同步世界里"一个请求慢只拖累它自己"的直觉,在这里失效。
上面那个裸 ASGI 应用,把代码里第二次 await send({"type": "http.response.body", ...}) 复制成连续三次、每次带不同的 body 字节、且前两次加 "more_body": True,客户端会收到什么?换成 WSGI,能做到等价的事吗?
展开答案(先停 10 秒再点)
客户端会收到一个响应、其 body 是三段字节依次拼接——这就是分块流式输出。因为 send 可以被调用多次,服务器把每次的 body 顺序写进同一条 HTTP 响应。
WSGI 也能返回一个 generator 逐块 yield 字节,看似类似;但 WSGI 调用是同步的,逐块之间无法 await 让出,一条慢连接会占住一个 worker 线程/进程不放。ASGI 的多次 send 之间可以穿插 await,让事件循环在等待下一块数据时去服务别的连接——这才是它为流式/WebSocket 而生的本质区别。
2.2Starlette——那个 ASGI 应用本体
Starlette 就是图 2.1 里的那个 async def app(scope, receive, send);它拥有路由、Request/Response、中间件、WebSocket、线程池,唯独不自带服务器。
裸 ASGI 应用(上一节那段)能跑,但要手写:从 scope["path"] 解析 URL 再 if/else 分发、自己拼 http.response.start 的状态码和头、自己循环 receive 把请求体分块拼起来。Starlette 把这些重复劳动收成对象:Request 包住 scope/receive、Response 包住 send、Router 负责 path→endpoint 匹配。
具体清单(这些全是 Starlette 的,不是 FastAPI 的,01 章三层封装那节已点过名):
- 路由表:把
scope["method"]+scope["path"]匹配到一个 endpoint。01 章说的"装饰器在导入时把函数注册进路由表",那张表就在这里。 Request/Response:把原始scope/receive/send包成可读对象(request.headers、request.query_params、await request.body())。- 中间件栈:在 endpoint 外层裹一圈的 ASGI 应用洋葱(CORS、GZip、会话)。
- WebSocket、BackgroundTasks、
lifespan(启停钩子的上下文管理器)、以及run_in_threadpool——把同步def函数丢进工作线程的那个调度原语(03 章并发模型的主角)。
它不自带服务器:Starlette 是被调用的那个 app,需要你另配一个 ASGI 服务器(默认 Uvicorn)去真正监听端口、读写 socket、调用它。
| 方案 | 优势 | 为什么没选(作为通用底座) |
|---|---|---|
| 每个应用自己手写在裸 ASGI 上 | 零依赖、完全可控 | 路由 / 请求解析 / 中间件每个项目重写一遍,且容易出错 |
| 把服务器也打包进来(单体框架) | 开箱即用、无需配 Uvicorn | 框架与服务器耦合,部署形态被锁死,违背 ASGI 解耦的初衷 |
| Starlette:只做 ASGI 应用层 | 路由 / Request / 中间件 / WebSocket 一套俱全,服务器可任意替换 | 选中 |
Starlette 刻意不校验、不序列化、不生成文档——它只把 scope 包成 Request 就交给你的 endpoint。要类型校验、自动 JSON、OpenAPI,必须自己写,或者上一层封装来补。这正是 FastAPI 存在的理由,也是下一节的主题。
如果只用裸 Starlette 写一个 POST /items,请求体是一段 JSON,你要拿到其中的字段,需要亲手做哪几步?哪一步会决定"字段缺失时返回 422 还是 500"?
展开答案(先停 10 秒再点)
至少三步:① body = await request.body() 取原始字节;② data = json.loads(body) 解析成 dict(这步若 JSON 非法会抛异常,得自己 try/except 决定返回什么状态码);③ 手动 data["name"] 取字段并逐个校验类型/必填。
"422 还是 500"完全由你这层手写代码决定——Starlette 不替你判断。FastAPI 把②③这套用 Pydantic 自动化了,并约定校验失败统一返回 422(详见 2.4 阶段④)。
2.3FastAPI 加了什么
FastAPI 继承 Starlette,在每个路由处理函数外面再包一层:签名自省 → Pydantic 校验 → 序列化 → Depends 解析 → 自动 OpenAPI。
2.2 末尾那段手写苦力——取请求体、解析、逐字段校验、拼错误响应、再单独维护一份接口文档——在每个 endpoint 上重复,且代码与文档极易脱节。FastAPI 用 01 章那条"类型注解即规约"把它一次性消掉:读一遍函数签名,校验、序列化、文档全自动派生。
机制上,app = FastAPI() 创建的对象是 Starlette 应用的子类。路由匹配、Request 对象、中间件栈、run_in_threadpool 线程池调度——全是继承来的,FastAPI 一行没改。它新增的只有每个 path operation 外面那层 per-endpoint 封装:用 inspect.signature() 读注解 → 按 path/query/body 规则定位参数来源 → 喂给 Pydantic 编译好的校验器 → 跑你的函数 → 用 response_model 的序列化器把返回值转成 JSON → 顺带把这套类型信息汇成 OpenAPI schema。这层封装薄,但承担了 01 章整章的编程模型。
| 方案 | 校验 / 序列化 / 文档怎么来 | 代价 / 何时仍然合适 |
|---|---|---|
| 裸 Starlette | 全手写:await request.body() → json.loads → 逐字段校验 → 手拼 422 → 另写 OpenAPI 文件 |
样板代码多、文档与代码易脱节;只在"纯转发、不要校验和文档"时划算(见 04 章判别题) |
| Flask(WSGI) | request.args.get() 手取参数;校验靠 marshmallow 等第三方;文档靠插件,与代码两套 schema |
同步生态成熟、模板渲染强;但无原生 async/流式,校验与文档非内建、需自己拼装 |
| FastAPI | 一份类型注解自动派生校验 + 序列化 + OpenAPI;失败自动 422 | 强耦合于类型注解,复杂动态参数要用 Annotated 绕;换来零样板、文档永不脱节 |
这层封装把"程序的真相"绑死在类型注解上。注解写得动态或不精确(例如收一个结构不定的 dict),校验和文档就同步退化——框架能给你的,恰好以你愿意写多准的类型为上限。便利和约束是同一枚硬币的两面。
既然路由、Request、中间件都是从 Starlette 继承来的,那么一个用裸 Starlette 写好的中间件,能不能原样塞进 FastAPI 应用?为什么?
展开答案(先停 10 秒再点)
能。FastAPI 是 Starlette 的子类,app.add_middleware(...)、中间件协议、Request/Response 对象全部继承自 Starlette,接口完全一致。FastAPI 没有重写这部分。
这正印证了图 2.1 的断言:中间件这类机制活在 Starlette 层。FastAPI 的新增物只有每个 endpoint 外面那层校验·DI·序列化·OpenAPI,其余都是继承。
2.4请求生命周期 7 阶段
一个请求从 socket 字节到 JSON 响应,按固定顺序穿过 7 个阶段;其中 Pydantic 校验(阶段④)发生在你的函数(阶段⑤)之前。
前三节是静态分层,这一节是动态流程:同一个请求在这三层里依次经过哪些处理。这是本章的核心,也是 01 章"类型注解即规约"真正发力的地方——同一份注解会在这条链上被读到两次。
逐阶段:每一阶段在哪一层、做了什么
- ① Uvicorn 解析 socket 字节(IMPL 层):从 TCP 连接读出原始 HTTP 字节,解析成
scopedict,备好receive/send,然后await调用上层那个 ASGI app(即 Starlette/FastAPI)。 - ② Starlette 路由匹配(MID 层):拿
scope["method"]+scope["path"]在路由表里查,命中 FastAPI 为这个 path operation 注册的处理器。没命中→404。 - ③ 依赖图解析(CORE 层):解析这个 endpoint 的
Depends图——子依赖深度优先(先子后父),按需缓存,yield型依赖的 setup 段推入一个AsyncExitStack待命(解析顺序与缓存的代价详见 03 章 DI 节)。 - ④ 参数解析 + Pydantic 校验(CORE 层,本阶段是关键):按 01 章的来源规则从 path/query/headers/body 取值,喂给类定义时就编译好的(Rust)校验器。任一字段不合规→框架在这里构造 422 响应直接返回,你的函数根本不会被调用。
- ⑤ 处理函数执行(APP 层):到这一步入参已是合法、类型正确的 Python 对象。
async def路由直接在事件循环上跑;普通def路由被await run_in_threadpool(fn)丢进线程池(03 章并发模型展开)。 - ⑥
response_model序列化(CORE 层):返回值过一遍response_model的(Rust)序列化器——这就是 01 章说的"返回值被再次跑过模型",能借此过滤掉模型没声明的字段、转成 JSON 可写的形态。 - ⑦ 经
send发出响应(一路下沉回 IMPL 层):序列化后的 JSON 通过send推出——先http.response.start(状态+头),再http.response.body。这正是 2.1 那个裸协议动作。 - 事后:响应发出之后,
AsyncExitStack按 LIFO(后进先出)展开,跑各yield依赖 yield 之后的清理段;BackgroundTasks 也在此时执行。
把 01 章的 item: Item 放进这条生命周期走一遍
01 章那个 def create_item(item: Item)——参数注解是 Pydantic 模型 Item,按规则被当成请求体。现在跟着它穿过上面的 7 阶段,看同一份注解如何在两个不同阶段各被用一次:
| 阶段 | item: Item 此刻是什么 | 注解被用来做什么 |
|---|---|---|
| ① 入站字节 | 请求体还是一段 JSON 字节 b'{"name":"pen","price":3}' | 尚未触及注解 |
| ④ 校验 | 字节先解析成 dict,再被 Item 的校验器构造成 Item 实例 | 注解第一次登场:作为校验规约,缺字段/类型错→422 |
| ⑤ 你的函数 | item 是一个合法的 Item 对象,可直接 item.price | 注解此刻只是普通类型,函数体正常用 |
| ⑥ 序列化 | 返回值依据 response_model(常仍是 Item 或其裁剪版)转回 JSON | 注解第二次登场:作为序列化规约,过滤未声明字段 |
一份 item: Item,进来时当校验器(阶段④)、出去时当序列化器(阶段⑥),中间在你函数里只是个普通对象。这就是 01 章 threshold 那句"你写的类型签名就是规约,没有第二份 schema"在运行时的具体兑现——没有"输入 schema"和"输出 schema"两份东西在同步,是同一份注解在生命周期的两端各被读了一次。
客户端 POST 一个缺了必填 price 字段的 JSON。它会走到上面 7 个阶段里的第几阶段为止?你的 create_item 函数体里的任何一行代码(比如一句日志打印)会被执行吗?
展开答案(先停 10 秒再点)
走到阶段④(Pydantic 校验)就终止。校验器发现 price 缺失,框架直接构造一个 422 响应交给阶段⑦发出,跳过阶段⑤和⑥。
你函数体里的任何代码——包括第一行的日志——都不会执行,因为函数压根没被调用。这正是图 2.2 朱红时序线的实际后果:校验是函数执行的前置闸门。
§本章 self-check
先合上教程,把你能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。
- ASGI 把一个应用定义成什么形状的对象?三个参数
scope/receive/send各自负责什么? - 路由表、
Request对象、中间件、线程池调度,这四样归 Starlette 还是 FastAPI?FastAPI 真正新增的是哪一层? - (跨机制综合)为什么一个 422 错误能在你的函数执行之前就返回?把它落到 7 阶段里的具体阶段编号来回答。
- (设计题)一份
item: Item注解在请求生命周期里被用到两次,分别在哪两个阶段、各扮演什么角色?这对应了 01 章哪一句本质?
答案(先做完再展开)
- 定义成一个 async 可调用对象
async def app(scope, receive, send)。scope=连接元数据 dict(type/method/path/headers,连接期不变);receive=await 它拉取入站事件 dict;send=推送出站事件 dict,可多次调用(流式/WebSocket 的根基)。 - 全归 Starlette——FastAPI 继承它,这部分一行没改。FastAPI 新增的只有每个 path operation 外面那层 per-endpoint 封装:签名自省 + Pydantic 校验 + 序列化 +
Depends+ 自动 OpenAPI。 - 因为校验是阶段④,处理函数执行是阶段⑤,④在⑤之前。参数解析后喂给 Pydantic 校验器,任一字段不合规,框架在阶段④直接构造 422 经阶段⑦返回,跳过⑤⑥,函数从不被调用。
- 阶段④(校验,作为输入规约:缺字段/类型错→422)和阶段⑥(序列化,作为输出规约:过滤未声明字段转 JSON)。对应 01 章"你写的类型签名就是规约,没有第二份 schema"——同一份注解在生命周期两端各被读一次,而非两份 schema 在同步。
一个 yield 依赖里开的数据库连接,在哪个阶段关闭?
给 create_item 加一个 db = Depends(get_db),其中 get_db 是 yield 型依赖(yield 前开连接、yield 后关连接)。这个连接在 7 阶段的哪一步被关闭?如果函数在阶段⑤抛了异常,连接还会被关吗?再想:把响应体写成流式(边算边 send),清理时机会不会被这个"流"推后?
提示(卡住再展开)
看图 2.2 最下面那个虚线框的位置——清理跑在响应发出之后,由 AsyncExitStack 按 LIFO 展开。异常情况下上下文管理器的 __aexit__ 仍会被调用,所以连接照样关闭(这正是 yield 依赖被当上下文管理器用的好处)。流式响应会把"响应发出完成"这个点推后到最后一块 send 之后——清理也随之推后,03 章 DI 节会精确化这个时序。