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 协议之上的一层薄封装"到底薄在哪、厚在哪。

APP CORE MID IMPL 你的代码(path operation 函数) 业务逻辑 · 你写的部分 FastAPI 封装 每个 endpoint:校验 · DI · 序列化 · OpenAPI Starlette(ASGI 应用本体) 路由 · Request/Response · 中间件 · 线程池 ASGI 协议 ← Uvicorn 服务器
图 2.1FastAPI 只是中间那一层高亮的薄封装,它继承下面的 Starlette。注意:路由、Request/Response 对象其实在 Starlette 层,不在 FastAPI——FastAPI 唯一新增的是每个 endpoint 外面的校验·DI·序列化·OpenAPI。

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_callable.py Python
# 一个最小的 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",
    })
scope连接级、只读、一次备好; receiveawait 一次得到一个入站事件; send可被调用多次——这是流式响应的根。

为什么不沿用 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 并发的代价)。

表 2.1 · ASGI 之前,同样的"服务器↔框架解耦"可以怎么做
方案形态 / 优势为什么没选
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、调用它。

表 2.2 · 路由 + Request 这层,可以放在哪
方案优势为什么没选(作为通用底座)
每个应用自己手写在裸 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 章整章的编程模型。

表 2.3 · 写一个带校验和文档的 HTTP API:裸 Starlette vs Flask vs FastAPI
方案校验 / 序列化 / 文档怎么来代价 / 何时仍然合适
裸 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 收字节 建 scope+收发 ② Starlette 路由 method+path 匹配 ③ 依赖图解析 Depends DFS ④ Pydantic 校验 失败→422 校验通过后才进函数 ⑤ 你的函数执行 循环 / 线程池 ⑥ 序列化 response_model→JSON ⑦ send 发响应 http.response.* 事后:清理 + 后台任务 AsyncExitStack 按 LIFO 展开
图 2.2请求生命周期 7 阶段,分两行:入站①–④、出站⑤–⑦。注意:朱红那条线标出关键时序——Pydantic 校验(④)跑在你的函数(⑤)之前,所以非法输入永远到不了你的业务代码。

逐阶段:每一阶段在哪一层、做了什么

  • ① Uvicorn 解析 socket 字节(IMPL 层):从 TCP 连接读出原始 HTTP 字节,解析成 scope dict,备好 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 阶段,看同一份注解如何在两个不同阶段各被用一次:

表 2.4 · 一份 item: Item 注解在生命周期里的两次登场
阶段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

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

  1. ASGI 把一个应用定义成什么形状的对象?三个参数 scope / receive / send 各自负责什么?
  2. 路由表、Request 对象、中间件、线程池调度,这四样归 Starlette 还是 FastAPI?FastAPI 真正新增的是哪一层?
  3. (跨机制综合)为什么一个 422 错误能在你的函数执行之前就返回?把它落到 7 阶段里的具体阶段编号来回答。
  4. (设计题)一份 item: Item 注解在请求生命周期里被用到两次,分别在哪两个阶段、各扮演什么角色?这对应了 01 章哪一句本质?
答案(先做完再展开)
  1. 定义成一个 async 可调用对象 async def app(scope, receive, send)。scope=连接元数据 dict(type/method/path/headers,连接期不变);receive=await 它拉取入站事件 dict;send=推送出站事件 dict,可多次调用(流式/WebSocket 的根基)。
  2. 全归 Starlette——FastAPI 继承它,这部分一行没改。FastAPI 新增的只有每个 path operation 外面那层 per-endpoint 封装:签名自省 + Pydantic 校验 + 序列化 + Depends + 自动 OpenAPI。
  3. 因为校验是阶段④,处理函数执行是阶段⑤,④在⑤之前。参数解析后喂给 Pydantic 校验器,任一字段不合规,框架在阶段④直接构造 422 经阶段⑦返回,跳过⑤⑥,函数从不被调用。
  4. 阶段④(校验,作为输入规约:缺字段/类型错→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 节会精确化这个时序。