09 中间件 —— 在请求"进门"和"出门"时各做一件事
前置阅读: 04-路由、08-异常处理
关键词: middleware, async def, def, request body
难度: ★★★☆☆
场景导入:每个请求都要计时、都要贴 ID——写在哪?
图书管理 API 接入网关后,有两个需求浮出水面:一是统计每个请求的处理耗时,方便做性能监控;二是在请求进门的瞬间生成一个唯一 ID,贴到请求对象和响应头里,方便日志串联。如果把这些逻辑写进每个 handler,代码量会线性膨胀;放到某个底层 SDK 里又侵入了业务。
中间件是这两个需求最自然的承载层。它坐在 ASGI 调用栈的最外层,能拦截所有进出请求——包括那些路由都没匹配上的 404——却不侵入任何业务代码。FastAPI 借 Starlette 提供了 @app.middleware("http") 装饰器,async def 和 def 都可以被装饰为中间件,分别对应"需要 await"和"纯计算"两种场景。
原理解析:中间件是 ASGI 栈的洋葱皮
中间件在请求处理链中的位置可以用一句话概括:请求先穿过所有中间件,再进入路由;响应按相反顺序穿回。每一层中间件只看到它"下面"的内容——最外层的中间件看到完整的请求和响应,最内层的只看到路由处理后的结果。
(图注:请求自上而下穿过中间件链,响应自下而上返回。async def 和 def 在同一链路内按注册顺序协同。)
中间件的注册顺序就是执行顺序——先注册的离客户端最近,最先看到请求、最后看到响应。这个性质有一个重要推论:耗时统计中间件应该最先注册,这样它覆盖的计时范围就包含了所有内层中间件和业务逻辑。
@app.middleware("http") 同时支持 async def 和 def 两种形态,差别在于调度路径:
两种形态在底层都被包装为 BaseHTTPMiddleware 的子类,进入同一条 ASGI 管线——只是调度方式不同。
一个必须了解的行为是:await request.body() 会消耗请求体。但 Starlette 的 Request 内部把 body 缓存为可重复读取的流——中间件读过一次之后,路由 handler 再读仍然能拿到相同字节。前提是你用的是 await request.body()(完整读取),而不是 await request.stream()(流式部分读取)。后者如果只消费了部分字节又不回退指针,下游就只能拿到截断的 body。
下面的时序图展示了 async def 和 def 两种中间件在同一请求中的协作方式:
(图注:async def 中间件在事件循环里串联,def 中间件由线程池执行;call_next 的调用顺序即为洋葱皮从外到内的穿透顺序。)
代码实现:计时 + 请求 ID,两种形态各取所需
中间件函数集中在 app/middleware.py,主应用 main.py 负责注册。log_request_async 需要读 body 取长度和计时,用 async def;add_request_id 只是生成一个字符串贴到 request 和 response 上,用 def 最干净。
# app/middleware.py
import time
from fastapi import Request
async def log_request_async(request: Request, call_next):
"""异步中间件:读取请求体并统计耗时,写入 X-Duration-ms 响应头。
await request.body() 会消耗请求体;Starlette 内部做了缓存,
路由 handler 再读仍能拿到相同字节——前提是这里完整读取。
"""
start = time.perf_counter()
body = await request.body()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
response.headers["X-Duration-ms"] = f"{duration_ms:.2f}"
response.headers["X-Body-Length"] = str(len(body))
return response
def add_request_id(request: Request, call_next):
"""纯计算中间件:生成请求 ID 并写入 request.state 与响应头。
函数体内没有 await,适合用 def;Starlette 会把这次调用投递到
默认线程池,事件循环不会因它而阻塞。
"""
request_id = f"req-{int(time.time() * 1000)}"
request.state.request_id = request_id
response = call_next(request)
response.headers["X-Request-ID"] = request_id
return response
然后是在 main.py 中按顺序注册:
# app/main.py(节选)
from fastapi import FastAPI
from app.api.books import router as books_router
from app.middleware import add_request_id, log_request_async
app = FastAPI(title="图书管理 API", version="0.3.0")
# 先注册 = 离客户端更近 = 先看到请求、后看到响应
app.middleware("http")(log_request_async)
app.middleware("http")(add_request_id)
app.include_router(books_router)
log_request_async 先注册,所以它的耗时统计覆盖了 add_request_id 的执行时间。这正是你想要的——外层计时包含所有内层开销,给出的数字才是接口的真实端到端延迟。
几处设计细节:
request.state是 Starlette 提供的请求级"便签",在一个请求的生命周期内可读可写,不会跨请求污染。中间件写入的request_id,依赖注入和 handler 都可以通过request.state.request_id读取。time.perf_counter()是单调时钟,不受系统时间调整影响,比time.time()更适合计时。f"{duration_ms:.2f}"把浮点毫秒格式化为两位小数,避免响应头里出现0.0123456789这种无意义的精度。
避坑指南
await request.body()适合 JSON/表单接口,不适合大文件上传。body 会一次性读入内存,上传 100MB 的文件就意味着中间件层先吃掉 100MB 内存。大文件上传场景应改用request.stream()逐块读取,或把计时逻辑拆分到只读元数据(headers、路径)。def中间件在异步栈下走线程池执行,不要在def里调同步阻塞库。虽然它在线程池里跑,但如果内部有个耗时 5 秒的requests.get(),它会占据线程池一个槽位 5 秒。线程池默认只有 40 个槽位——耗尽后所有defhandler 和def中间件都会排队。中间件数量要克制。每多一层中间件,每个请求就多一层
call_next的调用栈开销。横切逻辑应该在中间件层做最小可用集合,业务规则(鉴权、限流、分页)更适合放在依赖注入层。
面试 QA
Q1 [原理]: @app.middleware("http") 装饰 async def 和 def 有什么差别?纯计算用哪种?
两者都被包装为 BaseHTTPMiddleware 子类进入同一条 ASGI 管线,差别只在于:async def 中间件在事件循环里 await call_next(request),适合链路中有异步 I/O(读 body、调 AsyncSession);def 中间件由 Starlette 投递到默认线程池,适合纯计算(生成请求 ID、做哈希签名)。
纯计算用 def 写更直接,而且不占用事件循环的时间片。但如果中间件内部需要 await 任何东西——比如读 request.body()——就必须用 async def。
落到图书 API 中,log_request_async 需要 await request.body() 来统计 body 长度,所以用 async def;add_request_id 只是生成一个 req-<timestamp> 字符串,不涉及任何 I/O,用 def。
Q2 [源码]: @app.middleware("http") 与 BaseHTTPMiddleware 是同一条管线吗?执行顺序由什么决定?
是同一条管线。@app.middleware("http") 内部把传入的函数包装为 BaseHTTPMiddleware 的动态子类,加入 user_middleware 列表。应用启动时 FastAPI.build_middleware_stack 按列表顺序拼装为 ASGI 链。add_middleware(BaseHTTPMiddlewareSubclass) 也把中间件放入同一个列表,两者最终合并。
执行顺序由注册顺序决定——先注册者离客户端最近。这个性质决定了耗时统计中间件应最先注册(覆盖范围最广),业务鉴权中间件应靠后注册(只拦截需要鉴权的路径范围)。跨中间件传递数据(如 request_id)必须通过 request.scope 或 request.state,不能用模块级全局变量——后者在并发请求间会被覆盖。
小结
本篇用 @app.middleware("http") 的两种形态实现了耗时统计和请求 ID 注入——一个用 async def 读取 body 并计时,一个用 def 在请求进门的瞬间贴上唯一标识。关键结论:async def 适合需要 await 的链路,def 适合纯计算,两者在同一管线内按注册顺序协同,互不阻塞。
下一篇《10 依赖注入》将把横切关注点从"请求级"进一步下沉到"函数参数级"——用 Depends 和 yield 依赖管理资源生命周期,让 handler 只声明"我需要什么",由框架按依赖图逐层解析。