K 的一隅

Python FastAPI 后端实战

请求经过的管道:中间件怎么用

ASGI 中间件在路由前后的执行顺序、CORS 与日志计时等横切关注点,以及 BaseHTTPMiddleware 的使用注意点。

12 分钟阅读 更新于 2026-07-28
本文目录

客户端发来一个 HTTP 请求,在到达你的 @app.get 之前,可能已经历了解析、CORS 预检、访问日志、耗时统计、注入请求 ID 等多道工序;响应返回时,这些层再以相反顺序收尾。这条管道不是 FastAPI 路由表的一部分,而是 中间件(middleware)——对「每个请求」统一生效的包装器。弄懂顺序和边界,才能避免「Header 已经发出才改状态码」之类的坑,也才能和依赖注入、异常 handler 各就各位:中间件管传输层横切,Depends 管请求级能力,handler 管错误出站格式。

ASGI 中间件在栈里的位置

FastAPI 基于 Starlette,中间件模型是 ASGI callable 的洋葱圈:外层先收到 scope, receive, send,可以选择改 scope、包一层 send、或在调用内层前后执行代码。

注册:

python
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 或记日志。

python
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 避免刷屏:

python
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 response

request.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 的入站错误形状应对齐。

但有几处逻辑要补齐,否则上线会踩坑:

  1. 流式 / SSE / 文件下载:body 是生成器,中间件整段缓冲会破坏流式,也可能撑爆内存——路径白名单或按 media_type 跳过包装。
  2. 错误体二次包装:若异常 handler 已返回统一 envelope,中间件再包会变成嵌套 data.error;应约定只在一处定形(推荐 handler 管错误,中间件只包成功,或反过来)。
  3. OpenAPI 与类型response_model=ItemOut 描述的是未包装的 data;文档要额外说明 envelope,或改用自定义路由返回模型。
  4. 读 body 一次:包装中间件已耗尽 iterator,必须构造新 Response;别再假定原 response 可重放。

更稳妥的折中:成功路径由路由/response_model 直接返回业务对象,前端接受;错误路径靠第 9 篇的全局 handler 统一。若产品强制成功也要 envelope,优先在薄包装函数或基类路由里显式构造,比「全局吞 body 的中间件」更容易对 SSE 开豁免。中间件方案能教 AOP,但不是默认最佳实践。

CORS:浏览器跨域的专用中间件

浏览器跨域时先发 OPTIONS 预检。手写 Access-Control-* 头容易漏方法和 Header 白名单。Starlette 提供 CORSMiddleware,应通过 app.add_middleware 挂载:

python
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 类中间件(性能与行为更可预期):

python
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、限流粗粒度否(路由尚未匹配)
DependsDB 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 中间件就不加。

顺序排查清单

  1. CORS 尽量靠外,确保错误响应也带 CORS 头,否则浏览器会报跨域而掩盖真实 4xx/5xx。
  2. 改 body 的中间件与 gzip 等压缩中间件的顺序:通常先产生最终 body,再压缩。
  3. 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-ForX-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: nosniffX-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 或日志不必动业务代码。