中间件是包裹整个应用的洋葱圈结构:请求进入时逐层穿过中间件链到达路由,响应返回时逆序穿回。CORS 头注入、响应压缩、访问日志、耗时统计这些"每个请求都要做、但不属于任何具体接口"的逻辑,都住在这一层。本节讲清 FastAPI 的两种中间件写法与执行顺序,配置 CORS 与 GZip 两个内置件,并与 Flask 的钩子体系做迁移对照。
阅读完本节,你应当能够:
函数式中间件的形态:
import time from fastapi import Request @app.middleware("http") async def add_timing(request: Request, call_next): start = time.perf_counter() response = await call_next(request) cost = (time.perf_counter() - start) * 1000 response.headers["X-Process-Time"] = f"{cost:.1f}ms" return response
call_next 是洋葱圈的芯:调用它意味着"把请求交给内层",await 它拿到的是内层产出的响应。call_next 之前是请求方向的逻辑(改请求头、拒 IP),之后是响应方向的逻辑(加响应头、记日志)。这段耗时统计中间件是第一个值得亲手写的例子——五分钟就能给所有接口装上耗时头,排错时极好用。
执行顺序的规则要背下来:注册得越早,位置越靠外。先 add_middleware(A) 再 add_middleware(B),请求是 A → B → 路由,响应是路由 → B → A。装饰器写法等价于把中间件加在最外层(它最后注册却包在最外面——因为装饰器始终把新中间件套在已有链外)。排错口诀:请求头相关的逻辑想最先执行就最晚注册。这与 Express 的 app.use 顺序、Django 的 MIDDLEWARE 列表顺序语义一致,从 Node 迁来的工程师可以无缝复用心智。
类式写法(继承 BaseHTTPMiddleware)适合需要状态或复杂配置的中间件,结构等价,此处不展开。需要了解的边界是:BaseHTTPMiddleware 会改变流式响应的某些行为(历史上对后台任务与流式传输的交互有坑),纯 ASGI 风格的原始签名中间件性能与兼容性最好但写法门槛高——日常用函数式,遇到流式响应的怪异问题时查一下中间件实现方式。
跨域是前后端分离后的第一堵墙。FastAPI 的标准配置:
from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["https://app.example.com"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], )
五个参数各有语义:origins 是放行的来源列表(协议加域名加端口,缺一不可);credentials 放行 Cookie 与认证头——开启它之后 origins 不能再用星号,这是规范层的互斥,浏览器会直接拒绝;methods 与 headers 控制预检的放行范围。
排错时先分清两类失败。浏览器控制台报"未被 CORS 策略允许"且响应里没有 access-control-allow-origin 头——配置没命中:来源拼写不一致(多了斜杠、http 与 https 混用)是头号原因。 OPTIONS 请求返回 405 或 415——预检请求被路由层拒绝,通常是全局依赖或认证中间件没放行 OPTIONS 方法。第二类坑在"给 CORS 中间件内层又套了认证"时高发:中间件顺序让认证先于 CORS 执行,预检请求带着不存在的认证头进来被拒。解法是保证 CORS 在认证之外——按注册顺序规则调整即可。
Flask 世界的 flask-cors 扩展能力等价(origins、supports_credentials 等参数一一对应),迁移成本主要在把装饰器式配置改为集中式 add_middleware,反而更清爽。
压缩是个"两处都能做"的经典问题:
from fastapi.middleware.gzip import GZipMiddleware app.add_middleware(GZipMiddleware, minimum_size=1000)
minimum_size 设 1000 意味着小于 1KB 的响应不压——压缩开销可能超过收益。但生产环境更常见的架构是 Nginx 在前做压缩(gzip on 加类型配置),此时应用层再压就是重复劳动:CPU 白烧一遍,Accept-Encoding 的处理逻辑走两道。同一种横切关注点只该在一个层面做——有反向代理的架构把压缩交给代理,应用层中间件留给开发环境与无代理的小部署。这个判断原则适用于一大类中间件:限流在网关层还是应用层、缓存在 CDN 还是进程内,问的都是同一个问题"这个层面看得见完成这件事所需的全部信息吗"。
Flask 的横切机制是请求生命周期钩子对:
@app.before_request def start_timer(): g.start = time.perf_counter() @app.after_request def add_header(response): response.headers["X-Process-Time"] = ... return response
FastAPI 的等价物就是前面那段耗时中间件。差异在结构:钩子对是"两个函数共享 g 对象上的状态",中间件是"一个函数里 start 变量的闭包"——后者没有共享可变对象,状态的作用域天然收窄。Flask 的 teardown_request(无论成功失败都执行)对应中间件里 try/finally 包住 call_next 的写法;Flask 的 errorhandler 对应第 4.2 节的异常处理器而非中间件。
| 横切需求 | Flask | FastAPI |
|---|---|---|
| 请求前后逻辑 | before/after_request | 中间件 call_next 前后 |
| 无条件清理 | teardown_request | 中间件 try/finally 或依赖 yield 清理段 |
| 跨域 | flask-cors 扩展 | CORSMiddleware |
| 压缩 | flask-compress | GZipMiddleware 或反向代理 |
| 错误格式化 | errorhandler | exception_handler |
一个取舍提醒:能用依赖注入解决的就别上中间件。中间件包裹一切请求(包括静态文件与文档页),影响面是全局的;依赖只作用于声明它的路由,粒度精细。认证这类"部分接口需要"的逻辑用依赖(第 5 章),CORS 与耗时这类"所有请求需要"的逻辑用中间件——按影响面选机制,是两个系统并存的判断标准。
把本节知识合成一件实用工具。需求:每个请求记一行结构化日志——方法、路径、状态码、耗时、请求 id。请求 id 还要回填响应头,方便用户报障时直接提供。
import logging import time import uuid logger = logging.getLogger("access") @app.middleware("http") async def access_log(request: Request, call_next): request_id = request.headers.get("X-Request-Id") or uuid.uuid4().hex[:12] start = time.perf_counter() try: response = await call_next(request) except Exception: cost = (time.perf_counter() - start) * 1000 logger.exception("req failed", extra={ "request_id": request_id, "method": request.method, "path": request.url.path, "cost_ms": round(cost, 1), }) raise cost = (time.perf_counter() - start) * 1000 response.headers["X-Request-Id"] = request_id logger.info("req done", extra={ "request_id": request_id, "method": request.method, "path": request.url.path, "status": response.status_code, "cost_ms": round(cost, 1), }) return response
四个实现细节对应四条前文结论:请求 id 优先沿用客户端带来的值(分布式链路的常见约定),没有才生成——call_next 之前的逻辑可以读取并改写请求上下文;异常分支单独记日志再 raise,保证失败请求也有访问记录且异常继续走处理器通道(第 4.2 节的分工);extra 的结构化字段让日志可被日志系统索引(比拼字符串好用得多);耗时统计用 perf_counter 计毫秒,与本章开头那个示例同一模式。
这个中间件与 Flask 的钩子对写法对照着读一遍,两种范式的差异全部具象化:Flask 版两个函数、g 对象传状态、teardown 兜异常;FastAPI 版一个函数、闭包传状态、try 兜异常。逻辑完全相同,组织方式不同——后者把"这对钩子"合并成一个可整体阅读、整体测试的单元,这正是洋葱圈模型相对于钩子对的结构优势。

**问题:中间件和异常处理器的执行顺序?**异常先被处理器翻译成响应,响应再穿中间件链返回——所以中间件的响应侧逻辑能看到错误响应(比如给 500 响应也加上耗时头)。想拦截异常本身(在处理器之前),只能在中间件的调用内层捕获——可行但通常多余,处理器已经是异常的汇聚点。
**问题:能写多个自定义中间件吗?顺序怎么定?**任意多个,顺序按注册反序包裹(正文规则)。实用组合的典型排布:最外层访问日志(看全貌)、其次 CORS(保证预检早于认证)、再次限流、最内 GZip(压缩最终产物)。这个顺序不是铁律,但日志最外、压缩最内两端固定是常见共识——前者要看到一切,后者要处理成品。
**问题:中间件里能用到依赖注入的资源吗?**不能直接用——中间件在依赖解析之前运行,拿不到按请求构造的会话与用户。中间件的世界里只有 Request 与 Response 的原始形态。需要业务资源的横切逻辑(比如按用户配额限流)用中间件加内部手动调用服务实现,或干脆改成依赖——又回到按影响面选机制的判断。
**问题:中间件拖慢了所有请求怎么办?**先测——加一层中间件在热路径上多几毫秒很正常,关键是总量。真有开销大的(外部调用的风控检查),考虑降级为异步触发(先放行后核对)或移到依赖层只作用于需要的路由。中间件的全局属性决定了它的单位成本被每个请求放大,写的时候要有这份自觉。
正常路径与横切层齐备,第 5 章进入生产三件套:安全认证、后台任务与测试体系。
中间件体系的两条边界。其一,数量边界:中间件是全局税,每加一层所有请求都付成本——务实上限是五层左右(日志、跨域、压缩、限流、一个自定义),超出时审视是否有该下沉到网关或上移到依赖的逻辑。其二,职责边界:中间件不该理解业务——它看得见请求与响应的原始形态,看不见用户与订单;一旦中间件里出现业务词汇(按订单类型改响应),就是越界的信号,该逻辑属于依赖或路由。守住两条边界,中间件层会保持"薄、快、可预测"的健康形态,成为整个服务可靠的透明外壳。
从 Flask 迁移的读者还剩最后一个心结要解:flask 钩子生态里的很多扩展(请求计数、灰度标记)在 FastAPI 里的对应物不是中间件而是依赖——影响面判断(正文反复出现的那把尺子)在迁移时比语法对照表更重要。语法可以查文档,判断力才是迁移顺利度的真正变量。
给访问日志中间件做两个扩展练习。练习一:加上"慢请求标记"——耗时超过阈值(比如 500 毫秒)时日志级别升为警告,观察慢请求在日志流里的显形速度,这个阈值日后就是性能观测的第一道哨兵。练习二:加请求体大小记录——从请求头读长度字段记进日志,配合第 7 章的容量观测,能提前发现异常大的请求(爬虫、误用、攻击)。两个扩展各十几行,但它们让这个中间件从"练习品"变成"生产件"——好的中间件都是这样在基础版上按需长出来的,一次性设计完美中间件的尝试通常以过度工程收场。
当你能默画出请求进出洋葱圈的完整路径时,本节的目标就达成了,剩下的只是熟练度问题。
再看一眼那张洋葱圈图,然后合上它试着画一遍——能凭记忆复现的架构理解,才是真正内化了的理解。