Python 框架指南 · 第 08 章

依赖注入

第 04 章结尾留了个钩子:getdb 里那句 async with ... yield session——「yield 前借、yield 后还」,但当时没解释谁来调用它、谁把 session 塞进路由函数。这一章就把幕…

约 32 分钟12,901 字23 个代码示例

第 04 章结尾留了个钩子:get_db 里那句 async with ... yield session——「yield 前借、yield 后还」,但当时没解释谁来调用它、谁把 session 塞进路由函数。这一章就把幕后角色请出来:依赖注入(Depends)。

一句话定位:Depends 是 FastAPI 的「道具组」。你的路由函数是演员,只管伸手要道具;至于道具是哪个仓库调来的、用完怎么还,全是道具组的事——演员的台词本(函数签名)里写的是"我要什么",从不写"上哪儿拿"。

本章配套 demo:demo/demo_depends.py(单文件,内存假数据,内嵌 uvicorn,注释里有完整 curl 演示动线)

示例场景延续「墨集 inkhub 内容平台」:文章存哪儿?——本地磁盘、云端对象存储、内存模拟,三种引擎随时切换,正是依赖注入最经典的用武之地。

章节衔接:04 章的 get_db 雏形在本章 7 节正式回收;11 章的 get_current_user(认证)会大量复用本章的写法;12 章 pytest 测试会用到 11 节的 dependency_overrides。


1. 从一次「换存储引擎」翻车说起

先看一段"写死依赖"的代码,它没有任何语法问题,却是维护噩梦的开端:

# ❌ 反面教材:依赖被焊死在函数体里
from storage.cloud import CloudEngine

router = APIRouter(prefix="/articles")

@router.post("")
async def create_article(data: ArticleIn):
    engine = CloudEngine(bucket="inkhub-articles")   # 依赖在这里被写死
    return engine.save(data)

代价一:换实现 = 全项目大搜捕。 有一天墨集决定从云端搬到本地磁盘。你得满项目搜 CloudEngine,几十个路由文件一个个改。改漏一处,两套引擎同时写,数据悄悄裂成两半。

代价二:测试打的是真家伙。 跑单测时这段代码会真的往云端写文件——又慢、又花钱、还留下脏数据。想要一个"假引擎"跑测试?没有插的地方,除非改生产代码。

代价三:依赖的依赖,层层往里拖。 云端引擎需要访问令牌,令牌要从请求头解析——于是构造令牌的代码也得搬进路由函数。函数体越来越胖,业务逻辑被装配代码淹没。

病根只有一个:"要用什么"和"从哪儿拿"被焊在了一起。解法也只有一个:路由只声明"要用什么"(类型),"从哪儿拿"交给外面。这就是本章全部内容的出发点。


2. 先说人话:IoC、DI、IoC 容器

三个绕不开的名词,先拆掉:

  • IoC:Inversion of Control,控制反转——一种设计思想。核心动作:把"选哪个实现"的选择权从函数内部移交出去,让自己和具体实现解耦。
  • DI:Dependency Injection,依赖注入——把 IoC 落地的手段。具体做法:依赖不再自己造,而是从外部注入进来(最常见就是"从参数递进来")。
  • IoC 容器:优化 DI 的工具。依赖太多、注入关系太复杂时,由一个专职"管家"统一负责查表、构造、交付依赖。FastAPI 本身就是你的 IoC 容器。

不用框架,手动 DI 长这样:

class MemoryEngine: ...      # 内存模拟,测试用
class LocalEngine: ...       # 本地磁盘
class CloudEngine: ...       # 云端对象存储

def publish(data: ArticleIn, engine):     # 依赖从参数递进来
    return engine.save(data)

publish(data, CloudEngine())   # 生产:云端
publish(data, LocalEngine())   # 内网部署:本地
publish(data, MemoryEngine())  # 测试:内存模拟

选择权交出去了——publish 从此不关心引擎是谁,调用方说了算。这就是"控制反转":原来是我(函数)说了算,现在是外面说了算。

用剧组打个比方:

  • 写死依赖 = 演员自己揣着真西瓜上台,想换成塑料道具西瓜,只能把演员肚子剖开;
  • 手动 DI = 演员空手上台,导演递什么拿什么——能换了,但每场戏导演都得亲自递;
  • IoC 容器 = 剧组雇了道具组,剧本上写了"演员甲:西瓜一颗",道具组看到就自动备好。演员照伸手,导演睡大觉。

FastAPI 的 Depends,就是你雇的这个道具组。下一节开始讲怎么写"剧本"。


3. Depends:把依赖写进签名里

作用:告诉 FastAPI"这个参数的值,请调用某个依赖去取"。FastAPI 会在每次请求时自动执行依赖、把返回值塞进参数,再调用你的路由函数。

两种写法(同一个意思):

from fastapi import Depends

# 写法A:默认值挂 Depends(老项目常见)
async def list_articles(store: ArticleStore = Depends(get_engine)):
    ...

# 写法B:Annotated 挂 Depends(官方推荐,FastAPI 0.95+)
from typing import Annotated

async def list_articles(store: Annotated[ArticleStore, Depends(get_engine)]):
    ...

执行流程(背下来,后面全靠它):

请求进来
   │
   ▼
FastAPI 扫路由签名 → 发现参数挂着 Depends(get_engine)
   │
   ▼
调用 get_engine() ──依赖自己声明的参数(query/path/header),框架照样解析
   │
   ▼
拿到返回值(一个 ArticleStore 实例)
   │
   ▼
塞进 store 参数 → 这才调用你的路由函数

关键洞察:依赖函数自己也可以声明 query、path、header 参数,FastAPI 会一视同仁地解析——这就是后面"参数透传"的伏笔。

最小可跑示例:

from fastapi import Depends, FastAPI, Query
from typing import Annotated

app = FastAPI()

def get_engine(
    backend: str = Query(default="memory", pattern="^(memory|local|cloud)$"),
) -> ArticleStore:
    """函数依赖:按 query 参数选引擎"""
    ...

@app.get("/articles")
async def list_articles(store: Annotated[ArticleStore, Depends(get_engine)]) -> dict:
    return {"engine": store.backend, "titles": store.titles()}

请求 /articles?backend=cloud,路由里拿到的 store 就是云端引擎——路由代码一个字没提"cloud"。

常见坑

  • Depends(get_engine) 括号里传的是函数对象本身,不是调用结果。写成 Depends(get_engine()) 是最经典的笔误——后者把"现在调一次"的返回值当依赖,行为全乱。
  • Annotated 写法里,类型和 Depends 的顺序不能反:Annotated[类型, Depends(...)],Depends 永远在第二位。
  • 两种写法别混在同一个参数上:store: ArticleStore = Depends(...) 又包 Annotated,属于自找麻烦。

4. 函数依赖:最常用的形态

作用:任何可调用对象都能当依赖,函数是第一形态。同步、异步都行。

# 异步依赖:内部要 await 时用
async def get_engine() -> ArticleStore:
    engine = await load_engine_config()
    return engine

# 同步依赖:纯计算、读配置这类快活儿
def get_page_size(limit: int = Query(default=20, ge=1, le=100)) -> int:
    return limit

路由端拿到的是函数的返回值——注意,不是函数本身:

@app.get("/articles")
async def list_articles(
    store: Annotated[ArticleStore, Depends(get_engine)],   # 拿到 ArticleStore 实例
    limit: Annotated[int, Depends(get_page_size)],         # 拿到 int
) -> list[str]:
    return store.titles()[:limit]

同步依赖跑在线程池里(和同步路由函数一个待遇),不会卡事件循环——所以纯 CPU 或阻塞 IO 的小活儿,写同步依赖完全没问题。

常见坑

  • 依赖的返回值不是响应体。依赖是"备料",路由是"炒菜"——客户端永远只看到路由的返回值。
  • 依赖里可以直接 raise HTTPException(401) 做权限拦截,而且在路由执行之前就生效了(认证章节的标准姿势,11 章 get_current_user 就这么写)。
  • 依赖函数别起名 get_xxx 就以为是 GET 专用——它和 HTTP 方法无关,任何路由都能用。

5. 类依赖:new 的活儿外包给框架

作用:把类传给 Depends,FastAPI 每个请求会实例化一次,把实例塞给你。相当于框架替你写了 PublishGuard(...) 这行 new。

签名规则:__init__ 的参数会被 FastAPI 当成路由参数解析——类依赖天然自带状态和一小套方法。

from fastapi import Header

class PublishGuard:
    """发布权限守卫:类依赖示例"""

    def __init__(self, x_inkhub_editor: str = Header(default="匿名墨友")) -> None:
        self.editor = x_inkhub_editor     # 参数名 → 请求头 X-Inkhub-Editor(下划线转连字符)

    def stamp(self, title: str) -> str:
        return f"《{title}》由 {self.editor} 发布"

@app.post("/articles/publish")
async def publish(
    data: ArticleIn,
    guard: Annotated[PublishGuard, Depends(PublishGuard)],   # 注意:传的是类本身
) -> dict:
    article_id = get_store().write(data.title)
    return {"id": article_id, "stamp": guard.stamp(data.title)}

什么时候用类?当依赖需要携带状态(editor、超时配置、客户端句柄)或配套方法时,类比闭包函数清爽。函数依赖适合"算一个值就完事"的场景。

常见坑

  • Depends(PublishGuard) 传的是类,不是实例。Depends(PublishGuard()) 等于把一个现成对象当依赖调用——会按"可调用对象"走另一套逻辑,直接翻车。
  • 每个请求都会实例化一次:__init__ 里别干重活(建连接、读大文件),那类开销请交给 7 节的 yield 依赖或全局单例。
  • 类依赖同样吃"同请求缓存"(9 节):一次请求里多处 Depends(PublishGuard) 只会 new 一次。
  • Header 参数名的下划线会自动转连字符:x_inkhub_editor 对应请求头 X-Inkhub-Editor——框架不会替你加 X- 前缀。参数名写成 inkhub_editor 却去发 X-Inkhub-Editor 请求头,永远只拿到默认值,还不报错。名字对不上时用 alias= 显式指定。

6. Annotated 写法与可复用别名

作用:Annotated 不只是"新写法"——它把类型和依赖绑成一个可复用的类型别名,全项目声明一次,处处复用。

from typing import Annotated
from fastapi import Depends

# 声明一次:类型 + 依赖打包
StoreDep = Annotated[ArticleStore, Depends(get_engine)]
GuardDep = Annotated[PublishGuard, Depends(PublishGuard)]

# 到处复用,签名干净得像没写过依赖
@app.get("/articles")
async def list_articles(store: StoreDep) -> dict: ...

@app.post("/articles/publish")
async def publish(data: ArticleIn, store: StoreDep, guard: GuardDep) -> dict: ...

@app.delete("/articles/{article_id}")
async def remove(article_id: int, store: StoreDep) -> dict: ...

没有 Annotated 的年代,每个参数都要抄一遍 = Depends(get_engine);改依赖时得满文件改。有了别名,改一处,全局生效。

常见坑

  • 别名只是个类型别名,别在 Annotated 里塞业务参数——Query/Depends 之外的东西框架不认识。
  • 混用两种风格时优先统一到 Annotated,老式默认值写法留作"看得懂旧代码"的技能即可。

7. yield 依赖:前置后置一段管完

第 04 章 get_db 的正式回收。普通依赖"算个值就退场",yield 依赖是生成器:yield 前是准备段(借),yield 后是清理段(还),FastAPI 负责在恰当时机推进它。

形状:

from collections.abc import AsyncIterator

async def get_db() -> AsyncIterator[AsyncSession]:
    async with session_factory() as session:   # 前置段:开仓借 session
        yield session                            # ⬅ 路由函数在这里跑
    # 后置段:路由跑完,退出 async with,session 自动归还

执行时序(一次请求):

前置段执行(借)
   │
   ▼
yield 值交给路由 → 路由函数执行 → 响应发回客户端
   │
   ▼
后置段执行(还)

最小示例——给存储引擎做"租借式"依赖:

async def lease_store(
    backend: str = Query(default="memory", pattern="^(memory|local|cloud)$"),
) -> AsyncIterator[ArticleStore]:
    store = build_engine(backend)
    print(f"前置段:{store.backend} 就绪")        # 请求进来先执行
    try:
        yield store
    finally:
        print(f"后置段:{store.backend} 收尾归档")  # 响应发完才执行

@app.get("/articles/leased")
async def leased_list(store: Annotated[ArticleStore, Depends(lease_store)]) -> dict:
    return {"engine": store.backend, "titles": store.titles()}

服务端日志顺序:前置段 → 路由返回 → 后置段。后置段在响应发给客户端之后执行——所以清理再慢也不拖累用户体感。

两个关键保证:

  • 路由抛异常,finally 照样执行——资源不会因报错而泄漏;
  • 路由抛出的异常会穿越 yield 点传进依赖:想根据异常类型决定回滚策略,可以在 yield 后 except 捕获再 raise 放行。

常见坑

  • 一个依赖函数只能有一个 yield,多个会直接报错。要分层清理就拆成多个 yield 依赖(下一节的嵌套正好能组合它们)。
  • yield 后的清理代码里尽量别抛新异常——它发生在响应已发出之后,客户端什么都看不到,只剩服务端日志里一具难查的尸体。
  • 依赖在路由之前执行(包括 yield 前的段),但不在响应之后还活着的是 yield 前的挂起态——别在依赖里搞"先 yield 一个占位符、后置段再填充"的骚操作,路由拿到什么就是什么。

8. 依赖嵌套:依赖还能依赖

作用:依赖函数自己的参数,也可以挂 Depends——依赖可以叠依赖,形成一棵树。FastAPI 从叶到根逐层求解。

        front_page(父依赖)
        /          \
review_channel   get_engine(子依赖)
def review_channel() -> str:
    return "前沿速递"

def front_page(
    channel: Annotated[str, Depends(review_channel)],   # 依赖套依赖
    store: Annotated[ArticleStore, Depends(get_engine)],
) -> dict[str, str]:
    return {"channel": channel, "headline": store.titles()[0] if store.titles() else "空空如也"}

@app.get("/front-page")
async def read_front_page(page: Annotated[dict[str, str], Depends(front_page)]) -> dict:
    return page

路由只声明最顶层的 front_page,底下的 review_channel、get_engine 框架自动递归求解。依赖树让每层只管自己的事:channel 管栏目、engine 管存储、front_page 只负责组装——这就是 04 章会话、11 章认证将要反复出现的标准结构:

get_current_user            ← 11 章主角
    └── get_token           ← 从请求头解 token
          └── get_settings  ← 读配置

常见坑

  • 循环依赖必炸:A 依赖 B、B 又依赖 A,框架求解时直接抛错。画依赖树时留意方向,别成环。
  • 嵌套层数是"必要的复杂度",不是"架构含金量"。两层三层是常态,五层以上通常说明你在用依赖树写流程编排——那是业务层的事。

9. use_cache:一次请求只买一单

作用:默认情况下,同一个依赖在一次请求里只执行一次,多个参数共享同一个结果。这就是"同请求缓存"(use_cache 默认 True)。

def next_seq() -> int:
    print("next_seq 被调用")     # 观察点
    return random.randint(100, 999)

def dep_left(seq: Annotated[int, Depends(next_seq)]) -> int:
    return seq

def dep_right(seq: Annotated[int, Depends(next_seq)]) -> int:
    return seq

@app.get("/cache/on")
async def cache_on(
    left: Annotated[int, Depends(dep_left)],
    right: Annotated[int, Depends(dep_right)],
) -> dict:
    # left == right:底层 next_seq 只跑了一次,结果被缓存共享
    return {"left": left, "right": right, "same": left == right}

请求 /cache/on,服务端日志里 next_seq 被调用 只出现一次,left 与 right 相等。

什么时候关掉缓存——use_cache=False:

def dep_fresh(seq: Annotated[int, Depends(next_seq, use_cache=False)]) -> int:
    return seq

@app.get("/cache/off")
async def cache_off(
    left: Annotated[int, Depends(dep_left)],
    right: Annotated[int, Depends(dep_fresh)],     # 强制重跑
) -> dict:
    return {"left": left, "right": right, "same": left == right}   # same: False

典型场景:依赖返回每次调用必须不同的东西(自增序列号、随机挑战值、需要独立实例的资源句柄)。

常见坑

  • 缓存范围是这一次请求,不是全局。下一个请求来,依赖照样重新执行——它不是性能缓存,是"同一请求内去重"。
  • 别指望靠 use_cache=False 给同一请求开两个数据库会话来"规避事务"——那是设计问题,不是参数问题。会话类依赖保持默认缓存就好。

10. 参数透传:子依赖的参数,端点不用重抄

作用:子依赖里声明的 query/header 参数,FastAPI 会自动并入端点——路由签名不用重抄,文档里却完整出现。

def editor_gate(
    editor: str = Query(min_length=1, description="作者笔名"),
) -> str:
    return editor

@app.get("/articles/by-me")
async def articles_by_me(
    gate: Annotated[str, Depends(editor_gate)],   # editor 参数从子依赖"透传"进来
) -> dict:
    return {"editor": gate}

请求 /articles/by-me?editor=林墨:路由签名里根本没有 editor,但 query 参数照样被解析、文档(/docs)里照样列出——参数属于谁,由声明它的位置决定,不写在路由里。

另一种形态是两端同名共享:路由和子依赖声明同名参数,FastAPI 会解析一次、两边同步拿到同一个值:

def check_channel(channel: str = Query()) -> str: ...
    return channel

@app.get("/articles")
async def list_articles(
    channel: str = Query(description="栏目筛选"),
    ok: Annotated[str, Depends(check_channel)],   # 这里的 channel 与上面的同名同值
) -> dict: ...

常见坑

  • 子依赖的参数是"隐形"的:读路由签名看不出来,看 /docs 或读依赖源码才能发现全貌。接手老项目时,先扫依赖树再数参数。
  • 共享参数必须同名,channel 和 channel_id 就是两个互不相干的参数——一个填了另一个没填,422 报错让你怀疑人生。

11. dependency_overrides:给测试换替身

作用:运行时把某个依赖整体替换成另一个实现,路由代码零感知。app.dependency_overrides 是一个字典:key 是原依赖对象,value 是替身。

最小示例——测试时把真引擎换成假引擎:

class FakeStore(ArticleStore):
    backend = "fake(测试替身)"

    def write(self, title: str) -> int:
        return 42                              # 假数据,随便编

def get_fake_store() -> FakeStore:
    return FakeStore()

# 一行替换,所有用到 Depends(get_engine) 的路由全部生效
app.dependency_overrides[get_engine] = get_fake_store

@app.post("/articles/publish")   # 路由完全不知道自己拿到的是假引擎
async def publish(data: ArticleIn, store: StoreDep) -> dict: ...

测试脚本里的标准姿势(12 章预告,先混个眼熟):

from fastapi.testclient import TestClient

def test_publish():
    app.dependency_overrides[get_engine] = get_fake_store     # 换替身
    try:
        with TestClient(app) as client:
            resp = client.post("/articles/publish", json={"title": "测试文"})
            assert resp.status_code == 200
    finally:
        app.dependency_overrides.clear()                      # 必须还回来

这就是 1 节"代价二"的正式解药:测试不打真云端、不建真数据库,替身随取随换。

常见坑

  • key 必须是传给 Depends 的那个函数对象。路由里写的是 Depends(get_engine),你就得用 get_engine 当 key;传错了(比如传了别名、传了别的包装函数)替换静默失效,测试还在打真库。
  • 替身函数的返回类型要和原件兼容(最好同签名),否则路由拿到不相干的东西,错误出现在离现场很远的地方。
  • dependency_overrides 是全局字典,测完不 clear() 会污染后面的所有测试——这就是上面 try/finally 存在的意义。

12. 手搓一个迷你注入器(看懂 override 原理)

FastAPI 的 override 神不神奇?拆开看,核心就是一张替换表。自己搓一个 30 行的迷你版,原理立刻透明:

# mini_dep.py —— 手搓版 Depends,理解原理用
from collections.abc import Callable
from typing import Any

class MiniDep:
    """迷你注入器:包装一个依赖函数,调用时先查替换表"""

    _overrides: dict[Callable[..., Any], Callable[..., Any]] = {}

    def __init__(self, func: Callable[..., Any]) -> None:
        self.func = func

    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        actual = self._overrides.get(self.func, self.func)   # 有替身用替身
        return actual(*args, **kwargs)

    @classmethod
    def override(cls, original: Callable, replacement: Callable) -> None:
        cls._overrides[original] = replacement               # 登记替身

    @classmethod
    def clear_overrides(cls) -> None:
        cls._overrides.clear()                               # 撤掉全部替身

用它复刻墨集的"换引擎"三连:

def get_engine() -> ArticleStore:
    return CloudEngine()

def get_fake_engine() -> ArticleStore:
    return MemoryEngine()

def publish(title: str, engine: ArticleStore = MiniDep(get_engine)) -> int:
    return engine.write(title)        # 不关心引擎是谁,伸手就写

publish("云端首篇")                          # ① 走真云端

MiniDep.override(get_engine, get_fake_engine)
publish("测试第二篇")                        # ② 替身生效,写进内存

MiniDep.clear_overrides()
publish("又回云端")                          # ③ 替身撤干净,恢复原样

对着 MiniDep 看 FastAPI 的行为,三件事就通了:

  1. __call__ 里的 _overrides.get(...) 就是 app.dependency_overrides 的查表逻辑;
  2. 替换表的 key 是函数对象——所以 FastAPI 那边传错 key 会静默失效;
  3. clear_overrides 对应 app.dependency_overrides.clear()——替身是全局状态,用完必须还。

当然,真 FastAPI 远比它强:异步支持、签名解析、yield 生命周期、请求级缓存、依赖树递归求解……但 override 的芯,就是这张字典。


13. 本章速查表

要点 一句话
写死的代价 换实现全项目搜捕、测试打真家伙、依赖的依赖层层拖
IoC / DI / 容器 思想(移交选择权)/ 手段(外面递进来)/ 管家(FastAPI 本身)
Depends 基本形 Annotated[类型, Depends(依赖)],传函数/类对象本身
函数依赖 同步异步都行;返回值给路由;内部可抛 HTTPException 先拦截
类依赖 每请求实例化一次;__init__ 参数参与解析;重活别放构造器
Annotated 别名 StoreDep = Annotated[...] 声明一次全局复用
yield 依赖 前置借、yield 交、后置还;响应发出后才执行清理;异常穿 yield 可捕
依赖嵌套 依赖套依赖成树;框架从叶到根递归求解;别成环
use_cache 默认同请求只执行一次;需要每次不同就 use_cache=False
参数透传 子依赖声明的参数自动并入端点与文档;两端同名则同步共享
overrides app.dependency_overrides[原依赖] = 替身;key 是函数对象;测完必须 clear
迷你注入器 一张替换表 + 查表调用,就是 override 的全部原理

下一章:09 · 统一异常处理