依赖注入:FastAPI最核心的解耦能力案例解析

  在FastAPI后端开发里,依赖注入 是最核心、也最容易被低估的能力之一。很多开发者习惯在每个路由函数里重复写鉴权、打日志、开数据库连接、解密参数。接口越写越胖,改一处要动一片,单元测试也几乎没法写。FastAPI的Depends()机制,正是为了把这类横切关注点从业务逻辑里剥离出来。

  依赖注入的本质可以用一句话概括:把怎么拿到某个对象抽成独立函数,路由只声明我需要什么 。框架会在进入路由之前自动解析依赖树、按顺序执行、在同一请求内缓存结果,并把依赖里用到的QueryHeader等参数写进Swagger文档。相比Flask里装饰器加g对象的写法,FastAPI的依赖注入在组合性、可测试性和类型安全上都更成熟。下文会从最基础的分页依赖讲起,逐步走到类依赖、子依赖链、全局与局部挂载、缓存与异步,最后用鉴权、日志、解密、数据库会话四个场景串起来,并与Flask装饰器方案对照说明差异。


一、依赖注入基础:从函数依赖开始

什么是依赖注入

  先看一种很常见的写法:每个接口都自己从request里抠分页参数、自己校验Token,业务代码被挤到函数末尾。

python 复制代码
@app.get("/users")
async def list_users(request: Request):
    page = int(request.query_params.get("page", 1))
    page_size = int(request.query_params.get("page_size", 10))
    token = request.headers.get("Authorization", "").replace("Bearer ", "")
    if token not in VALID_TOKENS:
        raise HTTPException(401)
    # 业务逻辑从这里才开始......

  问题在于:分页和鉴权与列出用户毫无关系,却在每个路由里复制粘贴。依赖注入的改法,是把解析分页解析当前用户各自写成独立函数,路由参数里用Depends(...)声明需求即可。

python 复制代码
@app.get("/users")
async def list_users(
    pagination: Pagination = Depends(get_pagination),
    user: UserInfo = Depends(get_current_user),
):
    # pagination和user已经就绪,直接写业务
    ...

  FastAPI看到Depends(get_pagination)时,会先调用get_pagination,把返回值赋给pagination,再进入路由函数体。路由函数不再碰Request的原始字段,签名本身也变成了文档:一眼能看出这个接口需要分页信息和登录用户。

基础函数依赖:分页与请求ID

  任何可调用对象都可以当依赖,最常用的是普通函数。下面这个get_pagination把分页规则集中在一处:page默认1且不小于1,page_size默认10、范围1~100,并顺带算出SQL常用的offset

python 复制代码
def get_pagination(
    page: int = Query(default=1, ge=1),
    page_size: int = Query(default=10, ge=1, le=100),
) -> Pagination:
    return Pagination(
        page=page,
        page_size=page_size,
        offset=(page - 1) * page_size,
    )

  注意依赖函数参数上的Query(...):它们不是路由函数的参数,却会被FastAPI同样解析。也就是说,谁声明Depends(get_pagination),谁就自动拥有pagepage_size两个查询参数,Swagger也会展示出来,无需额外配置。 路由侧只需一行声明:

python 复制代码
@app.get("/demo/basic-dep")
async def demo_basic_dep(
    pagination: Pagination = Depends(get_pagination),
    request_id: str = Depends(get_request_id),
):
    return {"pagination": pagination.model_dump(), "request_id": request_id}

  get_request_id则从HeaderX-Request-ID读取追踪ID,客户端没传时用uuid生成一个。两个依赖互不干扰,FastAPI按各自需要的参数从请求里取值。

接口调用后返回信息:   访问GET /demo/basic-dep?page=2&page_size=5时,响应里的pagination.offset应为5(第二页、每页5条,跳过前5条)。若带上HeaderX-Request-ID: trace-abc,响应中的request_id会原样返回;不带则得到自动生成的UUID。调用返回:

json 复制代码
{"pagination":{"page":2,"page_size":5,"offset":5},"request_id":"e17a6ecc-5fe9-4001-b021-b7e6eaf8c219"}

二、类依赖与子依赖

类依赖:可配置的限流策略

  有时依赖本身需要构造参数------比如A接口30秒内最多3次,B接口60秒内最多100次。把限流写成一个类,实现__call__,实例就可以当作依赖使用:

python 复制代码
class RateLimiter:
    def __init__(self, max_calls: int = 5, window_seconds: float = 60.0):
        self.max_calls = max_calls
        self.window_seconds = window_seconds
        self._calls: list[float] = []

    def __call__(self, request: Request) -> None:
        now = time.time()
        # 清掉滑动窗口外的旧记录
        self._calls = [t for t in self._calls if now - t < self.window_seconds]
        if len(self._calls) >= self.max_calls:
            raise HTTPException(status_code=429, detail="请求过于频繁,请稍后再试")
        self._calls.append(now)

  rate_limit_strict = RateLimiter(max_calls=3, window_seconds=30)rate_limit_loose = RateLimiter(max_calls=100, window_seconds=60)是同一套逻辑的两个配置实例。路由通过dependencies=[Depends(rate_limit_strict)]挂载,限流失败时在__call__里直接抛429,路由函数甚至不会被执行。

python 复制代码
@app.get("/demo/class-dep")
async def demo_class_dep(_: None = Depends(rate_limit_strict)):
    return {"message": "限流通过"}

  参数写成_: None = Depends(...)是因为我们只需要限流的副作用(检查通过),不需要返回值。连续快速请求同一接口超过3次后,第4次应收到429。如图所示,我连续点击了4次收到了请求频繁提示:

子依赖:鉴权链如何一层层拼装

  依赖可以依赖其他依赖,FastAPI会构建一棵依赖树并按拓扑顺序执行。鉴权是最典型的例子,链路如下:HTTPBearer解析Header-get_token取出Bearer字符串-verify_token查表得到user_id-get_current_user加载用户-require_admin再校验角色。

python 复制代码
bearer_scheme = HTTPBearer(auto_error=False)

def get_token(
    credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> str:
    if credentials is None or credentials.scheme.lower() != "bearer":
        raise HTTPException(status_code=401, detail="缺少 Bearer Token")
    return credentials.credentials

def verify_token(token: str = Depends(get_token)) -> int:
    user_id = _TOKENS.get(token)
    if user_id is None:
        raise HTTPException(status_code=401, detail="Token 无效或已过期")
    return user_id

def get_current_user(user_id: int = Depends(verify_token)) -> UserInfo:
    row = _DB.get(user_id)
    if row is None:
        raise HTTPException(status_code=404, detail="用户不存在")
    return UserInfo(id=row["id"], username=row["username"], role=row["role"])

def require_admin(user: UserInfo = Depends(get_current_user)) -> UserInfo:
    if user.role != "admin":
        raise HTTPException(status_code=403, detail="需要管理员权限")
    return user

  演示用的Token表很简单:admin-token-001对应管理员alice,user-token-002对应普通用户bob。路由若只声明Depends(get_current_user),任意合法Token即可;管理接口改用Depends(require_admin),bob的Token会在最后一环收到403,而不需要在每个admin路由里手写if user.role != "admin"

python 复制代码
@app.get("/demo/sub-dep")
async def demo_sub_dep(user: UserInfo = Depends(get_current_user)):
    return user

  用curl测试时,带上Authorization: Bearer admin-token-001应返回alice的用户信息;不带Header则401。这条链的价值在于:每一环只做一件事,上层依赖下层的结果,组合而不是复制。这里使用APIFox测试:


三、全局依赖与路径局部依赖

  依赖可以挂在不同粒度上,效果范围不同。全局依赖在创建FastAPI()时声明,对该应用下所有路由生效;Router级依赖只影响该模块下的接口;单路由的dependencies=参数则只约束某一个端点。还有一种常见写法是把依赖写在路由参数 里如user: UserInfo = Depends(get_current_user),这时不仅执行依赖逻辑,还把返回值注入函数,适合业务需要用到依赖结果的场景。

  全局计时是一个典型副作用依赖:它不返回值给路由,只在request.state上记开始时间,供后续中间件或日志使用。

python 复制代码
async def global_request_timer(request: Request) -> None:
    request.state.start_time = time.perf_counter()

app = FastAPI(dependencies=[Depends(global_request_timer)])

  secure_router则在Router级别同时挂上日志和鉴权,该前缀下所有接口自动记录请求、自动要求登录,路由函数里一行日志代码都不用写:

python 复制代码
secure_router = APIRouter(
    prefix="/secure",
    dependencies=[Depends(log_request), Depends(get_current_user)],
)

@secure_router.get("/profile")
async def secure_profile(user: UserInfo = Depends(get_current_user)):
    return user

@secure_router.get("/admin/stats")
async def admin_stats(admin: UserInfo = Depends(require_admin)):
    return {"admin": admin.username, "user_count": len(_DB)}

  访问GET /secure/profile需要任意有效Token;GET /secure/admin/stats还需要admin角色。Router级的Depends(get_current_user)保证未登录进不来,参数级的Depends(require_admin)在需要管理员的地方再收紧一层。两种情况有无管理员权限:


四、依赖缓存与异步依赖

同一请求内只算一次

  默认情况下,同一个依赖函数在同一请求里被声明多次,只会执行一次 ,结果被缓存后复用。示例里expensive_sync_service内部有一个计数器,每被调用一次就加一:

python 复制代码
_call_counter = {"expensive_sync": 0}

def expensive_sync_service() -> dict:
    _call_counter["expensive_sync"] += 1
    return {"engine": "sync", "calls_in_request": _call_counter["expensive_sync"]}

@app.get("/demo/cache")
async def demo_cache(
    a: dict = Depends(expensive_sync_service),
    b: dict = Depends(expensive_sync_service),
    c: dict = Depends(expensive_sync_service, use_cache=False),
):
    return {"cached_twice": a, "cached_again": b, "no_cache": c}

  调用一次/demo/cache后,ab里的calls_in_request都会是1说明第二次声明命中了缓存;而c使用了use_cache=False,强制重新执行,计数器变为2,所以c里会看到2。若你的依赖代表实时配额,这类同一请求内必须多次刷新的值,才需要关掉缓存;大多数鉴权、会话、配置读取场景,默认缓存正是期望行为。如下图:

异步依赖与带清理的yield

  依赖函数可以是async def,FastAPI会自动await。模拟远程IO时:

python 复制代码
async def expensive_async_service() -> dict:
    await asyncio.sleep(0.01)
    return {"engine": "async"}

  数据库会话则常用yield:请求期间把session交给路由,请求结束后执行finally关闭连接。同步用@contextmanager,异步用@asynccontextmanager,FastAPI两种都支持:

python 复制代码
@contextmanager
def get_db_session():
    session = {"data": _DB}
    try:
        yield session
    finally:
        session.clear()

@asynccontextmanager
async def get_async_db_session():
    session = {"data": _DB}
    try:
        await asyncio.sleep(0.005)  # 模拟从连接池取连接
        yield session
    finally:
        session.clear()

  /demo/async-dep可以同时注入同步与异步依赖;/demo/async-db则演示异步session在async路由中的用法。模式与同步版一致:路由只消费session,创建与释放由依赖负责


五、实战:四个高频依赖场景

接口鉴权

  上一节的get_token - verify_token - get_current_user - require_admin链,就是生产里鉴权依赖的标准形状。普通业务接口参数写user: UserInfo = Depends(get_current_user);管理后台写admin: UserInfo = Depends(require_admin);完全公开的接口不声明任何鉴权依赖即可。Token校验、用户不存在、权限不足分别在链的不同节点抛出401、404、403,错误语义清晰,Swagger里AuthorizationHeader也会自动出现。

请求日志

  日志依赖log_request接收Requestrequest_id,把method、path、客户端 IP、时间戳写入内存列表。挂在Router的dependencies上后,该模块每个请求都会自动记一条,路由函数保持零日志代码。管理员通过GET /demo/request-logs需admin Token查看最近 20 条,便于对照排查。

python 复制代码
async def log_request(
    request: Request,
    request_id: str = Depends(get_request_id),
) -> None:
    _REQUEST_LOGS.append({
        "request_id": request_id,
        "method": request.method,
        "path": request.url.path,
        "client": request.client.host if request.client else "unknown",
        "at": datetime.now().isoformat(),
    })

  先访问几次 /secure/profile,再打开request-logs,应能看到对应path的记录。如图所示

请求参数统一解密

  前端有时对敏感字段加密传输,后端若在每条业务路由里各自解密,既重复又容易漏。做法是把解密放进依赖:decrypt_order_body接收原始body模型,调用_decode_encrypted_note示例里用Base64模拟,生产可换成AES/RSA,返回业务层只认的明文模型。

python 复制代码
def _decode_encrypted_note(value: str | None) -> str | None:
    if value is None:
        return None
    try:
        return base64.b64decode(value.encode()).decode()
    except Exception as exc:
        raise HTTPException(status_code=400, detail="encrypted_note 解密失败") from exc

def decrypt_order_body(body: OrderCreate) -> DecryptedOrderCreate:
    return DecryptedOrderCreate(
        product=body.product,
        amount=body.amount,
        note=_decode_encrypted_note(body.encrypted_note),
    )

@app.post("/demo/decrypt-order")
async def demo_decrypt_order(
    order: DecryptedOrderCreate = Depends(decrypt_order_body),
):
    return order

  请求体示例:encrypted_note为中文加急配送的Base64编码5Yqg5oCl6YWN6YCB。路由函数拿到的order.note已是解密后的明文,可以直接入库或校验,完全不知道加密字段的存在。 传入:

json 复制代码
{
  "product": "string",
  "amount": 1,
  "encrypted_note": "5Yqg5oCl6YWN6YCB"
}

返回如图:

数据库会话与仓储子依赖

  真实项目里session往往再包一层仓储。get_db_sessionyield出session,get_user_repo依赖session取出session["data"]作为仓储,路由只注入repo和分页:

python 复制代码
def get_user_repo(session=Depends(get_db_session)):
    return session["data"]

@app.get("/demo/db-session")
async def demo_db_session(
    repo=Depends(get_user_repo),
    pagination: Pagination = Depends(get_pagination),
):
    users = list(repo.values())
    start, end = pagination.offset, pagination.offset + pagination.page_size
    return {"total": len(users), "items": users[start:end]}

  这样形成 会话 - 仓储 - 业务 三层:连接生命周期在依赖里闭合,仓储构造也在依赖里完成,路由只做分页切片和返回JSON。与Flask里在视图开头db = get_db()、结尾db.close()相比,遗漏关闭或忘记rollback的概率小得多。


六、对比 Flask 装饰器:为什么依赖注入更优雅

  Flask里常见做法是用装饰器在视图外包一层,把用户塞进全局g

python 复制代码
def login_required(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        token = request.headers.get("Authorization", "")
        if not verify(token):
            return jsonify({"error": "unauthorized"}), 401
        g.current_user = get_user(token)
        return f(*args, **kwargs)
    return decorated

@app.route("/profile")
@login_required
def profile():
    return jsonify(g.current_user)

  能跑,但有几处长期痛点。用户信息藏在g.current_user里,IDE和类型检查器看不到类型;多个装饰器叠加时顺序敏感,@admin_required@login_required谁先谁后容易踩坑;分页、Header等参数不会出现在函数签名里,OpenAPI文档也生成不出来;单元测试必须构造完整Request 和应用上下文,成本高。

FastAPI 的等价写法是:

python 复制代码
@app.get("/secure/profile")
async def profile(user: UserInfo = Depends(get_current_user)):
    return user

  函数签名里的user: UserInfo既是运行时注入,也是文档和类型提示。require_admin依赖get_current_user,组合靠依赖树而不是装饰器栈。测试时可以用app.dependency_overrides替换任意依赖,无需真实Token:

python 复制代码
def mock_admin():
    return UserInfo(id=1, username="test", role="admin")

app.dependency_overrides[get_current_user] = mock_admin
# 此后所有使用 get_current_user 的接口都会拿到 mock 用户

  同一请求内依赖默认只执行一次,装饰器方案则没有这层缓存。/demo/flask-vs-fastapi接口用JSON概括了上述对照,便于快速回顾。


总结

  FastAPI的Depends()贯穿了从参数复用到鉴权、日志、解密、数据库的整条链路。函数依赖适合分页、Header解析这类无状态工具;类依赖适合带构造参数的策略,限流阈值不同;子依赖把Token校验、用户加载、角色判断拆成可复用的层级;全局与Router级挂载处理横切逻辑而业务路由保持干净;默认缓存避免同一请求重复建连或重复鉴权;async defyield则分别适配异步IO和会话生命周期。

  写好依赖的关键,是习惯在写路由之前先问一句:这件事是业务规则,还是拿到某个对象的plumbing? 后者放进依赖,路由就只剩业务。相比Flask装饰器加隐式全局状态,依赖注入提供了显式签名、自动文档、可override测试和请求级缓存,这也是FastAPI在工程化上最值得先掌握的一块。

相关推荐
Assby2 小时前
从 Function Calling 到 MCP:理解 Agent 工具调用的底层通信机制
人工智能·后端
打字机v2 小时前
创建第一个spring-boot项目
后端
像我这样帅的人丶你还2 小时前
Java 后端详解(三):全局异常处理与 JPA 数据库映射
java·后端
前端Hardy3 小时前
又一个 AI 神器火了!
前端·javascript·后端
神奇小汤圆3 小时前
面试被问烂的Java虚拟机调优,我用一个实战案例给你讲得明明白白
后端
明月_清风4 小时前
开发者网络概念全扫盲:一篇搞定
后端·网络协议
明月_清风4 小时前
零信任入门:从"城堡护城河"到"每次进门都要刷卡"
后端
站大爷IP5 小时前
Python循环中修改字典键导致遍历异常深度解析实战案例
后端