6.2 KiB
6.2 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | order | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| python.fastapi.deps-auth-errors | 依赖注入、鉴权、中间件和统一错误处理 | lesson | python |
|
|
intermediate |
|
3 |
|
|
2 |
依赖注入、鉴权、中间件和统一错误处理
解释说明
依赖注入 Depends:把“获取当前用户”“获取数据库会话”“获取模型客户端”写成函数,路由参数里声明 Depends(函数),FastAPI 自动调用并把结果传进来。
- 依赖可以嵌套(获取当前用户 → 依赖解析 token → 依赖读取配置)。
- 带
yield的依赖可以做资源清理(请求结束后关闭数据库会话)。 - 同一个请求里同一个依赖默认只执行一次(有缓存)。
- 测试时用
app.dependency_overrides[原依赖] = 假实现替换,这是 FastAPI 最好用的测试手段。
鉴权:常见两种——给服务调用方的 API Key(请求头 X-API-Key),给用户的 JWT(Authorization: Bearer ...)。写成依赖:校验失败抛 HTTPException(401);权限不足抛 403。多租户系统里,依赖返回“当前租户”,后续查询都带上租户条件。
中间件:每个请求都要经过的逻辑——请求 ID、耗时日志、CORS。@app.middleware("http") 或添加 Starlette 中间件类。业务相关的校验(鉴权、权限)用依赖更合适,因为能精确控制作用于哪些路由。
统一错误处理:@app.exception_handler(异常类型)。
HTTPException→ 统一格式{"code", "message", "request_id"};RequestValidationError→ 把 422 转成统一格式,列出字段和原因;- 兜底
Exception→ 记录完整日志,返回 500 和通用提示,不返回内部细节。
和 Gin 对照:Depends 类似在中间件里 c.Set("user", ...) 然后在 handler 里取,但它有类型、按路由声明、可以在测试中替换。
案例
AI 服务给多个业务系统调用:每个系统一个 API Key,对应一个租户;每个请求日志都带 request_id 和租户;参数错误返回统一格式让调用方能程序化处理;内部异常(数据库连接串、堆栈)只进日志。
代码示例
from collections.abc import Iterator
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
class Session: # 模拟数据库会话
def __init__(self) -> None:
self.closed = False
def query_docs(self, tenant: str) -> list[str]:
return [f"{tenant} 的文档 {i}" for i in range(2)]
opened: list[Session] = []
def get_session() -> Iterator[Session]: # 带 yield 的依赖:请求结束后清理
s = Session()
opened.append(s)
try:
yield s
finally:
s.closed = True
KEYS = {"key-a": "tenant-a", "key-b": "tenant-b"}
def current_tenant(x_api_key: Annotated[str | None, Header()] = None) -> str:
if not x_api_key or x_api_key not in KEYS:
raise HTTPException(status_code=401, detail="API Key 无效")
return KEYS[x_api_key]
def require_admin(tenant: Annotated[str, Depends(current_tenant)]) -> str: # 依赖嵌套
if tenant != "tenant-a":
raise HTTPException(status_code=403, detail="没有管理权限")
return tenant
app = FastAPI()
@app.exception_handler(HTTPException)
async def http_error(request: Request, exc: HTTPException):
return JSONResponse({"code": exc.status_code, "message": exc.detail}, status_code=exc.status_code)
@app.get("/documents")
def list_docs(tenant: Annotated[str, Depends(current_tenant)],
db: Annotated[Session, Depends(get_session)]) -> list[str]:
return db.query_docs(tenant) # 查询一律带租户条件
@app.delete("/cache")
def clear_cache(tenant: Annotated[str, Depends(require_admin)]) -> dict:
return {"cleared_by": tenant}
c = TestClient(app)
print(c.get("/documents").json()) # {'code': 401, 'message': 'API Key 无效'}
print(c.get("/documents", headers={"X-API-Key": "key-b"}).json()) # ['tenant-b 的文档 0', 'tenant-b 的文档 1']
print(opened[-1].closed) # True:请求结束后会话已关闭
print(c.delete("/cache", headers={"X-API-Key": "key-b"}).status_code) # 403
# 测试里替换依赖:不需要真实的 API Key 和数据库
app.dependency_overrides[current_tenant] = lambda: "tenant-test"
print(c.get("/documents").json()) # ['tenant-test 的文档 0', ...]
app.dependency_overrides.clear()
请求 ID 中间件、422 统一格式、500 兜底的完整写法见 examples/chat-api/app.py。
易错点
- 鉴权写在中间件里对所有路径生效:健康检查、文档页面也被拦住;用依赖按路由声明更精确(或在路由器级别
dependencies=[Depends(...)])。 - 多租户只在入口校验,查询时忘了带租户条件:越权读到别的租户数据。把租户作为数据访问层的必传参数。
- 兜底异常处理器把
str(exc)返回给调用方:泄露数据库地址、内部路径。 - 测试后不清理
dependency_overrides:影响其他测试。 - API Key 明文存储、用
==比较:存哈希,用secrets.compare_digest比较以防时序攻击。 - 自定义路由用了
/docs、/redoc、/openapi.json:和 FastAPI 自带的文档页面冲突(本课示例最初就踩了这个坑,GET /docs返回的是 Swagger 页面)。换路径,或者FastAPI(docs_url=None)关闭/改名自带文档——生产环境通常也应该关闭或加鉴权。 - 在依赖里做很重的操作(每个请求都查一次配置中心):考虑缓存或在应用启动时加载。
面试常问
见 FastAPI 面试题:Depends 的原理和用途、中间件和依赖的区别、怎么统一错误格式、怎么做多租户隔离。