FastAPI系列-09-中间件

FastAPI系列-09-中间件

_

09 中间件 —— 在请求"进门"和"出门"时各做一件事

前置阅读: 04-路由08-异常处理
关键词: middleware, async def, def, request body
难度: ★★★☆☆

场景导入:每个请求都要计时、都要贴 ID——写在哪?

图书管理 API 接入网关后,有两个需求浮出水面:一是统计每个请求的处理耗时,方便做性能监控;二是在请求进门的瞬间生成一个唯一 ID,贴到请求对象和响应头里,方便日志串联。如果把这些逻辑写进每个 handler,代码量会线性膨胀;放到某个底层 SDK 里又侵入了业务。

中间件是这两个需求最自然的承载层。它坐在 ASGI 调用栈的最外层,能拦截所有进出请求——包括那些路由都没匹配上的 404——却不侵入任何业务代码。FastAPI 借 Starlette 提供了 @app.middleware("http") 装饰器,async defdef 都可以被装饰为中间件,分别对应"需要 await"和"纯计算"两种场景。

原理解析:中间件是 ASGI 栈的洋葱皮

中间件在请求处理链中的位置可以用一句话概括:请求先穿过所有中间件,再进入路由;响应按相反顺序穿回。每一层中间件只看到它"下面"的内容——最外层的中间件看到完整的请求和响应,最内层的只看到路由处理后的结果。

flowchart LR A["客户端"] --> M1["中间件 1<br/>async def(日志/计时)"] M1 --> M2["中间件 2<br/>def(请求 ID)"] M2 --> APP["FastAPI 路由层"] APP --> R["依赖注入 + handler"] R --> APP APP --> M2 M2 --> M1 M1 --> A

(图注:请求自上而下穿过中间件链,响应自下而上返回。async defdef 在同一链路内按注册顺序协同。)

中间件的注册顺序就是执行顺序——先注册的离客户端最近,最先看到请求、最后看到响应。这个性质有一个重要推论:耗时统计中间件应该最先注册,这样它覆盖的计时范围就包含了所有内层中间件和业务逻辑。

@app.middleware("http") 同时支持 async defdef 两种形态,差别在于调度路径:

形态

执行位置

适合场景

async def

事件循环内 await call_next(request)

需要读 body、调异步服务、await 数据库

def

Starlette 投递到默认线程池

纯计算:生成 ID、做签名、轻量统计

两种形态在底层都被包装为 BaseHTTPMiddleware 的子类,进入同一条 ASGI 管线——只是调度方式不同。

一个必须了解的行为是:await request.body() 会消耗请求体。但 Starlette 的 Request 内部把 body 缓存为可重复读取的流——中间件读过一次之后,路由 handler 再读仍然能拿到相同字节。前提是你用的是 await request.body()(完整读取),而不是 await request.stream()(流式部分读取)。后者如果只消费了部分字节又不回退指针,下游就只能拿到截断的 body。

下面的时序图展示了 async defdef 两种中间件在同一请求中的协作方式:

sequenceDiagram participant C as 客户端 participant L as async 中间件<br/>log_request_async participant T as def 中间件<br/>add_request_id participant A as FastAPI 路由 participant R as handler C->>L: 发送请求 L->>L: start = perf_counter()<br/>body = await request.body() L->>T: await call_next(request) T->>T: request_id = 生成 ID<br/>写入 request.state T->>A: call_next(request)(线程池) A->>R: 路由匹配 + 依赖注入 R-->>A: 生成响应 A-->>T: 响应回传 T->>T: 写入 X-Request-ID T-->>L: 响应回传 L->>L: 写入 X-Duration-ms L-->>C: 返回响应

(图注:async def 中间件在事件循环里串联,def 中间件由线程池执行;call_next 的调用顺序即为洋葱皮从外到内的穿透顺序。)

代码实现:计时 + 请求 ID,两种形态各取所需

中间件函数集中在 app/middleware.py,主应用 main.py 负责注册。log_request_async 需要读 body 取长度和计时,用 async defadd_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 个槽位——耗尽后所有 def handler 和 def 中间件都会排队。

  • 中间件数量要克制。每多一层中间件,每个请求就多一层 call_next 的调用栈开销。横切逻辑应该在中间件层做最小可用集合,业务规则(鉴权、限流、分页)更适合放在依赖注入层。

面试 QA

Q1 [原理]: @app.middleware("http") 装饰 async defdef 有什么差别?纯计算用哪种?

两者都被包装为 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 defadd_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.scoperequest.state,不能用模块级全局变量——后者在并发请求间会被覆盖。

小结

本篇用 @app.middleware("http") 的两种形态实现了耗时统计和请求 ID 注入——一个用 async def 读取 body 并计时,一个用 def 在请求进门的瞬间贴上唯一标识。关键结论:async def 适合需要 await 的链路,def 适合纯计算,两者在同一管线内按注册顺序协同,互不阻塞。

下一篇《10 依赖注入》将把横切关注点从"请求级"进一步下沉到"函数参数级"——用 Dependsyield 依赖管理资源生命周期,让 handler 只声明"我需要什么",由框架按依赖图逐层解析。

FastAPI系列-10-依赖注入 2026-06-23
FastAPI系列-08-异常处理 2026-06-21

评论区