依赖注入
第 04 章结尾留了个钩子:getdb 里那句 async with ... yield session——「yield 前借、yield 后还」,但当时没解释谁来调用它、谁把 session 塞进路由函数。这一章就把幕…
第 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 的行为,三件事就通了:
__call__里的_overrides.get(...)就是app.dependency_overrides的查表逻辑;- 替换表的 key 是函数对象——所以 FastAPI 那边传错 key 会静默失效;
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 · 统一异常处理