《Python编程实战》5.2 路由、中间件与请求上下文

用中间件把日志、请求 ID、耗时统计等横切关注点从业务里抽出来:对比 BaseHTTPMiddleware 与纯 ASGI 两种写法,用 request.state 与 contextvars 传递请求上下文,并实测中间件的洋葱顺序与并发下的上下文隔离。

本节目标:掌握 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

两个观察点:

  1. 洋葱顺序正确:outer 先 enter、最后 exit;inner 夹在中间。
  2. 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 应用结构与依赖注入 · 下一节:生命周期、优雅关闭与健康探针 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时