第 12 章 · 中间件与 CORS
本章目标:理解中间件在请求/响应生命周期中的位置,学会写自定义中间件、掌握执行顺序规则,并彻底搞懂
CORSMiddleware的参数与常见踩坑。
12.1 中间件是什么
中间件(Middleware)是一个对每个请求都会生效的函数:它先于任何路径操作拿到请求,又晚于所有路径操作拿到响应。典型用途:日志、计时、鉴权预检、压缩、CORS。
工作流程:
- 接收请求;
- 可以对请求做预处理;
- 通过
call_next(request)把请求交给后续应用(路由 → 依赖 → 路径操作); - 拿到响应后可以再加工;
- 返回响应。
执行时机细节
官方文档明确指出:带 yield 的依赖,其退出代码(yield 之后的部分)在中间件之后运行;后台任务也在所有中间件之后运行。所以不要指望在中间件里观察到"依赖清理已完成"或"后台任务已完成"的状态。
12.2 自定义 HTTP 中间件:请求计时
用装饰器 @app.middleware("http") 定义。经典示例——给响应加上处理耗时头:
# main.py
import time
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
# call_next 之前 = 请求阶段(进入路径操作之前)
start = time.perf_counter()
response = await call_next(request)
# call_next 之后 = 响应阶段
process_time = time.perf_counter() - start
# 官方建议用 perf_counter(),比 time.time() 计时精度更高
response.headers["X-Process-Time"] = str(process_time)
return response
@app.get("/items/")
async def list_items():
return [{"name": "Foo"}]注意两点:
call_next是协程,必须await;- 自定义响应头若想让浏览器端 JS 可读,需要带
X-前缀,并且要在 CORS 配置的expose_headers里声明(见本章 12.5)。
12.3 多个中间件的执行顺序
这是面试和排障的高频考点。每次添加中间件都像包了一层洋葱:后添加的在最外层。
from fastapi import FastAPI, Request
app = FastAPI()
def log_middleware(name: str):
async def wrap(request: Request, call_next):
print(f"[{name}] request ->")
resp = await call_next(request)
print(f"[{name}] response <-")
return resp
return wrap
app.add_middleware(log_middleware("A"))
app.add_middleware(log_middleware("B"))执行顺序是:
请求方向: B → A → 路由处理
响应方向: 路由处理 → A → B即 add_middleware(MiddlewareB) 在 add_middleware(MiddlewareA) 之后调用时,B 是最外层,请求先经过 B。这个洋葱模型保证了顺序可预测——把"越早需要拦截"的中间件(如 HTTPS 重定向)放在外层即可。
12.4 纯 ASGI 中间件与内置中间件
装饰器写法本质上是 Starlette 的 BaseHTTPMiddleware 封装,方便但有开销,且无法精细控制原始 scope。更底层的方式是实现一个 ASGI 类中间件,通过 app.add_middleware() 注册:
from collections.abc import Awaitable, Callable
from typing import Any
from fastapi import FastAPI
app = FastAPI()
class RawContextMiddleware:
"""纯 ASGI 中间件:直接操作 scope / receive / send"""
def __init__(self, app: Callable, header: str = "X-Tenant"):
self.app = app
self.header = header
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
if scope["type"] == "http": # 只处理 HTTP,放过 lifespan/websocket
headers = dict(scope["headers"])
tenant = headers.get(self.header.lower().encode(), b"default")
async def send_wrapper(message: Any) -> None:
if message["type"] == "http.response.start":
message["headers"].append((b"x-tenant", tenant))
await send(message)
await self.app(scope, receive, send_wrapper)
else:
await self.app(scope, receive, send)
app.add_middleware(RawContextMiddleware, header="X-Tenant")FastAPI 还内置了几个开箱即用的中间件(均来自 Starlette,从 fastapi.middleware 导入更方便):
from fastapi import FastAPI
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
app = FastAPI()
# 所有 http/ws 请求强制重定向到 https/wss(一般放在反向代理后面才启用)
app.add_middleware(HTTPSRedirectMiddleware)
# 防 Host 头攻击:只允许这些域名访问,否则返回 400
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["example.com", "*.example.com"])
# 对 Accept-Encoding 含 gzip 的请求自动 GZip 压缩(超过 minimum_size 字节才压)
app.add_middleware(GZipMiddleware, minimum_size=1000)什么时候不用装饰器写法
BaseHTTPMiddleware 会为每个请求创建额外的任务与流封装,高并发下有可测的性能损耗;另外它对流式响应的处理有历史坑。性能敏感或需要操作原始 ASGI 事件时,用类写法。
12.5 CORSMiddleware 参数详解与踩坑
浏览器里跑的前端 JS 想请求"不同源"的后端时,浏览器会先发一个 OPTIONS 预检请求。后端必须返回正确的 CORS 头,浏览器才会放行。同源的判定包含协议 + 域名 + 端口三者,http://localhost 和 http://localhost:8080 是不同源。
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
origins = [
"http://localhost:8080",
"https://www.example.org",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins, # 明确列出允许的来源,别用 ["*"] 一劳永逸
allow_credentials=True, # 允许携带 Cookie / Authorization 头
allow_methods=["GET", "POST", "PUT", "DELETE"], # 或 ["*"]
allow_headers=["Authorization", "Content-Type"], # 或 ["*"]
expose_headers=["X-Process-Time"], # 让前端 JS 能读到自定义响应头
max_age=600, # 预检结果缓存秒数
)三个最常见的坑:
allow_credentials=True时不能用通配符:allow_origins、allow_methods、allow_headers任何一个都不能是["*"],否则凭据请求会被浏览器拒绝,必须显式列全;- 通配符会牺牲凭据:
allow_origins=["*"]只适用于无 Cookie、无 Authorization 头的简单场景; - 忘了
expose_headers:服务端确实返回了X-Process-Time,但浏览器 JS 读不到它——这不是后端 bug,是 CORS 可见性配置问题。
中间件只拦截两类请求:带 Origin 和 Access-Control-Request-Method 的 OPTIONS 预检请求(直接应答),以及带 Origin 头的普通请求(放行并在响应上追加 CORS 头)。
12.6 本章小结
- 中间件包裹整个应用:请求先进、响应最后出,
@app.middleware("http")最常用; - 后添加的中间件在最外层,请求最先经过它;
- 带
yield的依赖清理代码和后台任务的执行都在中间件之后; - 性能敏感或需操作原始 ASGI 事件时用类式中间件 +
add_middleware(); CORSMiddleware凭据模式禁止通配符,expose_headers决定浏览器 JS 能否读到自定义头。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. 依次执行 app.add_middleware(A) 和 app.add_middleware(B) 后,请求经过的中间件顺序是?
2. 关于带 yield 依赖的退出代码与后台任务,下列说法正确的是?
3. 设置了 allow_credentials=True 后,以下哪种 CORS 配置是正确的?
4. 后端通过自定义中间件返回了 X-Process-Time 头,但浏览器里的 JS 读到的值是 undefined,最可能的原因是?
🛠️ 动手实践
- 给你的项目加两个中间件:一个记录每个请求的方法、路径和状态码;一个统计耗时并输出慢请求日志(超过 500ms 打印警告)。验证两者的打印顺序符合洋葱模型。
- 用纯 ASGI 类实现一个"黑名单 IP 中间件",命中的请求直接返回 403(通过
send手工发送http.response.start/body),并用 curl 验证。 - 写一个最小前端页面(两个不同端口起静态服务),分别测试
allow_credentials=True + ["*"]的错误配置与正确配置下,带 Cookie 请求的行为差异,把浏览器 Console 报错截图留存。
学完本章,数据要落库了——请进入下一章:数据库集成。