11 中间件与依赖注入的区别 —— 同一个需求,放中间件还是放依赖?
场景导入:鉴别一个横切关注点该去哪
前面两章分别讲了中间件和依赖注入。它们都能让图书 API 复用横切逻辑——在请求处理链的某个环节插入一段公共代码。但它们的触发时机、可见范围和适用场景截然不同。
一个常见的翻车现场是:把鉴权逻辑写进了中间件,结果连 /health 健康检查接口都被要求带 token——Kubernetes 的 liveness probe 直接挂掉。另一个翻车现场是:把访问日志写进了依赖注入,结果 404 和异常响应在日志里一片空白——因为这些路径根本没走到依赖解析那一步。
本篇用一张全维度对比表把两种机制的区别讲清楚,并给出三条选型原则。读完你能立刻判断:一个新需求,应该挂到中间件链上,还是注册为依赖。
原理解析:触发时机决定了一切
中间件和依赖注入在请求处理链上的位置不同——这是所有区别的根源。
(图注:请求先经过中间件链,路由匹配成功后才进入依赖解析;响应沿相反方向返回。)
关键的时间差在于:中间件在路由匹配之前就已经在执行了。这意味着即使客户端请求了一个不存在的路径(/nonexistent),中间件仍然会触发;而依赖注入只在路由匹配成功后、handler 执行前才解析。
用一张流程图可以更清楚地看到两者的"势力范围":
(图注:中间件覆盖整个 ASGI 调用栈(包括 404 和异常响应),依赖注入只覆盖路由匹配之后的部分。)
全维度对比
下表覆盖了实际选型需要考虑的每个维度:
三条选型原则
从这张表可以归纳出三条原则,日常开发中几乎能覆盖所有场景:
原则一:协议级需求走中间件。 任何与 HTTP 协议本身相关的横切关注点——响应头注入、CORS、压缩、访问日志——都应该放中间件。因为它们对所有 ASGI 请求生效,与具体端点无关,即使路由匹配失败也需要记录和响应。
原则二:业务级需求走依赖。 分页、当前用户、数据库会话等只在部分端点需要的对象,应该放依赖。这样不需要这些对象的端点(如 /health)就不会被拖入解析链,也不用在中间件层做"哪些路径需要鉴权"的白名单维护。
原则三:跨层数据通过 request.scope 传递。 请求 ID、追踪标识等需要贯穿全链路的字段,先在中间件写入 request.scope 或 request.state,再由依赖按需读取并附加到日志或业务字段。这样中间件保持"无业务知识",依赖保持"无协议知识",各管各的。
下面用一个具体的代码示例展示依赖注入在路由中的常见写法——分页、当前用户、数据库会话三类依赖同时出现在一个 handler 签名中:
# app/api/books.py
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.db import get_db
class Pagination(BaseModel):
page: int = Field(1, ge=1)
size: int = Field(20, ge=1, le=100)
def get_pagination(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
) -> Pagination:
"""分页依赖:从查询参数构造 Pagination 对象。"""
return Pagination(page=page, size=size)
def get_current_user() -> dict[str, object]:
"""当前用户依赖的最小定义,生产环境应校验令牌。"""
return {"name": "demo"}
router = APIRouter(prefix="/books", tags=["books"])
PaginationDep = Annotated[Pagination, Depends(get_pagination)]
UserDep = Annotated[dict[str, object], Depends(get_current_user)]
DBDep = Annotated[Session, Depends(get_db)]
@router.get("")
async def list_books(
pagination: PaginationDep,
user: UserDep,
db: DBDep,
) -> dict[str, object]:
return {
"page": pagination.page,
"size": pagination.size,
"user": user["name"],
"items": [],
}
list_books 同时声明了分页、用户和会话三类依赖。如果请求路径是 /unknown,中间件仍然记录访问日志,但 get_pagination、get_current_user、get_db 都不会被调用——路由匹配失败的请求根本走不到依赖解析。这就是"可见范围"差异的最直观体现。
避坑指南
不要把同样的逻辑注册两次。鉴权拆成"中间件做全站拦截 + 依赖做端点级权限校验"是合理的,但把同一个 token 解析在中间件和依赖里各做一遍就是浪费。明确分工:中间件负责全局放行检查,依赖负责"当前用户"的具体权限。
中间件改写响应体后,
response_model可能再次校验。如果中间件改了响应体结构,而路由层声明了response_model,框架会按模型再次校验——可能产生双序列化或不匹配。建议中间件只改写响应头,响应体由 handler 和response_model统一控制。分散注册会让顺序和可见范围难以推断。中间件链和依赖图都应在
main.py或dependencies.py中集中描述并附注释。当项目中出现第五个中间件和第十个依赖时,集中管理的好处会非常明显。
面试 QA
Q1 [原理]: 中间件与依赖注入的本质区别是什么?
中间件作用于 ASGI 调用栈的请求-响应全链路,横切所有路由与异常响应;依赖注入作用于 handler 的形参,只在路由匹配成功并进入 handler 时才执行。
展开来看:中间件在请求进入路由之前与响应离开路由之后生效,可以观察和改写请求体、响应头、响应体与异常响应;依赖注入由 solve_dependencies 在请求期按拓扑序解析,只能影响 handler 及其子依赖。中间件的设计前提是"对所有请求生效",适合协议级横切;依赖注入的设计前提是"对部分端点生效",适合业务级复用。
源码层面,中间件栈由 FastAPI.build_middleware_stack 在启动期拼装,依赖图由 fastapi/dependencies/utils.py 的 solve_dependencies 在请求期解析。两者由不同模块负责,但共享 request.scope 作为上下文传递通道。
Q2 [项目]: 图书 API 中哪些横切关注点应该放入中间件,哪些应该放入依赖?
协议级和全站级 → 中间件。业务级和按端点可控的 → 依赖。两者通过 request.scope 协同。
具体来说:访问日志、请求 ID、响应压缩、CORS、安全响应头——这些都是"对所有 ASGI 请求生效"的协议级关注点,放中间件可以一次性覆盖路由、异常处理器和 404 路径。当前用户、数据库会话、分页参数、特性开关——这些都是"只在特定端点生效"的业务级关注点,放依赖可以让 /health 这类端点完全跳过相关解析。
协同方式:中间件写入 request.state.request_id,依赖读取它并附加到业务日志。鉴权可以拆为两步——中间件完成全局 token 解析和放行检查,依赖完成端点级权限校验。日志指标也应区分两层:中间件层关注状态码、耗时和请求 ID,依赖层关注业务字段——两端结合构成完整的请求画像。
小结
本篇用一张全维度对比表 + 三条选型原则,把中间件和依赖注入的边界画清楚了。记住一句话就够了:协议级走中间件,业务级走依赖。两者不是竞争关系,而是协作关系——中间件提供请求级维度(日志、计时、ID),依赖提供业务级维度(用户、会话、分页),通过 request.scope 传递上下文。
下一篇《12 ORM 介绍》将进入数据层,从关系型数据库的基础概念开始,讨论 FastAPI 项目中的数据持久化思路——原生 SQL 和 ORM 各自的适用场景是什么。