中间件
本章你将学会 - 听懂「横切关注点」「AOP」两个唬人的词,明白中间件到底在替你干什么活 - 中间件的标准模板:request → callnext → 返回,以及洋葱模型 - 两种写法的取舍:@app.midd…
本章你将学会
- 听懂「横切关注点」「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 的接口越写越多,某天老板提了三个要求:
- 每个接口的响应头里带上处理耗时(性能监控要用);
- 所有接口的返回统一成
{code, data, message}格式(前端统一处理); - 允许墨集前端(另一个端口)跨域访问。
最直觉的做法:打开 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 节的图:信封是典型的横切关注点。三个候选位置:
- 写进每个端点——侵入式,抄 30 遍,最先排除;
- 前端拦截器统一包——治标不治本:多个前端(Web、小程序、第三方调用方)就得各包一遍,且错误格式已经在后端统一了,成功格式却靠前端补,不对称;
- 中间件层统一包——后端出口唯一收口,所有调用方拿到的格式天然一致。
再配合 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) |
自测三问:
- 三个中间件按 A、B、C 的顺序执行注册语句,请求先经过谁家?响应最后经过谁家?
- 信封中间件里,为什么读完
body_iterator之后就不能再把原响应返回了?content-length不删会发生什么? - 前端 fetch 能正常收数据,却读不到
X-Process-Time响应头——列出两个可能的原因。
横切关注点的地基打好了:计时、跨域、统一格式,全都不再侵入业务代码。下一章要在「请求进门的必经之路」上再设一道更重要的关卡——你是谁?你的令牌呢?
下一章:11 · 认证与 JWT