本节目标:掌握 FastAPI / Starlette 的中间件模型与洋葱执行顺序,会用
BaseHTTPMiddleware与纯 ASGI 两种方式写中间件,能用request.state和contextvars把 request_id 贯穿整条调用链,并让日志自动带上它。
适用版本:Python 3.12+(实测 3.14.6);FastAPI 0.143.0、Starlette 1.7.0、httpx 0.28.1
5.2 路由、中间件与请求上下文
5.1 节把应用拆成了 api / service / repository 三层,每个请求从进门到出门都走这条路。但有一类逻辑不属于任何一层——日志、请求 ID、耗时统计、跨域、鉴权预检。它们横切所有请求,塞进路由会到处重复,塞进 service 又污染业务。中间件就是为它们准备的。
5.2.1 中间件是洋葱
Starlette 的中间件按「洋葱模型」层层包裹:请求从外往里穿透,响应从里往外返回。每个中间件都能在进入时和退出时各做一件事。
请求 → [外层中间件] → [内层中间件] → 路由/端点
响应 ← [外层中间件] ← [内层中间件] ← 路由/端点
这决定了中间件的能力边界:进入时能做鉴权、限流、注入上下文;退出时能改响应头、记录耗时、捕获异常。想同时做两件事,就在一个中间件里写「前半段 + await call_next + 后半段」。
5.2.2 两种写法:BaseHTTPMiddleware 与纯 ASGI
Starlette 提供两种中间件形态,各有取舍。
其一,BaseHTTPMiddleware:继承它、实现 async def dispatch(request, call_next),可读性最好:
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
class TimingMiddleware(BaseHTTPMiddleware):
def __init__(self, app, name: str) -> None:
super().__init__(app)
self.name = name
async def dispatch(self, request: Request, call_next):
print(f"[{self.name}] enter", flush=True)
response = await call_next(request)
print(f"[{self.name}] exit", flush=True)
return response
其二,纯 ASGI 中间件:就是一个 async def __call__(self, scope, receive, send) 的可调用对象,直接操作 ASGI 三元组,没有额外开销:
class RequestContextMiddleware:
def __init__(self, app) -> None:
self.app = app
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http": # 放行 lifespan / websocket
await self.app(scope, receive, send)
return
request_id = uuid.uuid4().hex[:8]
scope.setdefault("state", {})
scope["state"]["request_id"] = request_id
async def send_wrapper(message):
if message["type"] == "http.response.start":
message["headers"].append((b"x-request-id", request_id.encode()))
await send(message)
await self.app(scope, receive, send_wrapper)
怎么选?需要读取/改写响应体(如统一响应包装)才用 BaseHTTPMiddleware;只关心头、状态码、上下文注入时,纯 ASGI 更快也更能精确控制——因为 BaseHTTPMiddleware 会在内部用任务组把请求跑成一个独立任务,有额外调度成本,且会改变 contextvars 的可见性(见 5.2.5)。
5.2.3 request.state:请求级小仓库
每个请求都有一块自己的便签,叫 request.state。中间件写进去,端点和依赖读出来,天然按请求隔离。上面的纯 ASGI 中间件已经用 scope["state"]["request_id"] 写了进去,Starlette 会把它暴露成 request.state:
from fastapi import FastAPI, Request
app = FastAPI()
@app.get("/whoami")
def whoami(request: Request) -> dict[str, str]:
return {"from_state": request.state.request_id}
request.state 适合放「这个请求内到处都要用、但又不值得层层传参」的东西:当前用户、租户 ID、解析好的 token、request_id。它不是全局变量——每个请求一份,互不干扰。
5.2.4 contextvars:让日志自动带上 request_id
request.state 有个局限:只能通过 request 对象访问。可日志、service、repository 里未必拿得到 request。这时用 contextvars——它为每个协程/任务维护一份独立的变量副本:
from contextvars import ContextVar
request_id_ctx: ContextVar[str] = ContextVar("request_id", default="-")
中间件在入口 set,在出口 reset(必须 reset,否则上下文会残留):
token = request_id_ctx.set(request_id)
try:
await self.app(scope, receive, send_wrapper)
finally:
request_id_ctx.reset(token)
这样链路上任何地方(包括第三方库的日志)都能 request_id_ctx.get() 拿到当前请求的 ID,无需一路传参。
5.2.5 日志自动带 ID:一个 Filter 搞定
把 contextvar 接进标准库 logging,只需一个 Filter:
import logging
from app.context import request_id_ctx
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
record.request_id = request_id_ctx.get()
return True
def configure_logging() -> None:
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter("%(levelname)s rid=%(request_id)s %(name)s: %(message)s")
)
handler.addFilter(RequestIdFilter())
root = logging.getLogger()
root.handlers[:] = [handler]
root.setLevel(logging.INFO)
配置后,任何一条日志都会自动带上 rid=,排查线上问题时按 rid grep 即可拉出整条调用链。这比在每个函数里手动传 request_id 参数干净得多。
5.2.6 实测:中间件顺序与请求 ID 贯通
把三个中间件装上去——注意 add_middleware 后加的在外层:
app.add_middleware(TimingMiddleware, name="inner")
app.add_middleware(TimingMiddleware, name="outer")
app.add_middleware(RequestContextMiddleware) # 最外层:最先设好 request_id
发一个请求,真实日志(来自本机实测):
INFO rid=681bfc4c app: [outer] enter
INFO rid=681bfc4c app: [inner] enter
INFO rid=681bfc4c app: [inner] exit cost=3.60ms
INFO rid=681bfc4c app: [outer] exit cost=3.97ms
两个观察点:
- 洋葱顺序正确:outer 先 enter、最后 exit;inner 夹在中间。
- request_id 贯通全链路:四条日志的
rid完全一致,说明 contextvar 从最外层设好后,一路可见。
响应侧同样带上了 ID 与耗时头:
body: {'from_state': '681bfc4c', 'from_contextvar': '681bfc4c'}
x-request-id: 681bfc4c
x-timing-ms: 3.97
request.state 与 contextvar 两条路取到的 ID 一致——它们本就是同一个值。
顺序为什么关键? 如果把 RequestContextMiddleware 放在 TimingMiddleware 里面,那么 outer 的 enter/exit 日志会因为 contextvar 尚未设置(或已被 reset)而显示 rid=-。中间件的顺序不是风格问题,是正确性问题:注入上下文的中间件必须在外层。
5.2.7 实测:并发下 contextvar 不串号
contextvars 的核心承诺是「每个任务一份副本」。用一个慢端点验证——它在 await 前后各读一次 ID,await 期间事件循环会切去处理别的请求:
@app.get("/slow")
async def slow() -> dict[str, object]:
from app.context import request_id_ctx
before = request_id_ctx.get()
await asyncio.sleep(0.05) # 让出事件循环,其它请求插队
after = request_id_ctx.get()
return {"before": before, "after": after, "same": before == after}
用 httpx.AsyncClient 并发打 5 个请求,真实结果:
c70c1f80 {'before': 'c70c1f80', 'after': 'c70c1f80', 'same': True}
94f1c433 {'before': '94f1c433', 'after': '94f1c433', 'same': True}
5060b83d {'before': '5060b83d', 'after': '5060b83d', 'same': True}
295af48d {'before': '295af48d', 'after': '295af48d', 'same': True}
fd6c8e56 {'before': 'fd6c8e56', 'after': 'fd6c8e56', 'same': True}
5 个请求各有各的 ID,await 前后不串号。这正是 contextvars 相对「模块级全局变量」的价值——后者在并发下会被别的请求覆盖,是线上最难查的一类 bug。
5.2.8 一个真实的坑:BaseHTTPMiddleware 与 contextvars
需要留意:BaseHTTPMiddleware 在 call_next 内部用任务组把下游跑成独立任务,任务切换时 contextvars 会复制。这意味着下游中间件/端点里 set 的 contextvar,不一定能传回 BaseHTTPMiddleware 的 dispatch 里。上面之所以把纯 ASGI 的 RequestContextMiddleware 放在最外层,正是为了绕开这个边界——注入上下文尽量用纯 ASGI 中间件,且放在最外层。
如果某天发现「日志里的 rid 时有时无」,八成就是中间件顺序或这个任务边界在作祟。排查手段很朴素:在每个中间件入口打一行日志,看 rid 从哪一层开始变空。
5.2.9 中间件与依赖:谁该干哪件事
中间件和 Depends 都能「在端点执行前做点什么」,新手常混用。判据是作用域:
| 维度 | 中间件 | 依赖(Depends) |
|---|---|---|
| 生效范围 | 所有请求(或按路径前缀) | 仅声明了它的那个端点/路由 |
| 拿得到什么 | 原始 ASGI scope、Request | 已解析的参数、其它依赖 |
| 能否改响应 | 能(改头、改体、改状态码) | 不能直接改响应 |
| 典型用途 | 日志、request_id、CORS、限流、GZip | 当前用户、数据库会话、分页参数 |
经验法则:「每个请求都要做、且与业务无关」→ 中间件;「这个接口需要」→ 依赖。比如鉴权,通常拆成两半——中间件负责解析并注入身份,依赖负责「这个接口要求哪种权限」的细粒度判断。
另外注意中间件不能访问路由参数:在 dispatch 里拿不到 item_id,因为路由匹配发生在中间件之内。需要路径相关逻辑时,依赖或路由本身才是正确的位置。
小结
- 中间件是洋葱:进入做鉴权/注入,退出改响应/记耗时;
add_middleware后加的在外层。 BaseHTTPMiddleware可读性最好但有额外调度开销,还会改变 contextvars 可见性;纯 ASGI 中间件更轻、控制更精确,适合注入上下文。request.state是请求级便签,适合放当前用户、租户、token 这类「随手可取」的数据。contextvars让日志、service、第三方库都能读到 request_id;用set/reset配对,接入logging.Filter后日志自动带rid。- 中间件顺序是正确性问题:注入上下文的中间件必须放最外层,否则日志里会出现空的 rid。
- 并发实测证明 contextvar 在
await前后不串号——这是它相对全局变量的核心价值。
到这里,请求进得来、日志看得见、上下文传得通。但进程本身的「生老病死」还没管:启动时该建哪些资源、关闭时如何让在途请求体面收尾、容器探针怎么答。下一节讲 lifespan、优雅关闭与健康探针。
延伸阅读:Python Web 框架全景 、Python 微服务架构 。
阅读导航:上一节:FastAPI 应用结构与依赖注入 · 下一节:生命周期、优雅关闭与健康探针 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。