FastAPI 系列 · 第 09 篇:中间件与错误处理:让服务更健壮
🎯 适合人群 :熟悉 Java Spring Boot
@ControllerAdvice/Filter/Interceptor,希望在 FastAPI 中实现同等工程化能力的工程师⏱️ 阅读时间 :约 28 分钟
💬 本文从统一错误响应、自定义异常体系出发,覆盖请求日志中间件、CORS、限流、结构化日志,并解析中间件洋葱模型执行顺序,让 shop-api 具备生产级健壮性
很多 FastAPI 项目在功能跑通之后,就急忙上线,结果前端收到的是 {"detail": [{"loc": ["body", "price"], "msg": "value is not a valid float"}]},运维在日志里找不到出问题的请求,攻击者每秒狂刷登录接口无人阻拦......这些问题不是业务 bug,而是工程化缺失。
本篇的目标是为 shop-api 补齐生产级的三道防线:
- 错误处理层:统一响应格式 + 自定义业务异常,让前端永远得到可预期的 JSON
- 中间件层:请求日志、Trace ID、CORS、限流,让问题可追溯、服务可生存
- 可观测性:结构化日志(structlog),让 ELK/Loki 能够高效过滤和关联日志
一、统一错误响应格式
1.1 为什么需要统一格式
FastAPI 默认的校验错误格式对前端开发者并不友好:
json
{
"detail": [
{
"loc": ["body", "price"],
"msg": "value is not a valid float",
"type": "type_error.float"
}
]
}
这种格式的问题在于:
- 结构不固定 :
detail字段可能是字符串(HTTPException),也可能是列表(RequestValidationError) - 缺少业务语义:没有业务错误码,前端无法根据错误码做精细化处理
- 缺少追踪 ID:出了问题无法关联到服务端日志
统一为业务友好格式:
json
{
"code": 422,
"message": "请求参数校验失败",
"detail": "body -> price: value is not a valid float",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
这四个字段各司其职:code 供前端 switch-case、message 供用户提示、detail 供开发者排查、request_id 供运维关联日志。
1.2 ErrorResponse Pydantic Schema
首先定义统一的错误响应结构体。Pydantic 是 FastAPI 用于数据校验和序列化的核心库,BaseModel 类似 Java 中的带注解的 POJO。
python
# app/schemas/error.py
from pydantic import BaseModel
from typing import Optional
class ErrorResponse(BaseModel):
"""
统一错误响应格式
类比 Spring Boot 的 ErrorAttributes 或自定义 ApiResult<Void>
"""
code: int # HTTP 状态码或业务错误码
message: str # 人类可读的错误描述(直接展示给用户)
detail: Optional[str] = None # 详细错误信息(可选,生产环境可隐藏技术细节)
request_id: Optional[str] = None # 请求追踪 ID(来自 X-Request-ID header)
1.3 全局异常处理器注册
FastAPI 提供 @app.exception_handler(ExceptionClass) 装饰器,与 Spring Boot 的 @ControllerAdvice + @ExceptionHandler 作用完全对应------将特定异常类型映射到特定的响应构建逻辑。
python
# app/main.py ------ 追加异常处理器
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.schemas.error import ErrorResponse
app = FastAPI()
# ── 处理 Pydantic 校验失败(422)──────────────────────────────────────────
# 类比 Spring @ExceptionHandler(MethodArgumentNotValidException.class)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
):
request_id = request.headers.get("X-Request-ID", "")
# 将 Pydantic 错误列表展平为可读字符串
# exc.errors() 返回 [{"loc": [...], "msg": "...", "type": "..."}, ...]
detail = "; ".join(
f"{' -> '.join(str(loc) for loc in err['loc'])}: {err['msg']}"
for err in exc.errors()
)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=ErrorResponse(
code=422,
message="请求参数校验失败",
detail=detail,
request_id=request_id,
).model_dump(),
)
# ── 处理 HTTP 异常(404、405、401 等)──────────────────────────────────────
# 类比 Spring @ExceptionHandler(ResponseStatusException.class)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(
request: Request, exc: StarletteHTTPException
):
request_id = request.headers.get("X-Request-ID", "")
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
code=exc.status_code,
message=exc.detail,
request_id=request_id,
).model_dump(),
)
💡 小贴士 :这里用的是 starlette.exceptions.HTTPException,而不是 fastapi.HTTPException。两者在功能上等价,但 FastAPI 的 HTTPException 继承自 Starlette 的,所以注册 StarletteHTTPException 的处理器可以捕获两者。
二、自定义业务异常体系
错误处理的最佳实践是将框架级异常 (HTTPException、RequestValidationError)和业务级异常(商品不存在、库存不足)分开管理。业务异常有自己的错误码体系,由专门的处理器转换为统一格式响应。
2.1 错误码枚举
python
# app/exceptions/codes.py
from enum import IntEnum
class ErrorCode(IntEnum):
"""
业务错误码枚举
规范:1xxx 用户类、2xxx 商品类、3xxx 订单类、9xxx 系统类
类比 Spring Boot 自定义 ResultCode 枚举
使用 IntEnum 而非普通枚举,方便直接序列化为整数
"""
# 通用
SUCCESS = 200
# 用户类
USER_NOT_FOUND = 1001
USER_ALREADY_EXISTS = 1002
INVALID_CREDENTIALS = 1003
TOKEN_EXPIRED = 1004
TOKEN_INVALID = 1005
PERMISSION_DENIED = 1006
# 商品类
PRODUCT_NOT_FOUND = 2001
PRODUCT_OUT_OF_STOCK = 2002
INVALID_PRICE = 2003
# 订单类
ORDER_NOT_FOUND = 3001
ORDER_CANNOT_CANCEL = 3002
INSUFFICIENT_STOCK = 3003
# 系统类
INTERNAL_ERROR = 9001
EXTERNAL_SERVICE_ERROR = 9002
RATE_LIMIT_EXCEEDED = 9003
2.2 AppException 基类
python
# app/exceptions/base.py
from fastapi import status
class AppException(Exception):
"""
业务异常基类
类比 Spring Boot 的 BusinessException 或 ServiceException 基类
设计原则:
- message:面向用户,可直接展示在前端 Toast 中
- code:面向程序,用于前端精细化错误处理
- http_status:HTTP 层语义,404/400/401/403
- detail:面向开发者,生产环境可选择隐藏
所有业务异常继承此类,统一由全局异常处理器捕获并格式化响应
"""
def __init__(
self,
message: str,
code: int,
http_status: int = status.HTTP_400_BAD_REQUEST,
detail: str | None = None,
):
self.message = message # 用户可见的错误描述
self.code = code # 业务错误码(来自 ErrorCode 枚举)
self.http_status = http_status # HTTP 状态码
self.detail = detail # 技术细节(可选)
super().__init__(message)
2.3 具体业务异常类
python
# app/exceptions/errors.py
from fastapi import status
from app.exceptions.base import AppException
from app.exceptions.codes import ErrorCode
class NotFoundError(AppException):
"""
资源不存在
类比 Spring ResponseStatusException(HttpStatus.NOT_FOUND)
示例:raise NotFoundError("商品", product_id)
响应:HTTP 404 + code=2001 + message="商品不存在"
"""
def __init__(self, resource: str, resource_id: int | str, code: int = ErrorCode.PRODUCT_NOT_FOUND):
super().__init__(
message=f"{resource}不存在",
code=code,
http_status=status.HTTP_404_NOT_FOUND,
detail=f"Resource '{resource}' with id={resource_id} not found",
)
class UnauthorizedError(AppException):
"""
未认证(Token 缺失或过期)
类比 Spring Security 的 AuthenticationException
"""
def __init__(self, message: str = "未登录或 Token 已过期"):
super().__init__(
message=message,
code=ErrorCode.TOKEN_EXPIRED,
http_status=status.HTTP_401_UNAUTHORIZED,
)
class ForbiddenError(AppException):
"""
已认证但无权限
类比 Spring Security 的 AccessDeniedException
"""
def __init__(self, message: str = "权限不足,无法执行此操作"):
super().__init__(
message=message,
code=ErrorCode.PERMISSION_DENIED,
http_status=status.HTTP_403_FORBIDDEN,
)
class BusinessError(AppException):
"""
通用业务异常(库存不足、状态流转不合法等)
类比 Spring 中手动抛出的 RuntimeException 子类
"""
def __init__(self, message: str, code: int = ErrorCode.INTERNAL_ERROR):
super().__init__(
message=message,
code=code,
http_status=status.HTTP_400_BAD_REQUEST,
)
class ConflictError(AppException):
"""
资源冲突(重复注册、重复下单等)
类比 Spring ResponseStatusException(HttpStatus.CONFLICT)
"""
def __init__(self, message: str, code: int = ErrorCode.USER_ALREADY_EXISTS):
super().__init__(
message=message,
code=code,
http_status=status.HTTP_409_CONFLICT,
)
2.4 注册 AppException 全局处理器
python
# app/main.py 追加
from app.exceptions.base import AppException
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
"""
统一处理所有业务异常
类比 Spring @ControllerAdvice + @ExceptionHandler(AppException.class)
执行链:业务代码 raise NotFoundError → 被此处理器捕获 → 格式化为 ErrorResponse
"""
request_id = request.headers.get("X-Request-ID", "")
return JSONResponse(
status_code=exc.http_status,
content=ErrorResponse(
code=exc.code,
message=exc.message,
detail=exc.detail,
request_id=request_id,
).model_dump(),
)
2.5 在 Service 层中使用业务异常
python
# app/services/product_service.py 片段
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions.errors import NotFoundError, BusinessError
from app.exceptions.codes import ErrorCode
from app.models.product import Product
from app.repositories import product_repo
async def get_product(db: AsyncSession, product_id: int) -> Product:
"""获取商品,不存在时抛 404 异常"""
product = await product_repo.get_by_id(db, product_id)
if not product:
# 抛出自定义异常,由全局处理器自动转换为 HTTP 404 响应
# 路由函数无需 try/except,业务逻辑更纯粹
raise NotFoundError("商品", product_id, code=ErrorCode.PRODUCT_NOT_FOUND)
return product
async def deduct_stock(
db: AsyncSession, product_id: int, quantity: int
) -> None:
"""扣减库存,库存不足时抛业务异常"""
product = await get_product(db, product_id)
if product.stock < quantity:
raise BusinessError(
message=f"库存不足,当前库存:{product.stock},需要:{quantity}",
code=ErrorCode.INSUFFICIENT_STOCK,
)
product.stock -= quantity
await db.commit()
await db.refresh(product)
🤔 这种分层设计的好处:路由函数(Router 层)只负责处理 HTTP 请求/响应的拼装,Service 层只负责业务逻辑,异常的"翻译"工作交给全局处理器------每一层职责单一,与 Spring 的分层理念完全一致。
三、请求日志与 Trace ID 中间件
3.1 为什么需要 Trace ID
想象线上发生了一次偶现的 500 错误。日志系统里有数千条并发请求的日志混在一起,如何快速找到那次失败请求的完整调用链?
Trace ID(追踪 ID) 就是解决这个问题的银弹:为每一个进入系统的 HTTP 请求分配一个全局唯一的字符串标识符,这个 ID 会附着在该请求触发的所有日志、数据库操作、MQ 消息上。排查问题时,只需在日志系统中过滤 request_id=xxx,即可看到完整的调用链路。
类比:Spring Cloud Sleuth(或现代的 Micrometer Tracing)自动注入的 traceId,在 FastAPI 中需要通过中间件手动实现。
3.2 BaseHTTPMiddleware 介绍
BaseHTTPMiddleware 是 Starlette(FastAPI 的底层框架)提供的中间件基类。继承它并重写 dispatch 方法,即可在请求到达路由前和响应返回后插入自定义逻辑。
请求进入 → dispatch(request, call_next) 前半段
↓
call_next(request) ← 调用下一层(路由或下一个中间件)
↓
响应返回 → dispatch(request, call_next) 后半段
类比:Spring 的 HandlerInterceptor,preHandle 对应 call_next 之前,afterCompletion 对应 call_next 之后。
3.3 TimingMiddleware 完整实现
python
# app/middleware/timing.py
import time
import uuid
import logging
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
class TimingMiddleware(BaseHTTPMiddleware):
"""
请求耗时 + Trace ID 中间件
功能:
1. 为每个请求生成唯一 X-Request-ID(若请求头已有则复用,支持上游透传)
2. 将 request_id 存入 request.state,供后续中间件和路由函数读取
3. 在响应头中透传 X-Request-ID(供前端关联日志)
4. 记录请求方法、路径、状态码、耗时(结构化字段)
类比:Spring HandlerInterceptor 的 preHandle + afterCompletion
"""
async def dispatch(self, request: Request, call_next) -> Response:
# 从请求头获取 Trace ID,若无则生成
# 支持 API Gateway / Nginx 等上游服务透传 Trace ID
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
# 存入 request.state,任意路由函数可通过 request.state.request_id 读取
# 类比 Spring 的 ThreadLocal / MDC
request.state.request_id = request_id
start_time = time.monotonic()
try:
response = await call_next(request)
except Exception as exc:
# 中间件层捕获到未处理异常:记录日志后重新抛出
# 重新抛出后由 FastAPI 的异常处理器接管(app_exception_handler 等)
elapsed = (time.monotonic() - start_time) * 1000
logger.error(
"Request failed with unhandled exception",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"elapsed_ms": round(elapsed, 2),
"error": str(exc),
"error_type": type(exc).__name__,
},
)
raise # 重要:必须重新抛出,让异常处理器处理
elapsed = (time.monotonic() - start_time) * 1000
# 在响应头中透传 Trace ID(方便前端开发者工具查看、API Gateway 采集)
response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time"] = f"{elapsed:.2f}ms"
# 结构化日志(字段化,便于 ELK/CloudWatch/Loki 过滤)
logger.info(
"Request completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"elapsed_ms": round(elapsed, 2),
"user_agent": request.headers.get("User-Agent", ""),
"client_ip": request.client.host if request.client else "",
},
)
return response
3.4 在路由中读取 Trace ID
python
# app/routers/products.py 片段
from fastapi import APIRouter, Request, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.deps import get_db
from app.services import product_service
router = APIRouter()
@router.get("/products/{product_id}")
async def get_product(
product_id: int,
request: Request, # 注入 Request 对象以读取 state
db: AsyncSession = Depends(get_db),
):
# request.state.request_id 由 TimingMiddleware 在请求进入时注入
# getattr 加默认值防止单元测试中 state 未设置的情况
request_id = getattr(request.state, "request_id", "unknown")
product = await product_service.get_product(db, product_id)
return product
📝 补充说明 :request.state 是一个简单的命名空间对象(类似 Python 的 SimpleNamespace),可以挂载任意属性。中间件通过它向下游传递数据,是 FastAPI 中实现跨层数据传递的惯用方式。
四、CORS 配置
4.1 什么是 CORS
CORS(跨源资源共享,Cross-Origin Resource Sharing) 是浏览器出于安全考虑实施的同源策略的补充机制。当前端页面(如 http://localhost:3000)向不同源的后端(如 http://localhost:8000)发送请求时,浏览器会先发送一个 OPTIONS 预检请求,询问服务端是否允许跨源访问。
服务端需要在响应头中返回正确的 Access-Control-Allow-* 字段,浏览器才会放行实际请求。
浏览器 FastAPI 服务端
| |
|-- OPTIONS /api/products ---------> | 预检请求
| Origin: http://localhost:3000 |
| |
|<-- 200 OK ------------------------ | 预检响应
| Access-Control-Allow-Origin: * |
| Access-Control-Allow-Methods: * |
| |
|-- GET /api/products -------------> | 实际请求(浏览器放行)
| |
类比:Spring WebMvcConfigurer.addCorsMappings() 或路由上的 @CrossOrigin 注解。
4.2 CORSMiddleware 配置
FastAPI 通过 Starlette 内置的 CORSMiddleware 处理 CORS:
python
# app/main.py --- CORS 配置
# ⚠️ 重要:CORS 中间件要在 TimingMiddleware 之后注册(即在外层)
# 确保所有响应(包括 4xx/5xx 错误响应)都携带 CORS 头
from fastapi.middleware.cors import CORSMiddleware
import os
# 从环境变量读取允许的来源列表
# 开发环境:ALLOWED_ORIGINS="*"
# 生产环境:ALLOWED_ORIGINS="https://shop.example.com,https://admin.example.com"
raw_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000")
ALLOWED_ORIGINS = raw_origins.split(",")
# 判断是否为通配符模式(通配符模式下不能开启 allow_credentials)
IS_WILDCARD = ALLOWED_ORIGINS == ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
# ⚠️ allow_origins=["*"] 时 allow_credentials 必须为 False
# 浏览器规范:通配符来源不允许携带凭据(Cookie/Authorization)
allow_credentials=not IS_WILDCARD,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
# expose_headers:允许前端 JS 读取的响应头(默认只有 CORS 安全响应头)
expose_headers=["X-Request-ID", "X-Process-Time"],
max_age=600, # 预检请求结果缓存 600 秒,减少 OPTIONS 请求数量
)
4.3 开发 vs 生产环境配置
bash
# .env.development ------ 开发环境宽松,方便本地联调
ALLOWED_ORIGINS=*
# .env.production ------ 生产环境严格,只允许已知域名
ALLOWED_ORIGINS=https://shop.example.com,https://admin.example.com
⚠️ 注意 :生产环境中绝对不要使用 ALLOWED_ORIGINS=*,这会允许任意网站通过浏览器向你的 API 发送请求,存在 CSRF 风险。应明确列出前端域名白名单。
五、限流:slowapi
5.1 为什么需要限流
一个没有限流保护的 API 就像没有锁的大门。常见威胁包括:
- 暴力破解 :对
/auth/token接口高频尝试密码 - 接口滥用:爬虫对商品列表接口每秒数百次抓取
- DDoS 放大:利用复杂查询接口耗尽服务器 CPU
限流(Rate Limiting) 是指限制单个客户端(通常按 IP 或用户 ID)在单位时间内的请求次数,超出限制时返回 429 Too Many Requests。
类比:Spring Cloud Gateway 的 RequestRateLimiter GatewayFilter,或 Spring Security 的登录失败锁定机制。
5.2 安装与核心概念
slowapi 是 FastAPI/Starlette 生态中最流行的限流库,基于 limits 库实现,支持内存、Redis 等多种存储后端。
bash
pip install slowapi
# 若需要 Redis 后端支持(生产推荐):
pip install slowapi[redis]
slowapi 的核心概念:
Limiter:限流器,持有限流规则和存储后端key_func:从 Request 中提取限流 key 的函数(通常是 IP 或用户 ID)@limiter.limit("N/period"):路由级限流装饰器,period可以是second/minute/hour/day
5.3 配置限流器
python
# app/core/limiter.py
from slowapi import Limiter
from slowapi.util import get_remote_address
from starlette.requests import Request
# 默认限流器:基于客户端 IP 地址
# get_remote_address 从 request.client.host 获取 IP
# 类比 Spring RateLimiter(key=ClientIP)
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200/minute"], # 全局默认限制(每 IP 每分钟 200 次)
)
def get_user_id_or_ip(request: Request) -> str:
"""
认证接口的限流 key 策略:
- 已登录用户:按 user_id 限流(防止换 IP 绕过)
- 匿名用户:按 IP 限流
使用 request.state.current_user(由 JWT 依赖注入中间件设置)
"""
user = getattr(request.state, "current_user", None)
if user:
return f"user:{user.id}"
return get_remote_address(request)
# 用于需要用户感知限流的接口(如个人操作类接口)
user_aware_limiter = Limiter(key_func=get_user_id_or_ip)
5.4 在 main.py 中注册
python
# app/main.py 追加
from slowapi.errors import RateLimitExceeded
from app.core.limiter import limiter
from app.schemas.error import ErrorResponse
# 将 limiter 挂载到 app.state,slowapi 内部需要此引用
app.state.limiter = limiter
# 自定义 429 响应格式,统一为 ErrorResponse 结构
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
request_id = getattr(request.state, "request_id", "")
return JSONResponse(
status_code=429,
content=ErrorResponse(
code=429,
message="请求过于频繁,请稍后再试",
detail=str(exc.detail),
request_id=request_id,
).model_dump(),
)
5.5 在路由中使用
python
# app/routers/auth.py ------ 对高风险接口严格限流
from fastapi import APIRouter, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.limiter import limiter
from app.deps import get_db
router = APIRouter()
# ✅ slowapi 要求:Request 必须是路由函数的第一个位置参数
@router.post("/token")
@limiter.limit("5/minute") # 登录接口:每 IP 每分钟最多 5 次,防暴力破解
async def login(
request: Request, # ⚠️ Request 必须在第一位,slowapi 从此提取 IP
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
# ... 登录业务逻辑
pass
@router.post("/register")
@limiter.limit("3/minute") # 注册接口:每 IP 每分钟 3 次,防批量注册
async def register(
request: Request,
# ... 其他参数
):
pass
python
# app/routers/products.py ------ 普通查询接口宽松限流
from app.core.limiter import limiter
@router.get("/products")
@limiter.limit("100/minute") # 商品列表:每分钟 100 次,应对正常浏览流量
async def list_products(
request: Request,
# ... 其他参数
):
pass
@router.get("/products/{product_id}")
@limiter.limit("200/minute") # 商品详情:更宽松
async def get_product(
request: Request,
product_id: int,
# ... 其他参数
):
pass
5.6 限流存储后端选择
| 后端 | 适用场景 | 配置示例 |
|---|---|---|
| 内存(默认) | 单进程开发/测试 | Limiter(key_func=get_remote_address) |
| Redis | 多进程/多实例生产 | Limiter(key_func=..., storage_uri="redis://localhost:6379/1") |
💡 生产环境必须使用 Redis 后端 。内存后端的限流状态在每个 worker 进程中独立维护,多进程部署(如 gunicorn -w 4)时每个 worker 各自计数,实际限流效果是配置值的 N 倍宽松。
六、结构化日志(structlog)
6.1 为什么需要结构化日志
传统的文本日志难以被机器解析:
2024-01-15 10:23:45 INFO Processing order 123 for user 456, total=299.9
当日志量达到每天数亿条时,想从中过滤出"用户 456 的所有订单操作",就需要依赖正则表达式或复杂的文本解析------脆弱且低效。
结构化 JSON 日志:
json
{
"timestamp": "2024-01-15T10:23:45.123Z",
"level": "info",
"event": "order_created",
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": 456,
"order_id": 123,
"total": 299.9,
"logger": "app.services.order_service"
}
结构化日志的优势:
- 可精确过滤 :在 ELK/Grafana Loki 中直接
user_id=456 AND event="order_created"查询 - 可聚合统计 :统计某时间段内
event="order_created"的数量、平均total - 可串联链路 :通过
request_id聚合一次请求的所有日志行
类比:Spring Boot + Logback 配置 JSON Appender,结合 MDC(Mapped Diagnostic Context)注入 traceId。
6.2 安装与配置
structlog 是 Python 生态中最流行的结构化日志库,与 Python 标准 logging 完全兼容,支持 JSON 输出和彩色开发控制台输出两种模式。
bash
pip install structlog
python
# app/core/logging.py
import structlog
import logging
import sys
from typing import Any
def setup_logging(json_format: bool = True, log_level: str = "INFO") -> None:
"""
配置 structlog 日志系统
Args:
json_format: True 输出 JSON(生产环境),False 输出彩色文本(开发环境)
log_level: 日志级别,默认 INFO
"""
# 设置 Python 标准 logging 的根级别
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=getattr(logging, log_level.upper(), logging.INFO),
)
# shared_processors:每条日志都会经过的处理器链
shared_processors: list[Any] = [
# 合并通过 bind_contextvars 绑定的上下文变量(如 request_id)
# 类比 Spring MDC.put() 注入的字段
structlog.contextvars.merge_contextvars,
# 添加 level 字段(info/warning/error)
structlog.processors.add_log_level,
# 添加 ISO 8601 时间戳
structlog.processors.TimeStamper(fmt="iso"),
# 添加 logger 名称(便于定位日志来源)
structlog.stdlib.add_logger_name,
# 在日志中添加调用栈信息(仅 ERROR 级别)
structlog.processors.StackInfoRenderer(),
]
if json_format:
# 生产环境:JSON 格式,便于 ELK/Loki 解析
processors = shared_processors + [
# 将 Exception 的 traceback 序列化为结构化字典而非纯文本
structlog.processors.dict_tracebacks,
structlog.processors.JSONRenderer(),
]
else:
# 开发环境:彩色控制台,便于肉眼阅读
processors = shared_processors + [
structlog.dev.ConsoleRenderer(colors=True)
]
structlog.configure(
processors=processors,
# 创建过滤器:低于指定级别的日志直接丢弃
wrapper_class=structlog.make_filtering_bound_logger(
getattr(logging, log_level.upper(), logging.INFO)
),
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
# 第一次使用后缓存 logger,提升性能
cache_logger_on_first_use=True,
)
6.3 在 main.py 的 lifespan 中初始化
python
# app/main.py --- lifespan 中调用
from contextlib import asynccontextmanager
from app.core.logging import setup_logging
from app.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
# 应用启动:初始化日志(最优先,确保启动日志也是结构化的)
setup_logging(
json_format=settings.ENV != "development",
log_level=settings.LOG_LEVEL,
)
# ... 其他初始化(Redis、DB 连接等)
yield
# 应用关闭
# ... 清理资源
app = FastAPI(lifespan=lifespan)
6.4 在中间件中绑定 Trace ID 到日志上下文
python
# app/middleware/timing.py ------ 升级版:集成 structlog
import time
import uuid
import structlog
from structlog.contextvars import bind_contextvars, clear_contextvars
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = structlog.get_logger()
class TimingMiddleware(BaseHTTPMiddleware):
"""
请求耗时 + Trace ID 中间件(structlog 版)
"""
async def dispatch(self, request: Request, call_next) -> Response:
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
request.state.request_id = request_id
# ✅ 关键:将 request_id 绑定到当前请求的协程上下文
# 之后在本次请求处理链中的所有 structlog 调用都会自动携带 request_id
# 类比 Spring MDC.put("traceId", requestId)
#
# clear_contextvars() 必须在每次请求开始时调用,
# 防止协程上下文在连接复用时携带上一次请求的数据
clear_contextvars()
bind_contextvars(
request_id=request_id,
method=request.method,
path=request.url.path,
)
start_time = time.monotonic()
try:
response = await call_next(request)
except Exception as exc:
elapsed = (time.monotonic() - start_time) * 1000
logger.error(
"request_failed",
elapsed_ms=round(elapsed, 2),
error=str(exc),
error_type=type(exc).__name__,
)
raise
elapsed = (time.monotonic() - start_time) * 1000
response.headers["X-Request-ID"] = request_id
response.headers["X-Process-Time"] = f"{elapsed:.2f}ms"
# structlog 自动携带已绑定的上下文字段(request_id、method、path)
logger.info(
"request_completed",
status_code=response.status_code,
elapsed_ms=round(elapsed, 2),
)
return response
6.5 在业务代码中使用 structlog
python
# app/services/order_service.py 片段
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.order import OrderCreate
from app.models.order import Order
from app.repositories import order_repo, product_repo
from app.exceptions.errors import BusinessError
from app.exceptions.codes import ErrorCode
logger = structlog.get_logger() # 模块级 logger,不传名称,structlog 自动推断
async def create_order(
db: AsyncSession, user_id: int, order_data: OrderCreate
) -> Order:
"""创建订单,自动携带请求上下文日志"""
# 所有 logger 调用都会自动附加 TimingMiddleware 绑定的 request_id
logger.info(
"creating_order",
user_id=user_id,
item_count=len(order_data.items),
)
# 库存检查
for item in order_data.items:
product = await product_repo.get_by_id(db, item.product_id)
if not product or product.stock < item.quantity:
raise BusinessError(
message=f"商品 {item.product_id} 库存不足",
code=ErrorCode.INSUFFICIENT_STOCK,
)
order = await order_repo.create(db, user_id, order_data)
logger.info(
"order_created",
order_id=order.id,
total=float(order.total_price),
user_id=user_id,
)
return order
JSON 格式输出(生产环境):
json
{"timestamp": "2024-01-15T10:23:45.123Z", "level": "info", "event": "creating_order", "request_id": "550e8400-...", "method": "POST", "path": "/orders", "user_id": 456, "item_count": 2}
{"timestamp": "2024-01-15T10:23:45.187Z", "level": "info", "event": "order_created", "request_id": "550e8400-...", "method": "POST", "path": "/orders", "order_id": 42, "total": 299.9, "user_id": 456}
同一个 request_id 贯穿整个请求生命周期------这正是可观测性的核心价值所在。
七、中间件执行顺序:洋葱模型
7.1 洋葱模型原理
FastAPI(Starlette)中间件遵循"洋葱模型"------就像切洋葱时一层一层剥开,每个中间件包裹着内部的中间件和路由处理器。
核心规律:add_middleware 调用越晚,中间件越靠外(越早处理请求,越晚处理响应)。
HTTP 请求进入
│
▼
┌─────────────────────────────────────────────┐
│ CORS Middleware(最后注册 → 最外层) │
│ ┌───────────────────────────────────────┐ │
│ │ TimingMiddleware(最先注册 → 最内层) │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Route Handler │ │ │
│ │ │ (异常处理器在此层生效) │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│
▼
HTTP 响应返回
请求流向:CORS → TimingMiddleware → Route Handler
响应流向:Route Handler → TimingMiddleware → CORS
类比:Spring FilterChain,order 数值越小越先执行(越外层),但 Spring 是通过 @Order 数字控制,FastAPI 是通过注册顺序(后注册=外层)控制------方向相反,务必注意。
7.2 各层中间件的职责定位
| 中间件 | 层次 | 原因 |
|---|---|---|
| TimingMiddleware | 最内层(最先注册) | 需要尽可能精确计时,不应包含 CORS 处理的耗时 |
| CORSMiddleware | 最外层(最后注册) | 必须在所有响应上添加 CORS 头,包括 4xx/5xx 错误响应 |
🤔 为什么 CORS 要在最外层:如果 CORS 在内层,当 TimingMiddleware 或路由抛出异常时,CORS 头可能不会被添加,导致浏览器因缺少 CORS 头而报错,掩盖了真正的业务错误。
7.3 正确的中间件注册顺序
python
# app/main.py --- 中间件注册顺序(从内到外)
# 注册顺序就是洋葱从内到外的顺序
# ① 最先注册 → 最内层(最靠近业务逻辑)
# 计时要尽可能接近路由,确保计时精度
app.add_middleware(TimingMiddleware)
# slowapi 不通过 add_middleware 注入,而是通过装饰器 + app.state.limiter
# 执行时机在路由函数调用前,位置上等同于很内层的 Filter
# ② 最后注册 → 最外层(第一个接触请求,最后处理响应)
# CORS 需要覆盖所有响应,包括错误响应
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=not IS_WILDCARD,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-ID", "X-Process-Time"],
)
八、完整 main.py 汇总
集成了本篇所有功能后的完整 app/main.py:
python
# app/main.py --- 完整版(集成统一错误处理、中间件、限流)
from contextlib import asynccontextmanager
import os
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi.errors import RateLimitExceeded
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.config import settings
from app.core.limiter import limiter
from app.core.logging import setup_logging
from app.database import init_db
from app.exceptions.base import AppException
from app.middleware.timing import TimingMiddleware
from app.schemas.error import ErrorResponse
# 路由模块(按需引入)
from app.routers import auth, products, orders
# ── lifespan:应用生命周期管理 ─────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动阶段:按依赖顺序初始化
setup_logging(
json_format=settings.ENV != "development",
log_level=settings.LOG_LEVEL,
)
await init_db()
yield
# 关闭阶段:清理资源(关闭数据库连接池等)
# ── FastAPI 应用实例 ────────────────────────────────────────────────────────
app = FastAPI(
title="Shop API",
description="基于 FastAPI 的简化版电商 REST API",
version="0.1.0",
lifespan=lifespan,
# 生产环境关闭自动文档(安全考虑)
docs_url="/docs" if settings.ENV != "production" else None,
redoc_url="/redoc" if settings.ENV != "production" else None,
)
# ── 注册中间件(顺序重要:先注册 = 内层,后注册 = 外层)──────────────────────
# 第一层(最内层):请求计时 + Trace ID 注入
app.add_middleware(TimingMiddleware)
# 第二层(最外层):CORS 跨域处理
raw_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000")
ALLOWED_ORIGINS = raw_origins.split(",")
IS_WILDCARD = ALLOWED_ORIGINS == ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=not IS_WILDCARD, # 通配符来源时必须关闭
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Request-ID", "X-Process-Time"],
max_age=600,
)
# ── 注册 slowapi 限流器 ─────────────────────────────────────────────────────
app.state.limiter = limiter
# ── 注册全局异常处理器 ─────────────────────────────────────────────────────
# 注意:处理器按异常类型精确匹配,无需关心注册顺序
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
"""429:限流触发"""
request_id = getattr(request.state, "request_id", "")
return JSONResponse(
status_code=429,
content=ErrorResponse(
code=429,
message="请求过于频繁,请稍后再试",
detail=str(exc.detail),
request_id=request_id,
).model_dump(),
)
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
"""业务异常:NotFoundError、BusinessError、ForbiddenError 等"""
request_id = getattr(request.state, "request_id", "")
return JSONResponse(
status_code=exc.http_status,
content=ErrorResponse(
code=exc.code,
message=exc.message,
detail=exc.detail,
request_id=request_id,
).model_dump(),
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""422:Pydantic 请求体/查询参数校验失败"""
request_id = getattr(request.state, "request_id", "")
detail = "; ".join(
f"{' -> '.join(str(loc) for loc in err['loc'])}: {err['msg']}"
for err in exc.errors()
)
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content=ErrorResponse(
code=422,
message="请求参数校验失败",
detail=detail,
request_id=request_id,
).model_dump(),
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
"""HTTP 标准错误:404、405、401 等"""
request_id = getattr(request.state, "request_id", "")
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
code=exc.status_code,
message=str(exc.detail),
request_id=request_id,
).model_dump(),
)
# ── 注册路由 ────────────────────────────────────────────────────────────────
app.include_router(auth.router, prefix="/auth", tags=["认证"])
app.include_router(products.router, prefix="/products", tags=["商品"])
app.include_router(orders.router, prefix="/orders", tags=["订单"])
# ── 健康检查(无需认证,供负载均衡器探活使用)──────────────────────────────
@app.get("/health", tags=["系统"])
async def health_check():
return {"status": "ok", "version": app.version, "env": settings.ENV}
九、完整项目目录结构
经过本篇改造后,shop-api 的目录结构如下:
shop-api/
├── app/
│ ├── core/
│ │ ├── limiter.py # slowapi 限流器配置
│ │ └── logging.py # structlog 初始化
│ ├── exceptions/
│ │ ├── __init__.py
│ │ ├── base.py # AppException 基类
│ │ ├── codes.py # ErrorCode 枚举
│ │ └── errors.py # 具体业务异常类
│ ├── middleware/
│ │ ├── __init__.py
│ │ └── timing.py # TimingMiddleware
│ ├── schemas/
│ │ ├── error.py # ErrorResponse(新增)
│ │ └── ... # 其他 Pydantic Schema
│ ├── routers/
│ │ ├── auth.py
│ │ ├── products.py
│ │ └── orders.py
│ ├── services/
│ │ ├── product_service.py # 使用 AppException
│ │ └── order_service.py # 使用 structlog
│ ├── config.py
│ └── main.py # 本篇核心:汇总所有中间件和异常处理器
├── .env.development
├── .env.production
└── requirements.txt
十、常见坑与最佳实践
坑一:中间件执行顺序与直觉相反
python
# ❌ 认为"先注册的先执行",导致 CORS 在内层
app.add_middleware(CORSMiddleware, ...) # 想让 CORS 第一个处理请求?
app.add_middleware(TimingMiddleware) # 这个反而在外层(先处理请求)!
# 症状:4xx/5xx 错误响应缺少 CORS 头,导致前端报 CORS 错误而非看到真正的业务错误
# ✅ 记住洋葱模型:后注册 = 外层 = 更早处理请求、更晚处理响应
app.add_middleware(TimingMiddleware) # 最先注册 → 最内层
app.add_middleware(CORSMiddleware, ...) # 最后注册 → 最外层,覆盖所有响应
坑二:allow_origins 通配符与 allow_credentials 同时开启
python
# ❌ 浏览器规范明确禁止:通配符来源 + 允许凭据同时存在
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True, # 错误!浏览器会拒绝此预检响应
)
# 报错:The value of the 'Access-Control-Allow-Origin' header in the response
# must not be the wildcard '*' when the request's credentials mode is 'include'.
# ✅ 方案一:明确指定来源白名单 + 允许凭据
app.add_middleware(
CORSMiddleware,
allow_origins=["https://shop.example.com"], # 明确域名
allow_credentials=True,
)
# ✅ 方案二:通配符来源 + 不允许凭据(适合无登录态的公开 API)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False, # 不允许 Cookie / Authorization header
)
坑三:在 BaseHTTPMiddleware 中读取请求体导致 Body 被消费
python
# ❌ 在中间件中直接读取 request.body(),路由函数读不到请求体
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
body = await request.body() # 消费了底层的 receive() 流!
logger.info("request_body", body=body.decode())
response = await call_next(request) # 路由函数 request.body() 返回 b""
return response
# ✅ 读取后重置 receive 函数,让路由函数也能读到完整 body
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
body = await request.body()
logger.info("request_body", body=body[:500].decode(errors="replace"))
# 重置 body 流:将已读取的 body 封装为新的 receive 可调用对象
async def receive():
return {"type": "http.request", "body": body, "more_body": False}
request._receive = receive # 替换底层接收函数
response = await call_next(request)
return response
⚠️ 注意:读取请求体会缓冲整个请求体到内存,对大文件上传接口要谨慎使用。大多数情况下,可以通过日志字段(路径、方法、User-Agent)代替请求体审计。
坑四:混用 HTTPException 与 AppException 导致响应格式不统一
python
# ❌ 在路由/Service 层直接抛 HTTPException,绕过了统一异常体系
from fastapi import HTTPException
@router.get("/products/{product_id}")
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
product = await product_repo.get_by_id(db, product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# 响应格式:{"code": 404, "message": "Product not found"}
# 但如果其他地方抛 AppException,格式是一样的吗?
# ✅ Service 层统一使用 AppException 子类,格式和错误码完全可控
async def get_product(db: AsyncSession, product_id: int) -> Product:
product = await product_repo.get_by_id(db, product_id)
if not product:
raise NotFoundError("商品", product_id)
# 响应格式:{"code": 2001, "message": "商品不存在", "detail": "...", "request_id": "..."}
# 前端可以根据 code=2001 做精细化处理(如跳转商品不存在页面)
坑五:slowapi 装饰器要求 Request 参数必须在第一位
python
# ❌ Request 不是第一个参数,slowapi 无法提取客户端 IP,抛 AttributeError
@router.post("/token")
@limiter.limit("5/minute")
async def login(
form_data: OAuth2PasswordRequestForm = Depends(), # Depends 参数在前
request: Request = None, # Request 在后,错误!
):
...
# ✅ Request 必须是第一个参数(slowapi 内部通过 args[0] 获取 Request)
@router.post("/token")
@limiter.limit("5/minute")
async def login(
request: Request, # ✅ 必须第一位
form_data: OAuth2PasswordRequestForm = Depends(),
):
...
坑六:多进程部署时忘记使用 Redis 限流存储
python
# ❌ 生产环境 4 个 worker 进程,每个独立计数,实际限流是配置值 4 倍宽松
limiter = Limiter(key_func=get_remote_address) # 内存存储,仅进程内有效
# ✅ 生产环境使用 Redis 存储,跨进程共享计数器
limiter = Limiter(
key_func=get_remote_address,
storage_uri=os.getenv("REDIS_URL", "redis://localhost:6379/1"),
# 单独使用 db=1,与业务缓存隔离
)
十一、端到端验证
完成以上所有配置后,可以用 curl 或 httpie 验证各功能是否正常工作:
bash
# 1. 验证统一错误格式(故意发错误的价格类型)
curl -X POST http://localhost:8000/products \
-H "Content-Type: application/json" \
-d '{"name": "测试商品", "price": "not-a-number", "stock": 10}'
# 期望响应:
# {"code": 422, "message": "请求参数校验失败", "detail": "body -> price: ...", "request_id": "..."}
# 2. 验证 Trace ID 透传
curl -v http://localhost:8000/products/999
# 期望:响应头中包含 X-Request-ID: <uuid>
# 3. 验证限流(快速发送多次登录请求)
for i in $(seq 1 6); do
curl -X POST http://localhost:8000/auth/token \
-d "username=test&password=wrong"
done
# 第 6 次期望响应:
# {"code": 429, "message": "请求过于频繁,请稍后再试", ...}
# 4. 验证 CORS 预检请求
curl -v -X OPTIONS http://localhost:8000/products \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: GET"
# 期望响应头:Access-Control-Allow-Origin: http://localhost:3000(或 *)
总结
| 功能 | FastAPI 实现方式 | Spring Boot 对标 | 关键注意点 |
|---|---|---|---|
| 统一错误格式 | @app.exception_handler |
@ControllerAdvice |
同时覆盖 ValidationError + HTTPException + AppException |
| 自定义业务异常 | AppException 基类体系 |
BusinessException |
错误码枚举规范,按业务领域分段 |
| 请求日志中间件 | BaseHTTPMiddleware |
HandlerInterceptor |
读取 body 会消费流,需重置 _receive |
| Trace ID 注入 | request.state.request_id |
Spring Cloud Sleuth | 在响应头中透传,供前端和 API Gateway 使用 |
| CORS | CORSMiddleware |
WebMvcConfigurer |
allow_origins=["*"] 时必须 allow_credentials=False |
| 限流 | slowapi + 装饰器 |
RequestRateLimiter | Request 参数必须第一位;生产用 Redis 后端 |
| 结构化日志 | structlog |
Logback + MDC | bind_contextvars 绑定请求上下文,clear_contextvars 防泄漏 |
| 中间件顺序 | 洋葱模型(后注册 = 外层) | FilterChain | 与 Spring @Order 数字逻辑相反,CORS 必须在最外层 |
健壮性不是锦上添花,而是服务上线的前提条件------统一错误格式让前端可预期,Trace ID 让问题可追溯,限流让服务可生存。
参考资料
- FastAPI 中间件文档
- FastAPI 错误处理文档
- Starlette BaseHTTPMiddleware
- slowapi 文档
- structlog 文档
- CORS 规范(MDN)
- limits 库文档(slowapi 底层)
下期预告
第 10 篇将进入测试实战:从单元到集成------pytest + AsyncClient + dependency_overrides
核心内容:
pytest-asyncio配置与 async test fixturesAsyncClient发起 HTTP 集成测试dependency_overrides替换数据库依赖实现测试隔离- 使用
factory_boy+faker生成测试数据 - GitHub Actions CI workflow 完整配置
敬请期待 🎯