Python 框架指南 · 第 10 章

中间件

本章你将学会 - 听懂「横切关注点」「AOP」两个唬人的词,明白中间件到底在替你干什么活 - 中间件的标准模板:request → callnext → 返回,以及洋葱模型 - 两种写法的取舍:@app.midd…

约 45 分钟18,150 字36 个代码示例

本章你将学会

  • 听懂「横切关注点」「AOP」两个唬人的词,明白中间件到底在替你干什么活
  • 中间件的标准模板:request → call_next → 返回,以及洋葱模型
  • 两种写法的取舍:@app.middleware("http") 装饰器 与 BaseHTTPMiddleware 类
  • 计时中间件:给每个响应盖一枚 X-Process-Time 章
  • CORS 中间件:CORSMiddleware 全参数用法,同源策略到底在防什么
  • 统一响应信封:所有 JSON 响应重打包成 {code, data, message},流式/文件放行,错误响应防二次包裹
  • 注册顺序的真相:后注册的在外层(附实测办法,这个知识点八成和你想的不一样)
  • add_middleware 与装饰器的关系,以及集中注册的工程化写法

上一章我们把所有异常出口统一成了 {code, message} 的样子——但这是「出错了怎么办」。这一章处理另一类烦人的事:每个接口都要干、又跟业务毫无关系的活。老板要求每个接口报耗时、前端要求统一返回格式、浏览器要求跨域放行——这三件事如果写进每个端点函数,30 个端点就得抄 30 遍。中间件(Middleware)就是来终结这种复制粘贴的。本章继续用墨集 inkhub 内容平台做示例。


1. 先把两个唬人的词拆了:横切关注点与 AOP

作用:给「为什么需要中间件」建立一个心智模型。

墨集 inkhub 的接口越写越多,某天老板提了三个要求:

  1. 每个接口的响应头里带上处理耗时(性能监控要用);
  2. 所有接口的返回统一成 {code, data, message} 格式(前端统一处理);
  3. 允许墨集前端(另一个端口)跨域访问。

最直觉的做法:打开 posts.py、channels.py、authors.py……在每个处理函数的开头 time.perf_counter(),结尾包一层 dict,再给响应加头。这种写法有个学名叫侵入式——业务代码被非业务代码污染了,而且每新增一个端点就要再抄一遍,漏抄一个就是 bug。

这类「像切香肠一样横穿所有业务功能」的需求,术语叫横切关注点(Cross-Cutting Concerns)。解决它的思路叫 AOP(Aspect Oriented Programming,面向切面编程):把这些逻辑从业务代码里抽出去,挪到请求的必经之路上统一处理。

打个比方:墨集大厦里有三十家公司(三十个端点),家家都需要照明、安保、消防。没有哪家公司会自己雇一支消防队——物业统一在楼层层面解决。中间件就是这座写字楼的物业:请求进楼必须过物业这一层,响应出楼也得再过一次。你在端点函数里看不到任何计时、格式化的代码,但每个请求都享受了这些服务。

在 FastAPI 生态里,AOP 有两个落地手段:第 08 章的依赖注入(可以被单个路由按需使用)和本章的中间件(无差别作用于所有请求)。横扫全站的活交给中间件,按需使用的活交给依赖注入。


2. 中间件的标准模板:request、call_next 和洋葱模型

作用:一个中间件函数只有三样东西——进来的请求、交给下一层的动作、回来的响应。

签名(两种写法共用同一套逻辑):

async def my_middleware(request: Request, call_next):
    # ── 时机①「进」:请求还没到路由,可以看/改 request ──
    response = await call_next(request)
    # ── 时机②「出」:拿到下一层的响应,可以看/改 response ──
    return response

三个可用时机,对应三种典型用法:

时机 位置 能干什么 例子
① 进 call_next 之前 检查请求,甚至直接短路不往下传 黑名单 IP 直接返回 403
② 交 await call_next(request) 把请求交给下一层(下一个中间件或路由) 不动它,照抄
③ 出 call_next 之后 查看/修改响应 加响应头、重打包响应体

短路长这样——call_next 压根不执行,请求到不了路由:

async def block_scanners(request: Request, call_next):
    if request.headers.get("user-agent", "").lower().startswith("scrapy"):
        return JSONResponse({"code": "40303", "message": "爬虫走开"}, status_code=403)
    return await call_next(request)

洋葱模型:多个中间件层层嵌套,请求一层层穿进去,响应一层层穿出来。用墨集的话说:一篇文章的请求从大门进编辑部,要过「初审台 → 复审台 → 排版台」才能到主编(你的处理函数),出来的稿子再原路经过排版、复审、初审逐层盖章返回。

关键是完整的洋葱不止你写的这几层,Starlette 在最外和最内各塞了一层自带中间件:

客户端请求
  ↓
ServerErrorMiddleware(Starlette 自带,最外层:兜住没人处理的异常 → 500)
  ↓
你注册的中间件(后注册的在外层,本章主角)
  ↓
ExceptionMiddleware(Starlette 自带,最内层:第 09 章的异常处理器住在这里)
  ↓
路由 → 依赖注入 → 校验 → 你的处理函数

记住一个伏笔:异常处理器住在洋葱最内层。这意味着端点抛出的异常会先被第 09 章的统一异常处理器变成一个普通的 {code, message} 响应,然后才流到你的中间件眼前——中间件拿到的永远是「处理完的响应」,而不是异常本身。这正是第 6 节信封能包裹错误响应的前提。


3. 两种写法:装饰器与 BaseHTTPMiddleware 怎么选

3.1 装饰器写法(函数式)

作用:用 @app.middleware("http") 把一个函数升级成中间件。写起来最快,适合项目内的一次性逻辑。

签名:

@app.middleware("http")
async def 任意名字(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
    ...

最小示例——给墨集每个响应加一个自定义头:

from collections.abc import Awaitable, Callable

from fastapi import FastAPI, Request, Response

app = FastAPI(title="墨集 inkhub")


@app.middleware("http")
async def add_server_banner(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
    response = await call_next(request)
    response.headers["X-Powered-By"] = "inkhub"
    return response

它的底牌:装饰器只是语法糖,FastAPI 内部执行的就是一行

app.add_middleware(BaseHTTPMiddleware, dispatch=add_server_banner)

所以「装饰器中间件」的本质是 BaseHTTPMiddleware——这句话在下面 3.3 和第 8 节还会反复用到。

3.2 类写法(继承 BaseHTTPMiddleware)

作用:把中间件写成一个类,dispatch 方法里放逻辑,__init__ 里收配置。适合要传参数、要复用、要发布成包的中间件。

最小示例——同一个「加横幅」中间件,横幅名字可配置:

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp


class BannerMiddleware(BaseHTTPMiddleware):
    def __init__(self, app: ASGIApp, *, banner: str = "inkhub") -> None:
        super().__init__(app)
        self.banner = banner

    async def dispatch(
        self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
    ) -> Response:
        response = await call_next(request)
        response.headers["X-Powered-By"] = self.banner
        return response


# 注册时把配置传进去
app.add_middleware(BannerMiddleware, banner="inkhub-editors")

注意两个固定动作:__init__ 里必须 super().__init__(app);注册时不要自己传 app 参数(框架会穿针引线),只传你自己的配置参数。

3.3 选型速查

维度 装饰器(函数式) 类(BaseHTTPMiddleware) 纯 ASGI 类
上手速度 最快,三行搞定 要写类骨架 要理解 ASGI 协议
可配置 只能靠闭包/全局变量 __init__ 传参,干净 __init__ 传参,干净
复用/发布 难 容易 容易
性能 每请求多一层任务包装 同左 最快,零包装
典型用户 项目内私有逻辑 团队内通用组件 CORSMiddleware 这类官方件

「纯 ASGI 类」长什么样,认个脸即可,本章不展开:

class PureASGIMiddleware:
    def __init__(self, app: ASGIApp) -> None:
        self.app = app

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        await self.app(scope, receive, send)  # 直接操作协议层,没有 request/response 包装

CORSMiddleware 就是这一类——它直接在 ASGI 协议层干活,不走 BaseHTTPMiddleware 那套包装,所以它根本没法用 @app.middleware("http") 装饰器写出来,只能 add_middleware 注册。这也回答了一个常见疑问:「为什么 CORS 不写个装饰器版?」

3.4 BaseHTTPMiddleware 的两个真实怪癖(实测过)

装饰器和类写法本质都是 BaseHTTPMiddleware,它有两个值得知道的怪癖:

怪癖一:contextvars「只进不出」。 在中间件里设置的 contextvar,端点能读到;但在端点里设置的 contextvar,中间件在 call_next 之后读不到(原因:下游跑在另一个任务里,上下文是副本)。实测:

rid = contextvars.ContextVar("rid", default="unset")

@app.middleware("http")
async def mw(request, call_next):
    resp = await call_next(request)
    resp.headers["X-Rid-After"] = rid.get()  # 读到的是 "unset",不是端点设置的值!
    return resp

@app.get("/probe")
async def probe():
    rid.set("set-by-endpoint")
    return {"ok": True}

第 13 章用 loguru 给日志绑定「请求 ID」时会撞上这个坑——绑定动作要么放在中间件最外层入口做,要么别指望回读。

怪癖二:每个请求多一层任务包装。 BaseHTTPMiddleware 会把下游应用包进一个额外任务里跑,有一点点性能开销。绝大多数项目无感;超高并发的网关类服务才需要认真考虑换纯 ASGI 中间件。


4. 实战一:计时中间件,给每个响应盖耗时章

作用:在响应头写入本次处理耗时,前端或网关拿到就能做性能监控,完全不动业务代码。

签名(响应头的写入方式):

response.headers["X-Process-Time"] = 耗时字符串

最小示例(本章 demo 采用的版本,单位毫秒):

import time
from collections.abc import Awaitable, Callable

from fastapi import Request, Response


@app.middleware("http")
async def timing_middleware(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
    start = time.perf_counter()
    response = await call_next(request)
    elapsed_ms = (time.perf_counter() - start) * 1000
    response.headers["X-Process-Time"] = f"{elapsed_ms:.3f}"
    return response

验证:

curl -i http://127.0.0.1:8000/api/posts
HTTP/1.1 200 OK
x-process-time: 0.584        ← 这枚章
content-type: application/json

{"code":"0","data":[...],"message":"success"}

常见坑:

  • 为什么叫 X- 开头?HTTP 约定:非标准自定义头都建议冠 X- 前缀,明示「这是我们家私设的头」,跟官方头区分开。
  • 计时用 time.perf_counter(),别用 time.time()。前者是单调高精度计时器(专测耗时),后者会被系统校时掰弯——NTP 一校时,耗时可能出现负数。
  • 改响应头必须在 return response 之前,return 之后就晚了。
  • 计时中间件放最内层量的是「业务处理耗时」,放最外层量的是「全链路耗时」。没有对错,但你得知道自己量的是哪一段。

5. 实战二:CORS 中间件,跟浏览器的「同源门禁」打交道

5.1 先搞懂敌人:同源策略是什么

墨集前端跑在 http://localhost:5173(Vite 开发服务器),后端跑在 http://127.0.0.1:8000。前端一调接口,浏览器控制台劈头一句:

Access to fetch at 'http://127.0.0.1:8000/api/posts' from origin
'http://localhost:5173' has been blocked by CORS policy

这不是后端的错,也不是网络的错,而是浏览器的保安条例——同源策略:协议、域名、端口三件套完全一致才算「同源」。localhost:5173 和 127.0.0.1:8000:域名不同、端口也不同,跨了两条,浏览器默认禁止页面里的 JS 读取这个响应。

打个比方:浏览器是小区物业,只放行本小区(同源)住户互相串门。想跨小区送快递?可以,但收件小区的物业(后端)得先登记你的门牌——这就是 CORS 的本质:服务器明确声明「我允许哪些小区的住户来串门」,浏览器看到声明才放行 JS 读响应。

两个关键认知:

  • CORS 是浏览器的行为,不是服务器的。curl、Postman、后端之间的互调永远不受影响——它们不带 Origin 请求头,CORS 中间件对这种请求直接放行、什么都不加。
  • 服务器怎么「声明」?在响应里加 Access-Control-Allow-Origin 等响应头。CORSMiddleware 做的就是这件事。

5.2 预检请求:先敲门,再搬家

浏览器对「简单请求」(GET、POST 表单那种)会直接发出去再检查响应头;但对复杂请求(带自定义头的 POST、PUT、DELETE 等),会先发一个预检请求:方法为 OPTIONS,带上两个特殊请求头,先问物业一句「我等会儿想用这个方法、带这些头来,行不行?」:

OPTIONS /api/posts
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

物业(CORSMiddleware)检查白名单后回一张「通行证」:

HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:5173
access-control-allow-methods: GET, POST
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type
access-control-max-age: 600

通行证有效期 max_age 秒内,同类请求不再预检,浏览器这才把真正的 POST 发出来。

5.3 CORSMiddleware 参数手册

注册方式(它是纯 ASGI 中间件,只能这么注册):

from starlette.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
    expose_headers=["X-Process-Time"],
)

参数逐个讲:

参数 管什么 常用值
allow_origins 哪些源可以来串门 精确写前端地址;["*"] 表示谁来都行
allow_methods 允许哪些 HTTP 方法 ["*"] 或按需列举
allow_headers 允许请求带哪些自定义请求头 常见 Authorization、Content-Type
expose_headers 允许前端 JS 读哪些响应头 把 X-Process-Time 登记进来
allow_credentials 是否允许带 cookie/凭据 要带就 True,但见下方坑
max_age 预检通行证有效期(秒) 默认 600
allow_origin_regex 用正则匹配一批源 r"https://\w+\.inkhub\.cn"

最容易搞反的一对:allow_headers 管请求头白名单(浏览器发请求时能带什么头),expose_headers 管响应头白名单(前端 JS 能从响应里读到什么头)。方向一个朝外一个朝内。

5.4 curl 验证

# 预检:三个请求头缺一不可,缺了就不是预检,只是个普通 OPTIONS
curl -i -X OPTIONS http://127.0.0.1:8000/api/posts \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

# 普通请求带 Origin:看响应头回显
curl -i http://127.0.0.1:8000/api/posts -H "Origin: http://localhost:5173"

实测响应(节选):回显源 access-control-allow-origin: http://localhost:5173,登记的暴露头 access-control-expose-headers: X-Process-Time 也如约出现。

常见坑:

  • allow_origins=["*"] 与 allow_credentials=True 不能同时用。带凭据时浏览器要求服务器必须回显精确来源,* 会被浏览器直接判死。要带 cookie 就老老实实列来源清单。
  • 前端读不到 X-Process-Time?不是没盖章,是没登记。非标响应头默认对前端 JS 不可见,必须写进 expose_headers。
  • localhost 和 127.0.0.1 是两个不同的源。域名不同就算跨域,白名单里最好两个都写上(或者用 allow_origin_regex)。
  • 预检被拒不报错码以外的信息:方法不在 allow_methods 里,CORSMiddleware 直接回 400 Disallowed CORS method——看到这个串,先查白名单。
  • 预检请求由 CORSMiddleware 在最外层直接短路应答(所以第 7 节的排兵布阵里 CORS 要放最外层),它根本不会进到你的路由。

6. 实战三:统一响应信封,包裹 09 章的异常响应

6.1 目标

墨集前端要求:所有 /api/ 接口的成功响应长这样——

{"code": "0", "data": {"id": 1, "title": "中间件:FastAPI 的物业部"}, "message": "success"}

错误响应长这样(data 恒为 null,code/message 直接来自第 09 章的统一异常格式)——

{"code": "40404", "data": null, "message": "文章不存在"}

错误格式已经在第 09 章由异常处理器统一好了,本章的活是:用中间件在出口处统一打包,一个端点都不用改。

6.2 完整代码,分四段讲

import json

from fastapi.responses import JSONResponse


@app.middleware("http")
async def envelope_middleware(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
    response = await call_next(request)

    # 放行判断①:非业务接口(/docs、/openapi.json、/healthz……)不动
    if not request.url.path.startswith("/api/"):
        return response

    # 放行判断②:非 JSON 响应(SSE 流、文件下载)原样放行
    if "json" not in response.headers.get("content-type", ""):
        return response

    # 第一段:读出响应体。body_iterator 只能消费一次,读完回不了头
    body = b""
    async for chunk in response.body_iterator:
        body += chunk

    # 第二段:复制原响应头(保住 X-Process-Time 等),删掉旧长度
    headers = dict(response.headers)
    headers.pop("content-length", None)

    payload = json.loads(body) if body else None

    # 第三段:错误分支——拍平,防二次包裹
    if response.status_code >= 400:
        err = payload if isinstance(payload, dict) else {}
        return JSONResponse(
            content={
                "code": err.get("code", str(response.status_code)),
                "data": None,
                "message": err.get("message", ""),
            },
            status_code=response.status_code,
            headers=headers,
        )

    # 第四段:成功分支——原数据整体放进 data
    return JSONResponse(
        content={"code": "0", "data": payload, "message": "success"},
        status_code=response.status_code,
        headers=headers,
    )

第一段(读流):call_next 给你的响应体不是现成字符串,而是一个异步迭代器 body_iterator,要自己拼。注意它是一次性的——消费过就无法原样退回。

第二段(保头删长):重打包后响应体长度必然变化。JSONResponse 构造时发现 headers 里已有 content-length 就不再重算——实测不删的话,旧头声称 7 字节、新体实际 27 字节,严格按头收包的浏览器会把 JSON 拦腰截断。而 X-Process-Time 这类头必须复制过来,否则内层盖的章就丢了。

第三段(错误拍平):状态码 ≥400 的响应,正文已经是第 09 章异常处理器产出的 {code, message}。正确动作是把这两个字段拍平到信封顶层,data 置 null。错误做法是把整个错误体塞进 data——那样前端会拿到 data: {"code": "40404", "message": "文章不存在"},两层 code 套娃,这就是「二次包裹」事故。

第四段(成功包裹):原数据整个放进 data,状态码原样保留(201 还是 201)。

两个放行判断为什么必须有:

  • 路径判断:/docs、/openapi.json 是给浏览器和工具用的,包进信封会直接弄坏交互界面;健康检查这类运维接口也没必要跟前端共用格式。
  • content-type 判断:SSE 流(第 14 章的主角)要边生成边推给客户端,文件下载要按字节流吐——它们没有「完整的 JSON 响应体」可供读取和重打包。硬读 body_iterator 会把流缓冲干死,重打包更是直接毁掉功能。判断依据很朴素:响应的 content-type 里没有 json,就不是我们该碰的东西。

6.3 为什么这活必须在中间件层干

回头看第 1 节的图:信封是典型的横切关注点。三个候选位置:

  1. 写进每个端点——侵入式,抄 30 遍,最先排除;
  2. 前端拦截器统一包——治标不治本:多个前端(Web、小程序、第三方调用方)就得各包一遍,且错误格式已经在后端统一了,成功格式却靠前端补,不对称;
  3. 中间件层统一包——后端出口唯一收口,所有调用方拿到的格式天然一致。

再配合 09 章:异常处理器住在洋葱最内层,先把所有异常变成 {code, message} 响应;信封中间件在出口处统一拍平。两层接力,错误和成功都只有一个出口格式。

6.4 常见坑

  • body_iterator 消费后不能反悔。读完才发现「这单不该包」已经晚了——要么在消费前做完所有判断(本节代码的顺序就是为此),要么消费后用 JSONResponse(content=payload, ...) 重建一个等价响应,绝不能把消费过的 response 原样 return。
  • 某些端点已经自带信封怎么办?如果历史接口本来就返回 {code, data, message},会被包成双层。防法:成功分支里加一个判断——
if isinstance(payload, dict) and "code" in payload and "data" in payload:
    return JSONResponse(content=payload, status_code=response.status_code, headers=headers)  # 已是信封,原样重建

但更好的做法是立规矩:端点永远只返回裸数据,格式一律交给中间件。 - /docs 里的响应模型不会跟着变。信封是运行时动态打包的,而 OpenAPI 文档里的响应模型还是端点声明的裸模型——文档和真实返回对不上。进阶解法是改写 app.openapi() 把文档里的响应 schema 也包上信封,属于锦上添花的工程活,知道方向即可,本章不展开。 - 204、重定向这类没有响应体的,content-type 多半为空,会被放行判断②自然放行,不用特判。


7. 注册顺序的真相:后注册的在外层

作用:中间件一层套一层,谁在外面决定了请求先进谁家、响应后出谁家。这个顺序规则和多数人的直觉相反,先说结论:

后注册(后执行注册语句)的中间件在 外层,先接触请求;先注册的在 内层,更靠近路由。

7.1 为什么:看一眼源码机制

Starlette 注册中间件时用的是 insert(0, ...)——新来的插到队伍最前面;构建洋葱时从最内层(路由)开始,倒着逐层包裹。两下一配合:注册越晚,包得越外。

# starlette/applications.py(简化)
def add_middleware(self, middleware_class, *args, **kwargs):
    self.user_middleware.insert(0, Middleware(middleware_class, *args, **kwargs))  # 插队首

def build_middleware_stack(self):
    middleware = [ServerError] + self.user_middleware + [Exception]
    app = self.router                       # 从路由开始
    for cls, args, kwargs in reversed(middleware):   # 从列表尾(最内层)往外包
        app = cls(app=app, *args, **kwargs)
    return app

7.2 实测:别信记忆,信 trace

三个中间件、一个端点,各自往全局列表里塞记号:

trace: list[str] = []

@app.middleware("http")          # 代码里写在上面
async def mw_a(request, call_next):
    trace.append("a-in")
    resp = await call_next(request)
    trace.append("a-out")
    return resp

@app.middleware("http")          # 代码里写在下面
async def mw_b(request, call_next):
    trace.append("b-in")
    resp = await call_next(request)
    trace.append("b-out")
    return resp

实测输出:

trace: ['b-in', 'a-in', 'route', 'a-out', 'b-out']

注意看:写在下面的 mw_b 反而先进(外层),写在上面、先注册的 mw_a 在内层。因为装饰器注册语句是按代码从上到下执行的——mw_a 先注册进内层,mw_b 后注册包在外面。看着别扭?记住一句话:「后来者居上(外)」。

7.3 本章三件套的排兵布阵

知道规则后,回头看 demo 里三个中间件为什么这么排(注册顺序:计时 → 信封 → CORS,洋葱从外到内:CORS → 信封 → 计时):

层次 中间件 为什么在这层
最外 CORS 预检 OPTIONS 在这里直接短路应答,根本不必惊动里面两层
中间 信封 重打包时复制内层盖好的 X-Process-Time,一枚章都不丢
最内 计时 紧贴路由,量出来的就是纯业务耗时

排错的代价:把信封放最外层,CORS 的预检响应也会被塞进信封(虽然响应头都在、多半还能用,但语义脏了);把计时放最外层,耗时就混进了外层中间件的处理时间。中间件顺序是架构的一部分,改动前拿 7.2 的 trace 法实测一遍。

7.4 常见坑

  • Django 老兵最容易栽这里:Django 的 MIDDLEWARE 列表从上到下正好是从外到内——先注册的在外层。FastAPI/Starlette 正相反。从 Django 迁过来的同学请把 7.2 的实测代码跑一遍再动手。
  • 应用启动后不能再 add_middleware。中间件栈在收到第一个请求前就已搭好,之后再注册直接 RuntimeError: Cannot add middleware after an application has started。所有注册必须发生在应用启动阶段(模块顶层)。
  • 装饰器和 add_middleware 混用时,判断层次只看一个标准:注册语句谁后执行,谁在外层,别按代码的视觉位置想当然。

8. add_middleware 与装饰器:一个注册口,两副面孔

作用:把注册这件事彻底说透。add_middleware 是唯一的注册入口,装饰器只是它的一件马甲。

3.1 节看过装饰器的底牌:

@app.middleware("http")
async def my_middleware(request, call_next): ...
# 等价于
app.add_middleware(BaseHTTPMiddleware, dispatch=my_middleware)
# 也等价于:不写装饰器,拿到函数后手动注册
app.middleware("http")(my_middleware)

而 add_middleware 这一个口子能装下三类东西:

# ① 函数式(上面那种等价写法)
app.middleware("http")(my_middleware)

# ② BaseHTTPMiddleware 子类——可以传配置参数
app.add_middleware(BannerMiddleware, banner="inkhub-editors")

# ③ 纯 ASGI 中间件类——CORSMiddleware 这种,装饰器根本写不了
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:5173"])

工程化建议:集中注册。中间件散落在各文件里到处 add_middleware,顺序关系很难一眼看清。把本章三件套收进一个注册函数,顺序即文档:

import inspect

from fastapi import FastAPI

# 列表顺序 = 注册顺序 = 洋葱从内到外(第一个注册的在最内层)
MIDDLEWARES: list[tuple[type | Callable, dict]] = [
    (timing_middleware, {}),          # 最内层
    (envelope_middleware, {}),
    (CORSMiddleware, {"allow_origins": ["http://localhost:5173"], "expose_headers": ["X-Process-Time"]}),  # 最外层
]


def register_middleware(app: FastAPI) -> None:
    for mw, kwargs in MIDDLEWARES:
        if inspect.isclass(mw):
            app.add_middleware(mw, **kwargs)      # 类 → add_middleware
        else:
            app.middleware("http")(mw)            # 函数 → 装饰器等价注册

inspect.isclass 分流:是类就走 add_middleware(能带构造参数),是函数就走 app.middleware("http")。列表顺序就是注册顺序,谁内谁外一目了然,将来插入新中间件只需挪一行。

常见坑:

  • add_middleware 传参传的是中间件类的构造参数(如 banner="..."),不是 dispatch 函数的参数——函数式中间件想要配置,只能靠闭包,要配置就升级成类写法。
  • app.middleware("http") 的参数目前只有 "http" 一个合法值——它处理的是 HTTP 请求;WebSocket 流量走不到这层中间件(第 14 章见)。

9. 跑一下本章 demo

完整代码在 demo/demo_middleware.py:内存假数据 + 计时、CORS、信封三个中间件 + 健康检查、文章列表/详情/创建、SSE 流、文件导出一组路由,单文件自足。

uv add fastapi "uvicorn[standard]"
python demo_middleware.py        # 内嵌 uvicorn,等价 uvicorn demo_middleware:app --port 8000

另开一个终端,按顺序验证三件套(以下均为实测输出,节选):

# ① 计时章 + 信封一起看
curl -i http://127.0.0.1:8000/api/posts
HTTP/1.1 200 OK
x-process-time: 0.584
content-type: application/json

{"code":"0","data":[{"id":1,"title":"中间件:FastAPI 的物业部","summary":"横切关注点一次性解决"},{"id":2,"title":"SQLAlchemy 异步会话的正确姿势","summary":null}],"message":"success"}
# ② 错误响应拍平(防二次包裹):注意 data 是 null,code/message 来自 09 章格式
curl -i http://127.0.0.1:8000/api/posts/999
HTTP/1.1 404 Not Found
x-process-time: 0.477

{"code":"40404","data":null,"message":"文章不存在"}
# ③ SSE 流式放行:不进信封,逐条收到 data: 事件
curl -N http://127.0.0.1:8000/api/posts/1/stream
data: 墨

data: 集

data: inkhub

data: 中间件:FastAPI 的物业部
# ④ 文件下载放行:拿到的是原始 markdown,不是信封
curl -i http://127.0.0.1:8000/api/posts/export

# ⑤ 非 /api/ 路径放行:原样返回,但计时章还在
curl -i http://127.0.0.1:8000/healthz

# ⑥ CORS 预检:200 + Allow-* 头,且预检响应没被信封包裹(CORS 在最外层短路)
curl -i -X OPTIONS http://127.0.0.1:8000/api/posts \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

# ⑦ 带 Origin 的普通请求:看 allow-origin 回显和 expose-headers
curl -i http://127.0.0.1:8000/api/posts -H "Origin: http://localhost:5173"

预检实测响应(节选):

HTTP/1.1 200 OK
access-control-allow-methods: GET, POST
access-control-allow-origin: http://localhost:5173
access-control-allow-headers: Accept, Accept-Language, Authorization, Content-Language, Content-Type
access-control-max-age: 600
content-length: 2        ← 预检响应体只是 "OK",没进信封

另外注意 ④ 文件导出 的一个路由细节:/api/posts/export 必须声明在 /api/posts/{post_id} 之前,否则 export 会被当成 post_id 去做 int 校验,返回 422。


10. 小结速查

需求 用法
注册函数式中间件 @app.middleware("http"),本质 add_middleware(BaseHTTPMiddleware, dispatch=fn)
注册可配置的类中间件 继承 BaseHTTPMiddleware,add_middleware(类, **配置)
短路请求 call_next 之前直接 return JSONResponse(...)
给响应盖耗时章 response.headers["X-Process-Time"] = f"{ms:.3f}"
解决跨域 add_middleware(CORSMiddleware, allow_origins=[...], ...)
让前端读到自定义响应头 expose_headers=["X-Process-Time"]
统一响应信封 消费 body_iterator → 重打包 JSONResponse,删旧 content-length
信封放行 非 /api/ 路径、非 JSON content-type
错误防二次包裹 ≥400 时把 09 章的 code/message 拍平到信封顶层,data=None
中间件顺序 后注册的在外层;装饰器写在代码下面的反而在外层
集中注册 列表 + inspect.isclass 分流 + register_middleware(app)

自测三问:

  1. 三个中间件按 A、B、C 的顺序执行注册语句,请求先经过谁家?响应最后经过谁家?
  2. 信封中间件里,为什么读完 body_iterator 之后就不能再把原响应返回了?content-length 不删会发生什么?
  3. 前端 fetch 能正常收数据,却读不到 X-Process-Time 响应头——列出两个可能的原因。

横切关注点的地基打好了:计时、跨域、统一格式,全都不再侵入业务代码。下一章要在「请求进门的必经之路」上再设一道更重要的关卡——你是谁?你的令牌呢?


下一章:11 · 认证与 JWT