135 lines
6.2 KiB
Markdown
135 lines
6.2 KiB
Markdown
---
|
||
id: python.fastapi.deps-auth-errors
|
||
title: 依赖注入、鉴权、中间件和统一错误处理
|
||
type: lesson
|
||
domain: python
|
||
stack: [fastapi]
|
||
tags: [Depends, 鉴权, JWT, 中间件, 异常处理器, 多租户]
|
||
level: intermediate
|
||
prerequisites: [python.fastapi.routing-validation]
|
||
est_hours: 3
|
||
related: [python.fastapi.streaming-testing, backend.gin.middleware-auth, python.fastapi.interview]
|
||
week: [4, 5]
|
||
order: 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` 和租户;参数错误返回统一格式让调用方能程序化处理;内部异常(数据库连接串、堆栈)只进日志。
|
||
|
||
## 代码示例
|
||
|
||
```python
|
||
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](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 面试题](interview.md):Depends 的原理和用途、中间件和依赖的区别、怎么统一错误格式、怎么做多租户隔离。
|