Skip to content

第 12 章 · 中间件与 CORS

本章目标:理解中间件在请求/响应生命周期中的位置,学会写自定义中间件、掌握执行顺序规则,并彻底搞懂 CORSMiddleware 的参数与常见踩坑。

12.1 中间件是什么

中间件(Middleware)是一个对每个请求都会生效的函数:它先于任何路径操作拿到请求,又晚于所有路径操作拿到响应。典型用途:日志、计时、鉴权预检、压缩、CORS。

工作流程:

  1. 接收请求;
  2. 可以对请求做预处理;
  3. 通过 call_next(request) 把请求交给后续应用(路由 → 依赖 → 路径操作);
  4. 拿到响应后可以再加工;
  5. 返回响应。

执行时机细节

官方文档明确指出:带 yield 的依赖,其退出代码(yield 之后的部分)在中间件之后运行;后台任务也在所有中间件之后运行。所以不要指望在中间件里观察到"依赖清理已完成"或"后台任务已完成"的状态。

12.2 自定义 HTTP 中间件:请求计时

用装饰器 @app.middleware("http") 定义。经典示例——给响应加上处理耗时头:

python
# 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 多个中间件的执行顺序

这是面试和排障的高频考点。每次添加中间件都像包了一层洋葱:后添加的在最外层

python
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"))

执行顺序是:

text
请求方向:  B → A → 路由处理
响应方向:  路由处理 → A → B

add_middleware(MiddlewareB)add_middleware(MiddlewareA) 之后调用时,B 是最外层,请求先经过 B。这个洋葱模型保证了顺序可预测——把"越早需要拦截"的中间件(如 HTTPS 重定向)放在外层即可。

12.4 纯 ASGI 中间件与内置中间件

装饰器写法本质上是 Starlette 的 BaseHTTPMiddleware 封装,方便但有开销,且无法精细控制原始 scope。更底层的方式是实现一个 ASGI 类中间件,通过 app.add_middleware() 注册:

python
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 导入更方便):

python
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://localhosthttp://localhost:8080 是不同源。

python
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_originsallow_methodsallow_headers 任何一个都不能是 ["*"],否则凭据请求会被浏览器拒绝,必须显式列全;
  • 通配符会牺牲凭据allow_origins=["*"] 只适用于无 Cookie、无 Authorization 头的简单场景;
  • 忘了 expose_headers:服务端确实返回了 X-Process-Time,但浏览器 JS 读不到它——这不是后端 bug,是 CORS 可见性配置问题。

中间件只拦截两类请求:带 OriginAccess-Control-Request-MethodOPTIONS 预检请求(直接应答),以及带 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,最可能的原因是?

🛠️ 动手实践

  1. 给你的项目加两个中间件:一个记录每个请求的方法、路径和状态码;一个统计耗时并输出慢请求日志(超过 500ms 打印警告)。验证两者的打印顺序符合洋葱模型。
  2. 用纯 ASGI 类实现一个"黑名单 IP 中间件",命中的请求直接返回 403(通过 send 手工发送 http.response.start/body),并用 curl 验证。
  3. 写一个最小前端页面(两个不同端口起静态服务),分别测试 allow_credentials=True + ["*"] 的错误配置与正确配置下,带 Cookie 请求的行为差异,把浏览器 Console 报错截图留存。

学完本章,数据要落库了——请进入下一章:数据库集成