FastAPI-依赖注入与异常中间件
这篇讲什么
Depends是 FastAPI 的核心机制:鉴权、数据库会话、分页参数、配置都写成依赖函数,处理器只声明「我要什么」。- 三条实测出来的硬规矩:同一个依赖函数在一次请求里只跑一次;处理器抛异常时
yield依赖的收尾代码不会执行;路由 404 走的是 Starlette 的HTTPException,只注册 FastAPI 那个 handler 拦不到。 - 中间件、异常处理、后台任务、lifespan 四类扩展点逐个跑过,含执行顺序与 CORS 预检的响应头。
依赖的四种写法
-
函数依赖,可以嵌套:
deps_chain.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20from fastapi import Depends, FastAPI
app = FastAPI()
async def dep_a():
return "A"
async def dep_b(a: str = Depends(dep_a)):
return a + "B"
async def dep_c(a: str = Depends(dep_a)):
return a + "C"
def chain(b: str = Depends(dep_b), c: str = Depends(dep_c)):
return {"b": b, "c": c}1
2GET /chain -> {'b': 'AB', 'c': 'AC'}
依赖函数实际调用顺序: ['a', 'b', 'c'] # dep_a 只跑了一次- 依赖树先解析、再执行,同一个可调用对象在一次请求内结果被缓存(实测两个参数共用
dep_a,计数只加 1)。 - 缓存是按「函数对象」算的,两个不同的函数哪怕内容一样也会各跑一次。
- 依赖树先解析、再执行,同一个可调用对象在一次请求内结果被缓存(实测两个参数共用
-
类依赖,参数直接变成查询参数:
class_dep.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
class CommonQuery:
def __init__(self, page: int = 1, size: int = 20):
self.page, self.size = page, size
def cls_ep(q: Annotated[CommonQuery, Depends()]):
return {"page": q.page, "size": q.size}1
2/cls -> {'page': 1, 'size': 20}
/cls?page=3&size=5 -> {'page': 3, 'size': 5}- 这是「一堆接口共用同一组分页参数」的标准做法,比在每个函数里写一遍干净。
Depends()不传参数时,FastAPI 直接拿类型当依赖,所以Annotated[CommonQuery, Depends()]就够了。- ⚠️ 别在
__init__里用assert做校验,实测assert size <= 100失败时 AssertionError 一路冒到服务端,客户端拿到 500;要拒绝就raise HTTPException(422, ...)。
-
yield依赖,前半段是 setup、后半段是 teardown(数据库会话、临时文件都靠它):yield_dep.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
async def dep_session():
print("open")
yield "sess"
print("close")
def use(s: Annotated[str, Depends(dep_session)]):
return {"s": s}收尾时机实测得说清楚,四种路径结果不一样:
1
2
3
4/e4 正常返回 -> ['open', 'handler', 'close']
/e1 handler 抛 HTTPException(400) -> ['open', 'handler'] # close 没跑
/e2 参数校验失败(422) -> ['open'] # 处理器都没进,close 没跑
/e3 handler 里 1/0 (500) -> ['open', 'handler'] # close 没跑-
也就是说,裸写的
yield依赖不保证收尾:异常路径由异常中间件短路,yield之后的代码根本走不到。 -
但把收尾包进
try/finally就完全不同,四条路径逐个实测(open= 进入依赖,close=yield之后的收尾):路径 裸 yieldtry/finally包住200 正常返回 ['open', 'handler', 'close']['open', 'handler', 'close']400 handler 抛 HTTPException['open', 'handler'](close 丢了)['open', 'handler', 'close']422 参数校验失败(处理器没进) ['open'](close 丢了)['open', 'close']500 handler 里 1/0['open', 'handler'](close 丢了)['open', 'handler', 'close'] -
所以规矩很明确:依赖里做资源管理就必须
try/finally,db.close()、file.close()、释放锁这类事不能指望裸yield后面那几行。数据库那篇的 session 依赖就是按try/finally写的,见 FastAPI-数据库。
-
-
依赖里可以读请求体,实测把 Pydantic 模型直接当依赖参数:
dep_body.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16from fastapi import Depends
from pydantic import BaseModel
class Payload(BaseModel):
a: int
b: str = "x"
async def dep_model(p: Payload):
return p
def dep_model_ep(p: Annotated[Payload, Depends(dep_model)]):
return {"got": p.model_dump()}1
2POST /dep-model {"a":1,"b":"hello"} -> 200 {"got": {"a": 1, "b": "hello"}}
POST /dep-model {"b":"hello"} -> 422 missing@body.a老教程常说「依赖里不能声明 body」,那是指同一个依赖里既想读 body 又想被多个接口复用的场景不划算;单纯读 body 实测没问题,校验错误也是标准的 422。
依赖挂在哪一层
-
四个层级都能挂,实测计数能看出差别:
dep_layers.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17from fastapi import APIRouter, Depends, FastAPI
app = FastAPI(dependencies=[Depends(g_dep)]) # 全局
api = APIRouter(dependencies=[Depends(r_dep)]) # router 级
def a_ep():
return {"ok": "a"}
# 路由级
def b_ep():
return {"ok": "b"}
app.include_router(api)1
GET /a 一次 + GET /b 一次之后: {'global': 2, 'router': 2, 'route': 1}
-
选择原则:
- 全局依赖只放「所有接口都要」的东西,比如访问日志、请求 ID。
- 一个容易讲错的点:全局依赖不会挡文档路由。实测挂上
dependencies=[Depends(verify_admin)]之后/api返回 401,而/docs、/redoc、/openapi.json仍然是 200。所以「给文档加登录」要单独包 docs 路由,别指望这层帮你挡。 - router 级适合「这一组接口要登录」。
- 路由级适合单个接口的权限(
dependencies=[Depends(require_role("admin"))])。 - 需要拿到依赖的返回值就用参数形式(
user: User = Depends(current_user)),只需要「执行一下」就用dependencies=[...]。
-
Security是Depends的加强版,多一个 scopes 声明,用在 OAuth2 权限上,见 FastAPI-安全与认证。 -
配置对象也走依赖,但要加
lru_cache,否则每个请求都重新解析一遍环境变量与.env:settings_dep.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23from functools import lru_cache
from typing import Annotated
from fastapi import Depends, FastAPI
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "shop"
page_size: int = 20
def get_settings():
return Settings()
app = FastAPI()
def cfg(s: Annotated[Settings, Depends(get_settings)]):
return {"app_name": s.app_name, "page_size": s.page_size}1
2
3GET /cfg -> {'app_name': 'shop', 'page_size': 20}
lru_cache 命中(同一个实例): True
不包 lru_cache 时 Settings() is Settings() -> False # 每次都是新对象- 不加
@lru_cache就是「每个请求 new 一个 Settings」,功能没错但白花时间,还让测试里改环境变量变得难以预期。 - 想在测试里替换它,
app.dependency_overrides[get_settings] = lambda: 假配置正好用上(依赖是按函数对象匹配的)。
- 不加
测试时替换依赖
-
dependency_overrides是 FastAPI 官方推荐的「打桩」方式,实测:override_demo.py 1
2
3
4
5
6
7async def need_token(x_token: Annotated[str, Header()]):
if x_token != "secret":
raise HTTPException(401, "坏 token")
return x_token
app.dependency_overrides[need_token] = lambda: "fake"1
2
3
4没覆盖,带坏 token -> 401
覆盖后(连头都不用带) -> {'t': 'fake'}
覆盖成返回 Response 的函数 -> 200 {"t":{"status_code":418,...}} # 不是 418!
app.dependency_overrides.clear() 之后 -> {'t': 'secret'}- 注意第三行:覆盖函数返回一个
Response对象并不会短路请求,它只是被当成「依赖的值」塞进处理器参数再序列化出去。想模拟 401,就在覆盖函数里raise HTTPException(401)。 - 覆盖是按函数对象匹配的,所以要覆盖的必须是同一个
need_token;包一层 lambda 或用子依赖会让它找不到。 - 记得
clear(),否则同一进程里的后续测试全被污染。
- 注意第三行:覆盖函数返回一个
异常处理
-
业务错误就一个写法:
raise HTTPException,别return:raise_only.py 1
2
3
4
5
def get_goods(goods_id: int):
if goods_id != 1:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="商品不存在")
return {"goods_id": 1}return HTTPException(...)实测会把异常对象当数据序列化出去(得到一个 200 的奇怪响应),不报错、不拦截,非常难查。 -
三类 handler 各管一段,实测:
handlers.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22from fastapi import FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
async def h_http(request: Request, exc: HTTPException):
return JSONResponse({"code": exc.status_code, "msg": exc.detail},
status_code=exc.status_code)
async def h_val(request: Request, exc: RequestValidationError):
return JSONResponse({"code": 422, "msg": "参数不对", "detail": exc.errors()},
status_code=422)
async def h_any(request: Request, exc: Exception):
return JSONResponse({"code": 500, "msg": "服务器内部错误"}, status_code=500)1
2
3
4/http -> 404 {"code": 404, "msg": "自定义没有"}
/val?n=abc -> 422 {"code": 422, "msg": "参数不对", "detail": [{"type": "int_parsing", ...}]}
/crash -> 500 {"code": 500, "msg": "服务器内部错误"}
/nope -> 404 {"detail": "Not Found"} # 没走自定义 handler -
最后那行是重点:路由不匹配时抛的是
starlette.exceptions.HTTPException,不是fastapi.HTTPException,所以只注册 FastAPI 那个拦不到 404。实测两种注册方式:1
2
3
4
5只注册 fastapi.HTTPException:
/known -> 404 {"custom": "fastapi", "code": 404} # 自己 raise 的能拦
/nope -> 404 {"detail": "Not Found"} # 路由 404 拦不到
改成注册 starlette.exceptions.HTTPException:
/nope -> 404 {"custom": "starlette", "code": 404, "detail": "Not Found"}要统一全站 404 文案,就注册 Starlette 那个(
fastapi.HTTPException是它的子类,注册子类只覆盖自己 raise 的场景)。 -
Exceptionhandler 有两个限制要知道:- 它只在没有更具体的 handler 时才生效,且必须返回
Response(实测返回 dict 会TypeError: 'dict' object is not callable)。 - 用
TestClient验它要传raise_server_exceptions=False,否则异常会直接冒到测试代码里。
- 它只在没有更具体的 handler 时才生效,且必须返回
-
校验错误想改文案而不是改结构,不用写 handler,改
Field(description=...)或Field(title=...)就行;要改结构才动RequestValidationErrorhandler。
中间件
-
三种写法,实测执行顺序是「后加的在外层」:
middleware_order.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25from fastapi import FastAPI, Request
app = FastAPI()
async def mw_http(request: Request, call_next):
trace.append("http-in")
resp = await call_next(request)
resp.headers["X-MW"] = "http"
trace.append("http-out")
return resp
class MwAsgi:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
trace.append("asgi-in")
await self.app(scope, receive, send)
trace.append("asgi-out")
app.add_middleware(MwAsgi)1
2GET /mw -> ['asgi-in', 'http-in', 'handler', 'http-out', 'asgi-out']
响应头 X-MW: http@app.middleware("http")是BaseHTTPMiddleware的糖,好处是能拿到Response对象改头;代价是它会把响应包成流,不适合 StreamingResponse 与 WebSocket(WebSocket 路径上它根本不参与)。- 纯 ASGI 中间件(自己写
__call__)性能最好、能处理所有类型,官方文档里的 CORS/GZip 也都是纯 ASGI 实现。要埋点、计时、注入请求 ID 优先用它。
-
CORS 实测(
CORSMiddleware):cors_app.py 1
2
3
4
5
6
7
8from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(CORSMiddleware,
allow_origins=["https://good.example"],
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["X-Trace"],
expose_headers=["X-Total"])1
2
3
4
5
6
7
8
9
10
11
12
13
14
15带 Origin: https://good.example
-> access-control-allow-origin: https://good.example
access-control-allow-credentials: true
access-control-expose-headers: X-Total
带 Origin: https://evil.example
-> 没有 access-control-allow-origin(浏览器会拦),但另外两个头仍然在
OPTIONS 预检(POST + X-Trace)
-> 200 access-control-allow-methods: GET, POST
access-control-max-age: 600
access-control-allow-headers: Accept, Accept-Language, Content-Language,
Content-Type, X-Trace
OPTIONS 预检(DELETE)
-> 400 Disallowed CORS methodallow_credentials=True与allow_origins=["*"]是互斥的组合:带了凭据就不能用通配 origin,实测通配时浏览器会拒。要「任意源 + 凭据」得显式列名单或用allow_origin_regex。allow_headers里那几个Accept/Content-Type/...是中间件自带的默认值,不用自己写全。- 前端要读自定义响应头(比如分页的
X-Total)必须写进expose_headers,否则 JS 拿不到。
-
GZip 实测:
1
2/big (1000 个 x,minimum_size=200) -> content-encoding: gzip
/small (1 个 x) -> 没有 content-encoding- 小于
minimum_size不压,避免负收益。 - 生产里这层通常交给 nginx,不必让应用压;
TestClient会自动解压,所以看到的长度是解压后的,别拿它当压缩率。
- 小于
后台任务
-
响应发出去之后再做的事,用
BackgroundTasks:background.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23import time
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def bg_sync(name: str):
time.sleep(0.2)
seq.append("sync-%s" % name)
async def bg_async(name: str):
await asyncio.sleep(0.1)
seq.append("async-%s" % name)
def task(bg: BackgroundTasks):
bg.add_task(bg_sync, "1")
bg.add_task(bg_async, "2")
seq.append("handler")
return {"ok": True}1
2POST /task -> {'ok': True}
执行顺序: ['handler', 'sync-1', 'async-2']- 顺序是「先加的先跑」,而且同步任务真的会阻塞后续异步任务(sync 睡 0.2 秒,async 只能等它)。任务之间要并发就别用这个,交给任务队列(Celery / RQ / Dramatiq),写法与取舍见 Django-Celery 那类文章。
- 它只在「响应已经发出去」之后跑,客户端拿不到它的结果,所以不能用来保证投递:进程被杀任务就没了。发通知、写审计日志可以,扣款不行。
- 后台任务里抛异常,实测在
TestClient下会直接冒到测试代码(RuntimeError);真实服务里是记日志,响应已经发出去了,用户看不到。所以要监控就得自己包try/except上报。
lifespan
-
启动/关闭时的一次性动作,官方现在只推荐
lifespan:lifespan_app.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
app = FastAPI()
async def lifespan(application: FastAPI):
application.state.db = "连接池"
print("lifespan 启动")
yield
print("lifespan 关闭,释放", application.state.db)
app = FastAPI(lifespan=lifespan)
def state(request: Request):
return {"db": request.app.state.db}1
2
3进入 TestClient 上下文 -> lifespan 启动
GET /state -> {'db': '连接池'}
退出上下文 -> lifespan 关闭,释放 连接池-
资源挂在
app.state上,处理器里通过request.app.state或Depends取,比全局变量好测。 -
必须用
with TestClient(app) as client:才会触发,实测两种写法的事件差别:1
2不用 with: []
用 with: ['startup', 'shutdown']这是测试里最常见的「为什么我的连接池没建」。
-
-
老写法
@app.on_event("startup")实测还能用,但会打:1
DeprecationWarning: on_event is deprecated, use lifespan event handlers instead.
而且它有个已知缺陷:startup 里
yield不出东西,没法做「配对释放」,多资源时顺序也难保证。新代码一律lifespan。