本文目录
客户端发来一个 HTTP 请求,在到达你的 @app.get 之前,可能已经历了解析、CORS 预检、访问日志、耗时统计、注入请求 ID 等多道工序;响应返回时,这些层再以相反顺序收尾。这条管道不是 FastAPI 路由表的一部分,而是 中间件(middleware)——对「每个请求」统一生效的包装器。弄懂顺序和边界,才能避免「Header 已经发出才改状态码」之类的坑,也才能和依赖注入、异常 handler 各就各位:中间件管传输层横切,Depends 管请求级能力,handler 管错误出站格式。
ASGI 中间件在栈里的位置
FastAPI 基于 Starlette,中间件模型是 ASGI callable 的洋葱圈:外层先收到 scope, receive, send,可以选择改 scope、包一层 send、或在调用内层前后执行代码。
注册:
from fastapi import FastAPI
app = FastAPI()
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
...@app.middleware("http") 注册的是 Starlette 的 HTTP 中间件,只处理 scope["type"] == "http" 的连接。注册顺序与执行顺序的关系:后添加的中间件更靠外——最先接触请求、最后接触响应。
可以用下面这张简图建立直觉(仅 HTTP 层):
请求 → [Middleware C 进] → [Middleware B 进] → [Middleware A 进] → 路由
响应 ← [Middleware C 出] ← [Middleware B 出] ← [Middleware A 出] ← 路由若 A 是 CORS、B 是日志、C 是请求 ID,则 ID 最先被赋值,日志能读到 ID,CORS 头在最外层统一加在响应上。
add_middleware 与 @app.middleware("http") 不要混用顺序时搞糊涂:后注册的外层优先。若 CORS 用 add_middleware 而日志用装饰器,以实际注册先后为准;拿不准时在本地打 log 看「进/出」顺序。WebSocket 的 scope["type"] == "websocket" 不走 HTTP 中间件装饰器,需要 ASGI 级中间件统一处理或单独分支。
函数式 HTTP 中间件:计时与请求 ID
典型模式:call_next(request) 之前做 setup,之后读 response 再改 headers 或记日志。
import time
import uuid
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.middleware("http")
async def request_context_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4())
start = time.perf_counter()
response = await call_next(request)
elapsed_ms = (time.perf_counter() - start) * 1000
response.headers["X-Request-Id"] = request_id
response.headers["X-Process-Time-Ms"] = f"{elapsed_ms:.2f}"
return response把 request_id 放进 contextvars 后,深层 Service 和日志 formatter 都能读到,而不必每个函数传参——这是中间件与横切日志的常见配合。注意:若 call_next 内部抛未捕获异常,中间件仍可在 except 里记日志再 re-raise,或由异常 handler 生成响应;不要在中间件里吞掉所有异常,除非明确要转成 500 Response。
访问日志可记录 method、path、status、duration、user agent。排除 /health 避免刷屏:
SKIP_PATHS = {"/health", "/metrics"}
@app.middleware("http")
async def access_log_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
if request.url.path in SKIP_PATHS:
return await call_next(request)
start = time.perf_counter()
response = await call_next(request)
ms = (time.perf_counter() - start) * 1000
logger.info(
"access method=%s path=%s status=%s duration_ms=%.2f",
request.method,
request.url.path,
response.status_code,
ms,
)
return responserequest.state.request_id = request_id 是常见写法,后续依赖或 handler 从 request.state 读取,与 contextvars 二选一或同时用(state 绑定本请求对象,contextvars 绑定 async 任务)。
统一响应信封:能做,但要想清楚代价
不少项目希望成功也长成 { "code": "0", "data": ..., "message": "success" },错误同形。一种做法是中间件读完 body 再包一层(课件常见示范):call_next 之后消费 response.body_iterator,再 JSONResponse 写回。概念上它属于横切「出站格式」,与异常 handler 的入站错误形状应对齐。
但有几处逻辑要补齐,否则上线会踩坑:
- 流式 / SSE / 文件下载:body 是生成器,中间件整段缓冲会破坏流式,也可能撑爆内存——路径白名单或按
media_type跳过包装。 - 错误体二次包装:若异常 handler 已返回统一 envelope,中间件再包会变成嵌套
data.error;应约定只在一处定形(推荐 handler 管错误,中间件只包成功,或反过来)。 - OpenAPI 与类型:
response_model=ItemOut描述的是未包装的 data;文档要额外说明 envelope,或改用自定义路由返回模型。 - 读 body 一次:包装中间件已耗尽 iterator,必须构造新 Response;别再假定原 response 可重放。
更稳妥的折中:成功路径由路由/response_model 直接返回业务对象,前端接受;错误路径靠第 9 篇的全局 handler 统一。若产品强制成功也要 envelope,优先在薄包装函数或基类路由里显式构造,比「全局吞 body 的中间件」更容易对 SSE 开豁免。中间件方案能教 AOP,但不是默认最佳实践。
CORS:浏览器跨域的专用中间件
浏览器跨域时先发 OPTIONS 预检。手写 Access-Control-* 头容易漏方法和 Header 白名单。Starlette 提供 CORSMiddleware,应通过 app.add_middleware 挂载:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type", "X-Request-Id"],
max_age=600,
)生产环境避免 allow_origins=["*"] 与 allow_credentials=True 同时使用(规范不允许)。开发本地前端可用明确列表或环境变量区分。CORS 是中间件职责,不要和「业务权限」混在路由里——后者管数据能否访问,前者管浏览器是否允许读响应。
预检 OPTIONS 请求通常不会进你的路由函数,由 CORSMiddleware 直接返回 200 与允许头。若自定义中间件在 CORS 内层把 OPTIONS 拦掉,浏览器会报 mysterious CORS error——排查时先用 curl 看 API 本身是否正常,再在 DevTools Network 里看 OPTIONS 响应头。
TrustedHost 与其它内置中间件
TrustedHostMiddleware 校验 Host 头,防 DNS rebinding 类攻击,生产应配 allowed_hosts=["api.example.com"]。HTTPSRedirectMiddleware 在终止 TLS 的反向代理之后时要谨慎:代理后往往是 HTTP,需配 ProxyHeadersMiddleware 或信任 X-Forwarded-Proto。这些和 CORS 一样属于传输/部署关切,放在路由之外。
限流与粗粒度门禁(概念)
全局限流(按 IP 或 API Key)适合放中间件:计数器在 Redis,超过阈值直接 429,不占用 DB 连接。细粒度「该用户能否调用此接口」仍应在 Depends + Service。中间件限流要注意与 429 统一错误体——要么在 middleware 里构造 JSON envelope,要么 raise 自定义异常交给 handler。
类式中间件 vs BaseHTTPMiddleware
除装饰器外,可以用纯 ASGI 类中间件(性能与行为更可预期):
class SimpleASGIMiddleware:
def __init__(self, app: Callable[..., Awaitable[None]]) -> None:
self.app = app
async def __call__(
self,
scope: dict[str, object],
receive: Callable[..., Awaitable[object]],
send: Callable[..., Awaitable[None]],
) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
# 可在调用 self.app 前修改 scope,或包装 send 以观察 status
await self.app(scope, receive, send)Starlette 的 BaseHTTPMiddleware 把上述模式简化成 dispatch(request, call_next),但社区与官方文档都提醒:高并发或流式响应场景下,它可能引入额外缓冲或与其他 ASGI 特性交互的问题。概念上要知道——若中间件需要读 body、处理 WebSocket/SSE,或极端追求延迟,优先写原生 ASGI 中间件或确认 BaseHTTPMiddleware 是否满足需求;普通日志、加 Header 用 @app.middleware("http") 通常足够。
流式响应(StreamingResponse、SSE)经过 BaseHTTPMiddleware 时,body 可能被缓冲后才发送,违背「边生成边推」的初衷。系列后面讲流式时会再提;此处记住:观测 first byte 延迟的端点,中间件选型要更保守。
包装 send 可以截获 status 与 headers 发送时机,适合在真正写出 body 前改 status——函数式 middleware 在 call_next 返回后改 response.status_code 通常仍有效,因为 Starlette Response 尚未发送;一旦 handler 开始 streaming 且 headers 已发,就无法再改 status。
中间件、依赖、异常 handler 的分工
| 机制 | 典型用途 | 能否方便访问路由参数 |
|---|---|---|
| 中间件 | CORS、全链路计时、gzip、限流粗粒度 | 否(路由尚未匹配) |
| Depends | DB Session、当前用户、租户 | 是 |
| exception_handler | 统一错误 JSON | 异常已发生 |
误解:用中间件做鉴权就够了。 全局中间件可以拦没有 Token 的请求,但「这个用户能否删这条订单」仍依赖路由参数与 DB,适合放在 Depends(get_current_user)+ Service。中间件更适合「没有 Token 直接 401、不进入路由」的第一道门。
误解:在中间件里开 DB 事务包住整个请求。 事务边界应按业务用例在 Service 层划定(系列后面会专讲);请求级中间件持有长事务会占连接、锁表,并与部分 404/422 短路路径冲突。
误解:中间件里读 request.body() 无代价。 body 只能读一次;中间件消费后路由拿不到。必须读时要用 receive 包装或 Starlette 提供的 pattern 把 body 存回 request._body,否则 POST JSON 路由会挂。能不加 body 中间件就不加。
顺序排查清单
- CORS 尽量靠外,确保错误响应也带 CORS 头,否则浏览器会报跨域而掩盖真实 4xx/5xx。
- 改 body 的中间件与 gzip 等压缩中间件的顺序:通常先产生最终 body,再压缩。
- TestClient 会走完整中间件栈——集成测试通过只说明管道连通,还要单测路由与 Service。
认证中间件与 Depends 双轨
有些团队写 middleware 解析 JWT 并设 request.state.user_id,路由里不再 Depends get_current_user。能工作,但测试与文档变模糊:OpenAPI 看不出需要登录。更常见组合是 middleware 只做「有无 Authorization 头」的粗筛,细粒度用户仍 Depends。无论选哪条,全项目统一,避免一半路由读 state、一半 Depends。
静态文件与 SPA fallback
StaticFiles 挂载不是 middleware,但常与 middleware 栈一起考虑:API 在 /api,前端 dist 在 /,未匹配路由 fallback 到 index.html 给 SPA。注意 API 404 不应被 HTML fallback 吃掉——路由注册顺序与 mount 路径要分开前缀。CORS 对静态与 API 可共用同一 middleware 栈。
请求体大小与超时
Nginx client_max_body_size 先于应用拒绝大上传;middleware 层一般不再读 body 验大小。Timeout 可在 proxy 与 Uvicorn 双侧配置。慢 middleware(外部 HTTP 调用)会占用 worker,尽量轻量或异步队列化。
与部署、反向代理的配合
生产里客户端往往先打 Nginx/Caddy,再转发 Uvicorn。X-Forwarded-For、X-Forwarded-Proto 由代理写入,应用侧用 ProxyHeadersMiddleware 或 Uvicorn 的 --forwarded-allow-ips 信任这些头,访问日志才能记真实客户端 IP。中间件里读 request.client.host 在代理后面常常是 127.0.0.1,除非配置 forwarded headers。
HTTPS 终结在代理时,应用内部仍是 HTTP;若中间件做「强制 HTTPS 重定向」,要基于 X-Forwarded-Proto == http 判断,而不是 request.url.scheme,否则死循环或漏 redirect。
性能与可观测性
每个中间件增加一次 async 调用深度。一般几十个以内不是瓶颈;若单层做重 CPU(大 body JSON pretty print 日志),会拖慢所有路由。日志采样、路径排除、异步写 log 队列是常见优化。Metrics 中间件可统计 status histogram,与 Prometheus 集成时注意不要对每个 path 无限 cardinalities——用模板化 route pattern(如 /orders/{id})而非原始 path。
调试中间件顺序的实用做法
本地临时在最外层 middleware 打印 >> entering X 与 << leaving X,发一条请求看 stdout 顺序,比读文档猜注册先后更快。确认 CORS 是否在最外:故意触发 500,看浏览器 Network 里 500 响应是否仍有 Access-Control-Allow-Origin。若没有,前端只会看到 CORS error,排错方向会偏。
常见误解补充
复制粘贴中间件到每个小项目。 抽成可复用模块(如 create_access_log_middleware(logger))并在 create_app() 里按 settings 启用,减少 drift。
安全响应头
部分团队在中间件统一加 X-Content-Type-Options: nosniff、X-Frame-Options: DENY。与 CORS 不同,这些对 API JSON 客户端也有意义。Content-Security-Policy 对纯 API 价值有限,对同域返回的 Swagger UI 页面更有用。安全头 middleware 可放在 CORS 内层,确保 error response 同样带头。
压缩与 HTTP/2
HTTP/2 多路复用下,middleware 顺序仍按 ASGI 栈;不要假设每个请求独立 TCP。Keep-Alive 由 server 与 proxy 管理,应用 middleware 一般不需 touch。
GZip 与静态资源
GZipMiddleware 压缩响应 body,对 JSON API 通常有益,对已是图片/zip 的响应可能略增 CPU 收益却不大。静态文件若由 Nginx 直接 serve,不必再经 Python gzip 中间件。压缩层应位于「最终 body 已确定」之后,否则流式 endpoint 可能被整段缓冲。
WebSocket 与中间件边界
WebSocket 握手走 HTTP upgrade,部分 HTTP 中间件会处理 upgrade 请求;连接建立后的帧不再经过 @app.middleware("http")。若要做 WS 鉴权,常在 accept 前读 query token,或在 ASGI 层中间件区分 scope["type"]。这与 REST API 的 Depends 鉴权并行存在,别假设「HTTP 中间件拦了就等于 WS 也安全」。
案例:组合 middleware 与 handler 的 429
限流中间件触发时,可以直接 return JSONResponse(429, content=error_body(...)),与异常 handler 产出的 envelope 一致,前端无需分支解析。关键是复用同一个 error_body 工厂,避免 middleware 手写 dict 与 handler 字段漂移。计时中间件记录的 duration 应写入 response header,与 access log 交叉验证,排查慢查询时很有用。
本地开发与生产差异
开发时常把 CORS 放宽到 localhost:5173,生产收紧到正式域名;用 settings 切换,不要注释代码切换。本地少开 gzip 便于 DevTools 读 body。Staging 应尽量与生产 middleware 栈一致,否则「staging 绿、生产 CORS 挂」仍会发生。Feature flag 控制实验性 middleware(如 canary 限流)时,默认 off,避免 half-baked 逻辑影响全量流量。
中间件管「每个请求必经的管道」;下一篇用 pytest 与 TestClient 说明如何对这条管道上的接口行为做自动化断言。
排查生产问题时,先确认请求是否穿过预期 middleware:看 access log 有无 request id、响应有无 process time header。若缺失,可能是部署用了不同 create_app 工厂或 middleware 被条件禁用。Staging 与 Production 的 middleware 列表应代码级 diff,而不是靠运维手册记忆。Middleware 改顺序属于发布风险点,变更时跑一遍 CORS + 401 + 500 的 smoke。
中间件是横切关注点的主战场,但业务正确性仍在 Service。别把「在 middleware 里打日志」当成已完成 observability——结构化日志、trace、metrics 还要在 handler 与 Service 边界补全。下一篇 pytest 会说明如何验证这些行为在 CI 里可重复。
小结
中间件负责每个请求进路由前、出路由后的公共管道:CORS、日志、计时、安全头、限流粗筛。与 Depends、exception handler 分工明确;顺序与 body 只读一次是两大排坑点。生产变更 middleware 栈时做 CORS 与 error path smoke,避免前端误报跨域。
回顾整条链路:浏览器或客户端请求先经最外层 middleware(常为 CORS 与 request id),再经日志与计时,才到 FastAPI 路由与 Depends。响应沿反方向出去,任何一层改 header 都会影响最终客户端所见。理解这条链,是排查「本地 curl 正常、浏览器跨域失败」「access log 缺字段」的第一步。Middleware 写得多不如写得准——每层只做一件事,顺序文档化,测试用 TestClient 验证关键 header 与 error path 上的 CORS 是否仍在。
生产环境 middleware 变更应可配置开关,便于快速关闭有问题的层而不回滚整包。配合结构化 access log 与 trace id,排障时可从网关日志一路追到应用 middleware 输出,定位延迟落在哪一层。
Middleware 栈是 API 的「前置与后置滤镜」,与路由解耦,改 CORS 或日志不必动业务代码。