第 19 章:最佳实践与速查表
最后一章不引入新 API,只做一件事:把前 18 章反复出现的取舍固化成规范。前半部分是清单(怎么组织、怎么命名、怎么设计接口),后半部分是速查表(忘了就翻)。
学习目标
- 掌握一套可直接落地的项目结构规范与目录职责划分
- 理解 Python 侧
snake_case与 API 侧camelCase的转换方案,能统一实施 - 能按语义选择 HTTP 状态码,并设计一致的错误响应结构与分页参数
- 建立两份可自查的清单:性能清单与安全清单
- 能对照跨章坑点总表定位问题,并通过速查表快速回忆语法
19.1 项目结构规范
目录职责表
| 目录 | 职责 | 允许依赖 | 禁止出现 |
|---|---|---|---|
app/main.py | 组装应用:路由、中间件、异常处理器、lifespan | 所有层 | 业务逻辑、SQL |
app/core/ | 配置与安全原语(Settings、密码哈希、JWT) | 无 | 数据库会话、HTTP 异常 |
app/db/ | 引擎、会话工厂、DeclarativeBase、get_db | core | 业务逻辑 |
app/models/ | SQLAlchemy 表定义与关系 | db | 校验逻辑、HTTP |
app/schemas/ | Pydantic 输入/输出模型 | 标准库、pydantic | SQLAlchemy 查询 |
app/repositories/ | 数据访问:select()、flush()、分页 | models | 业务规则、HTTPException |
app/services/ | 业务规则、事务编排、抛领域异常 | repositories、schemas、models | fastapi 的任何 import |
app/api/ | 路由、依赖、状态码、response_model | services、schemas | 直接写 SQL |
依赖方向单向向下。可以用一条 grep 自查:
# services / repositories 里出现 fastapi,说明分层被打破
grep -rn "import fastapi\|from fastapi" app/services app/repositories && echo "❌ 分层违规"模块命名约定
| 类型 | 约定 | 示例 |
|---|---|---|
| 包与模块 | 全小写、单数、无下划线 | models/post.py 而不是 models/Posts.py |
| 路由文件 | 按资源拆分,与 URL 前缀同名 | posts.py 对应 /posts |
| 路由变量 | 模块级 router,一处一个 | router = APIRouter(prefix="/posts", tags=["posts"]) |
| Service | <资源>Service | PostService、CommentService |
| Repository | <资源>Repository | PostRepository |
| 依赖别名 | <东西>Dep | SessionDep、PostServiceDep |
| 异常 | <原因>Error | NotFoundError、ConflictError |
不要按技术层拆路由文件(api/v1/get_posts.py、api/v1/create_post.py)——一个资源的增删改查应该在同一文件里,否则改一个需求要开五个文件。
💡 当单个资源文件超过 300 行,先考虑把"管理端"与"用户端"拆成两个 router(
/admin/posts与/posts),而不是按 HTTP 方法拆。
19.2 代码风格与命名
Python 侧 vs API 侧
| 位置 | 风格 | 示例 |
|---|---|---|
| 变量 / 函数 / 模块 | snake_case | def get_post_service() |
| 类名 | PascalCase | class PostRepository |
| 常量 | UPPER_SNAKE_CASE | MAX_PAGE_SIZE = 100 |
| 表名 / 列名 | snake_case、表名复数 | posts.author_id |
| JSON 字段 | camelCase | {"displayName": "...", "createdAt": "..."} |
Python 世界用 snake_case、前端世界用 camelCase 是常态。不要在模型里逐字段写 alias=——一个基类 + alias_generator 就能全局转换:
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class APIModel(BaseModel):
"""所有对外的请求/响应模型的基类。"""
model_config = ConfigDict(
alias_generator=to_camel, # display_name <-> displayName
populate_by_name=True, # 用 Python 字段名构造也合法(内部代码友好)
from_attributes=True, # 可直接接受 ORM 对象
str_strip_whitespace=True,
)
class UserOut(APIModel):
id: int
display_name: str # 对外:displayName
created_at: datetime # 对外:createdAt
out = UserOut(id=1, display_name="moqian", created_at=datetime.now())
out.model_dump(by_alias=True) # 键名转好,但 datetime 仍是 Python 对象
out.model_dump(mode="json", by_alias=True)
# {'id': 1, 'displayName': 'moqian', 'createdAt': '2025-01-01T00:00:00Z'}🔑 FastAPI 默认
response_model_by_alias=True,所以响应会自动输出camelCase,不需要在每个路由上写参数。而请求体解析时,alias_generator生成的是校验别名,客户端必须传camelCase;populate_by_name=True只是额外允许服务端内部用snake_case构造。
如果只有个别字段需要特殊名字(如外部契约要求 from),再单独用 Field(alias="from")。
模型命名
| 后缀 | 含义 | 含密码等敏感字段 | 用 from_attributes |
|---|---|---|---|
XxxCreate | 创建请求体 | 可以(如 password) | 否 |
XxxUpdate | 更新请求体,字段全可选 | 否 | 否 |
XxxOut | 响应体 | 绝不 | 是 |
XxxInDB | 数据库内部表示(如带 hashed_password) | 是 | 是 |
XxxFilter | 复杂查询条件封装 | 否 | 否 |
Page[XxxOut] | 分页响应容器 | — | — |
命名本身就是文档:看到 UserOut 就知道能直接返回给客户端,看到 UserInDB 就知道它只在服务端流转。
路由函数命名与 operation_id
@router.get(
"/{post_id}",
response_model=PostOut,
operation_id="getPost", # 客户端 SDK 生成的方法名
summary="查看文章详情", # 侧边栏一级条目
description="未发布的文章仅作者本人与管理员可见。", # 展开后的详细说明
responses={404: {"description": "文章不存在"}},
)
async def get_post(...) -> PostOut:
...| 项 | 建议 |
|---|---|
| 函数名 | 动词 + 资源:list_posts / get_post / create_post / update_post / delete_post |
列表用 list_ 而非 get_all_ | 为分页留出语义空间 |
operation_id | 手动指定,避免长路径生成 a_b_c_d_get 这类不可读名字 |
summary | 中文短句,就是 /docs 里显示的那行 |
description | 只在有非显然行为时写(权限、副作用、幂等性) |
19.3 API 设计约定
资源命名
| 规则 | ✅ 好 | ❌ 差 |
|---|---|---|
| 用复数名词 | /posts /users | /post /getUsers |
| 层级表达从属 | /posts/{id}/comments | /comments?post_id=1(可读,但层级关系丢了) |
| 动作交给 HTTP 方法 | DELETE /posts/1 | POST /posts/1/delete |
| 非 CRUD 动作用子资源 + 动词 | POST /posts/1/publish | POST /publishPost |
| 路径小写、连字符 | /blog-posts | /blogPosts |
| 过滤用查询串 | /posts?tag=fastapi | /posts/tag/fastapi |
状态码语义
| 码 | 含义 | 用在这里 |
|---|---|---|
| 200 | OK | GET 成功、PATCH/PUT 成功 |
| 201 | Created | POST 创建资源成功,配 Location 头更佳 |
| 204 | No Content | DELETE 成功(响应体必须为空) |
| 400 | Bad Request | 请求格式本身有问题(如非法 JSON 语义) |
| 401 | Unauthorized | 未认证:没带 token、token 过期/签名错 |
| 403 | Forbidden | 已认证但无权限 |
| 404 | Not Found | 资源不存在或对当前用户不可见 |
| 409 | Conflict | 唯一约束冲突(slug、邮箱重复) |
| 422 | Unprocessable Entity | 参数校验失败(FastAPI 默认) |
| 429 | Too Many Requests | 触发限流,带 Retry-After |
| 500 | Internal Server Error | 未捕获异常,不要主动抛 |
两个高频错误:
- 把 401 和 403 混用。判断依据是"换个身份能不能成功":能 → 403;不能(压根没身份)→ 401。
- DELETE 返回 204 却带了 body。Starlette 会因为
status_code=204而丢弃 body,但你会得到一条难以理解的警告;正确做法是路由返回None。
分页参数统一
选一种,全项目贯彻:
| 风格 | 参数 | 适合 |
|---|---|---|
| 页码式 | page + size | 有 UI 页码控件、需要知道总页数 |
| 偏移式 | limit + offset | 内部接口、无限滚动 |
| 游标式 | cursor + limit | 大数据量、要求稳定不跳页 |
页码式的标准实现(本教程统一用这套):
class Page(BaseModel, Generic[T]):
items: list[T]
total: int # 总条数,前端算页数
page: int
size: int约定:page 从 1 开始;size 上限 100(防止 size=100000 拖垮数据库);total 必须与 items 使用同一套过滤条件。
错误响应统一结构
{
"detail": "文章 42 不存在",
"code": "not_found"
}| 字段 | 说明 |
|---|---|
detail | 面向开发者的可读信息;字符串或 FastAPI 原生的错误数组 |
code | 稳定的机器可读标识,前端据此做分支,不要解析 detail 文案 |
用 app/errors.py 集中注册处理器(见 第 18 章),保证 4xx 全都长这样。
版本化策略
| 策略 | 写法 | 取舍 |
|---|---|---|
| URL 路径版本 | /api/v1/posts | 最直观、最好调试、CDN 友好 → 推荐 |
| 请求头版本 | Accept: application/vnd.api+json;version=1 | URL 干净,但调试与缓存麻烦 |
| 查询参数版本 | /posts?version=1 | 会污染业务参数,不推荐 |
v1 是路径的一部分,不是文件的一部分:不要建 api/v1/posts.py 和 api/v2/posts.py 两份拷贝。破坏性变更时新建 api/v2/,复用 services/ 层,v1 保留一个弃用周期:
app.include_router(posts_v1.router, prefix="/api/v1", deprecated=True)
app.include_router(posts_v2.router, prefix="/api/v2")19.4 性能清单
| # | 检查项 | 做法 | 反例 |
|---|---|---|---|
| 1 | 能用异步就用异步 | I/O 密集(DB、HTTP、Redis)一律 async def + 异步驱动 | 在 async def 里调 requests.get() |
| 2 | CPU 密集别占事件循环 | 哈希、图像处理放 def(自动走线程池)或 run_in_threadpool | 在 async def 里做 PBKDF2 10 万次 |
| 3 | 列表接口必须分页 | limit 强制上限,服务端永远不返回全表 | select(User) 直接返回 |
| 4 | 连接池按并发配 | pool_size + max_overflow 对齐 worker 数与 DB 上限 | 默认值扛 4 worker × 50 并发 |
| 5 | 消灭 N+1 | 关系设 lazy="selectin" 或 .options(selectinload(...)) | 循环里访问 post.author.username |
| 6 | 序列化用 ORJSON | FastAPI(default_response_class=ORJSONResponse) | 大列表默认 JSON 编码占满 CPU |
| 7 | 静态资源不经过应用 | 交给 Nginx / CDN,只 mount 开发环境的 StaticFiles | 用 FastAPI 分发前端构建产物 |
| 8 | 慢查询建索引 | 对 WHERE / ORDER BY 列建索引,用 EXPLAIN ANALYZE 验证 | 在 10 万行表上 ILIKE '%x%' |
| 9 | 分页排序键唯一 | ORDER BY created_at DESC, id DESC | 只按时间排序,翻页重复 |
| 10 | 缓存热点读 | 读多写少的聚合结果放 Redis,设明确 TTL | 每次请求都 COUNT(*) 全表 |
# 1 + 3 + 6 的组合写法
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
app = FastAPI(default_response_class=ORJSONResponse)
@router.get("", response_model=Page[PostOut])
async def list_posts(
service: PostServiceDep,
size: Annotated[int, Query(ge=1, le=100)] = 20, # 上限就是性能护栏
) -> Page[PostOut]:
...⚠️
ORJSONResponse不能序列化任意 Python 对象(比如Decimal之外的自定义类)。它依赖 orjson 的类型支持;遇到报错先看返回模型里有没有非标准类型,而不是直接换回默认响应类。
19.5 安全清单
| # | 检查项 | 做法 | 不做的后果 |
|---|---|---|---|
| 1 | 密钥环境变量化 | SECRET_KEY 走环境变量,.env 进 .gitignore | 密钥进 Git 历史,轮换成本极高 |
| 2 | 密钥强度校验 | Field(min_length=32),启动即失败 | 空密钥签发可伪造的 token |
| 3 | CORS 精确配置 | 列举具体来源;allow_credentials=True 时禁用 "*" | 任意站点可携带用户 Cookie 调你的 API |
| 4 | 密码哈希用 Argon2 | pwdlib.PasswordHash.recommended() | MD5/SHA1 一秒撞库 |
| 5 | JWT 设过期时间 | exp 必填,access token 15~60 分钟 | token 泄露后永久有效 |
| 6 | 校验 JWT 算法 | 解码时显式 algorithms=["HS256"] | alg=none 伪造 |
| 7 | 输入一律过 Pydantic | extra="forbid"、长度/范围/正则约束 | 超大字符串、脏数据入库 |
| 8 | 响应模型过滤敏感字段 | 输出模型里根本不存在 hashed_password | 密码哈希随用户对象泄露 |
| 9 | 文件上传白名单 | 校验扩展名 + MIME + 大小,重命名存储 | 上传 .py / .html 后被解析执行 |
| 10 | 限流 | slowapi 按 IP/用户限速,返回 429 + Retry-After | 撞库、刷接口拖垮服务 |
| 11 | 依赖漏洞扫描 | uv lock --upgrade + pip-audit 进 CI | 长期带着已知 CVE 上线 |
| 12 | 生产关闭 /docs | 内部服务 docs_url=None 或加鉴权 | 接口结构完全暴露 |
限流的最小可用接法(slowapi 基于 Starlette 中间件,与 FastAPI 天然兼容):
uv add slowapifrom slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
# 路由上:@limiter.limit("5/minute"),登录端点尤其需要依赖漏洞扫描:
uv sync --frozen # 保证 CI 与本地依赖完全一致
uv lock --upgrade # 升级前先看 diff
uv run pip-audit # 扫描已安装依赖的已知漏洞
uv run pip-audit --fix # 自动升级到修复版本(需人工复核)错误示范 vs 正确示范
❌ 不推荐:
# 密钥硬编码 + 算法不校验 + token 永不过期
SECRET = "dev"
payload = jwt.decode(token, SECRET) # 未限定算法
data = {"sub": str(user.id)} # 无 exp✅ 推荐:
from datetime import datetime, timedelta, timezone
import jwt
from app.core.config import get_settings
settings = get_settings()
def create_access_token(subject: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=settings.access_token_expire_minutes)).timestamp()),
}
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict:
# 显式限定算法;exp 由 PyJWT 自动校验
return jwt.decode(token, settings.secret_key, algorithms=[settings.jwt_algorithm])19.6 常见坑与排查(跨章总表)
| 坑 | 原因 | 正确做法 | 详见章节 |
|---|---|---|---|
/users/{id} 写在 /users/me 之前,me 被当成 id 解析 | 路由按注册顺序匹配,先命中的先赢 | 静态路径放在动态路径之前 | 第 3 章 |
函数里参数默认值写 q: str = None | 类型注解说是 str,实际给了 None | 写成 q: str | None = None | 第 3 章 |
Optional[str] 迁移到 v2 后变成必填 | v2 中 Optional 只表示"可为 None",不再隐含默认值 | 想要可选就写 = None | 第 4 章 |
可变默认值 tags: list[str] = [] 造成请求间串味 | 默认值在模型定义时只求值一次 | 用 default_factory=list | 第 4 章 |
| 前端字段拼错却"创建成功"了 | extra 默认 "ignore",未知字段被静默丢弃 | 输入模型设 extra="forbid" | 第 4 章 |
响应里带出了 hashed_password | 直接返回 ORM 对象且输出模型里有该字段 | 输出模型不含敏感字段(结构保证) | 第 5 章 |
response_model 与函数返回值类型不一致,字段被静默裁剪 | response_model 才是真正的过滤规则 | 两边注解保持一致,或信任 response_model 并显式声明 | 第 5 章 |
yield 依赖里 return 而非 yield,清理代码不执行 | 生成器语义未生效 | 确保函数体内有 yield,清理逻辑写在 yield 之后 | 第 6 章 |
依赖里改了 get_db 却忘记在测试中 dependency_overrides.clear() | 覆盖是全局字典,跨用例残留 | fixture 的 yield 之后显式 clear() | 第 6 章 |
同时写了 Path() 却把参数名写错,收到 422 | 参数名与路径模板不匹配就会被当成查询参数 | 依赖与路由的参数名必须与 {...} 逐字一致 | 第 7 章 |
Field(min_length=8) 在去空格前校验," abc " 通过 | 校验顺序早于业务处理 | 配 str_strip_whitespace=True 或在 mode="before" 校验器里 strip() | 第 7 章 |
| 自定义异常处理器里返回了错误的结构,前端解析失败 | 每个处理器各写一套响应体 | 统一 {"detail", "code"} 并集中注册 | 第 8 章 |
中间件里读 request.body() 后下游拿不到 body | body 是流,读一次就消费掉了 | 中间件里 await request.body() 后重新构造 Request,或改用依赖 | 第 9 章 |
@app.on_event("startup") 不执行清理 | 该 API 已废弃,且异常会静默 | 改用 lifespan 上下文管理器 | 第 9 章 |
MissingGreenlet: greenlet_spawn has not been called | 异步会话里触发了同步惰性加载 | lazy="selectin" / selectinload;expire_on_commit=False | 第 10 章 |
迁移里全是 DROP TABLE | 模型没被 import,Base.metadata 里没有它们 | 在 migrations/env.py 或 models/__init__.py 里 import 全部模型 | 第 10 章 |
| 路由里直接写 SQL,改个字段要动三层 | 分层被打破 | SQL 只出现在 repositories/ | 第 11 章 |
| 事务提交散落在 Service,异常时半提交 | 没有统一的提交边界 | 提交/回滚放 get_db,Service 只 flush() | 第 11 章 |
分页查询 total 与 items 数量对不上 | 两处过滤条件写得不一样 | 用同一个 base 语句构造 count() | 第 11 章 |
| 翻页时出现重复或漏掉的记录 | 排序键不唯一 | 排序追加主键兜底 | 第 11 章 |
pip install passlib 后 Argon2 报版本不兼容 | passlib 长期未维护 | 改用 pwdlib[argon2] | 第 12 章 |
| 无 token 得到 403 而不是 401 | 权限依赖先于认证依赖判定 | 先认证(401)再鉴权(403) | 第 12 章 |
在 async def 里调 requests.get(),并发数上不去 | 同步阻塞调用占住事件循环 | 用 httpx.AsyncClient,或把函数改成 def 走线程池 | 第 13 章 |
UploadFile 全量读进内存后 OOM | await file.read() 没有大小限制 | 流式分块读取 + MAX_SIZE 提前校验 | 第 14 章 |
文件上传按原名存储,被上传 .html 后 XSS | 未做白名单与重命名 | 扩展名 + MIME 白名单,UUID 重命名 | 第 14 章 |
WebSocket 广播时遍历连接列表抛 RuntimeError | 广播过程中集合被修改 | 遍历副本,并对失效连接做 discard | 第 15 章 |
测试里 AsyncClient(app=app) 报 TypeError | httpx 新版本移除了 app= 简写 | 用 ASGITransport(app=app) | 第 16 章 |
| 测试之间数据互相污染 | 没清表,或测试库与开发库是同一个 | 独立测试库 + fixture 内 drop_all/create_all | 第 16 章 |
--workers 4 后内存里缓存的 token / 状态对不上 | 多进程之间不共享内存 | 状态放 Redis / DB,别放模块级变量 | 第 17 章 |
容器里 docker compose up 后 API 先起,报 relation 不存在 | depends_on 只保证容器启动顺序 | db 加 healthcheck + migrate 用 service_completed_successfully | 第 17 章 |
allow_origins=["*"] 与 allow_credentials=True 同时开,CORS 失效 | 浏览器规范禁止该组合 | 精确列举来源 | 第 9 章 |
19.7 速查表
常用命令
| 场景 | 命令 |
|---|---|
| 开发热重载 | uv run fastapi dev app/main.py |
| 开发热重载(等价) | uv run uvicorn app.main:app --reload --port 8000 |
| 生产启动 | uv run fastapi run app/main.py --host 0.0.0.0 --port 8000 --workers 4 |
| 添加依赖 | uv add httpx / uv add --dev pytest |
| 生成迁移 | uv run alembic revision --autogenerate -m "add tags" |
| 应用迁移 | uv run alembic upgrade head |
| 回滚一步 | uv run alembic downgrade -1 |
| 查看当前版本 | uv run alembic current |
| 跑测试 | uv run pytest -q |
| 带覆盖率 | uv run pytest --cov=app --cov-report=term-missing |
| 只跑失败过的 | uv run pytest --lf |
| 依赖漏洞扫描 | uv run pip-audit |
| 格式化 / 检查 | uv run ruff format . && uv run ruff check --fix . |
| 类型检查 | uv run mypy app |
参数声明速查(Annotated 一行式)
| 来源 | 声明 |
|---|---|
| 路径 | item_id: Annotated[int, Path(ge=1, description="资源 ID")] |
| 查询(可选) | q: Annotated[str | None, Query(max_length=50)] = None |
| 查询(必填) | q: Annotated[str, Query(min_length=1)] |
| 查询(枚举) | sort: Annotated[Literal["asc", "desc"], Query()] = "desc" |
| 请求体(单模型) | payload: PostCreate |
| 请求体(多模型 / 嵌入) | payload: Annotated[PostCreate, Body(embed=True)] |
| 请求头 | x_token: Annotated[str, Header(alias="X-Token")] |
| Cookie | session_id: Annotated[str | None, Cookie()] = None |
| 表单 | username: Annotated[str, Form(min_length=3)] |
| 文件 | upload: Annotated[UploadFile, File(description="≤ 2 MB")] |
| 可复用别名 | PageSize = Annotated[int, Query(ge=1, le=100)] |
状态码速查
| 码 | 什么时候用 | FastAPI 写法 |
|---|---|---|
| 200 | 默认成功 | 不用写 |
| 201 | 创建成功 | status_code=status.HTTP_201_CREATED |
| 204 | 删除成功 | status_code=status.HTTP_204_NO_CONTENT,函数返回 None |
| 400 | 请求语义错误 | HTTPException(400, ...) |
| 401 | 未认证 | HTTPException(401, headers={"WWW-Authenticate": "Bearer"}) |
| 403 | 已认证无权限 | HTTPException(403, ...) |
| 404 | 不存在或不可见 | HTTPException(404, ...) |
| 409 | 唯一约束冲突 | HTTPException(409, ...) |
| 422 | 校验失败 | 框架自动返回 |
| 429 | 限流 | slowapi 自动返回,附 Retry-After |
| 500 | 未捕获异常 | 不要主动抛 |
依赖注入速查
| 需求 | 写法 |
|---|---|
| 普通依赖 | def get_service(db: SessionDep) -> PostService: ... |
| 使用依赖 | service: Annotated[PostService, Depends(get_service)] |
| 带清理的依赖 | 函数体 yield session,清理写在 yield 之后 |
| 带参数的依赖 | Depends(require_role("admin"))(工厂返回 checker) |
| 类作为依赖 | Depends(CommonQueryParams),类实例即依赖结果 |
| 路由级依赖 | @router.get("/x", dependencies=[Depends(verify_token)]) |
| 全局依赖 | FastAPI(dependencies=[Depends(verify_token)]) |
| 子依赖 | 依赖函数自己的参数同样可以 Depends(...),形成依赖树 |
| 同一请求复用 | 依赖按「可调用对象 + 参数」缓存,同一次请求内只执行一次 |
| 测试覆盖 | app.dependency_overrides[get_db] = fake,用完 clear() |
Pydantic v2 常用 API 速查
| 需求 | 写法 |
|---|---|
| dict → 模型 | UserCreate.model_validate(data) |
| JSON 字符串 → 模型 | UserCreate.model_validate_json(raw) |
| 模型 → dict | user.model_dump() |
| 模型 → JSON 字符串 | user.model_dump_json() |
| 排除未传字段 | data.model_dump(exclude_unset=True) |
| 排除敏感字段 | user.model_dump(exclude={"hashed_password"}) |
| 用别名输出 | user.model_dump(by_alias=True) |
| ORM 对象 → 模型 | UserOut.model_validate(row)(需 from_attributes=True) |
| 字段级校验器 | @field_validator("name") + @classmethod |
| 跨字段校验器 | @model_validator(mode="after"),必须 return self |
| 计算字段 | @computed_field + @property |
| 生成 JSON Schema | Model.model_json_schema() |
| 局部更新模型 | Model.model_copy(update=patch) |
常用 ConfigDict 选项:
| 选项 | 作用 |
|---|---|
extra="forbid" | 出现未声明字段直接报错 |
from_attributes=True | 允许从对象属性读取(ORM 转 schema) |
populate_by_name=True | 字段名与别名两种写法都接受 |
alias_generator=to_camel | 全局 snake_case ↔ camelCase |
str_strip_whitespace=True | 字符串自动去首尾空白 |
validate_assignment=True | 赋值时也触发校验(有性能开销) |
use_enum_values=True | 枚举字段序列化为其值 |
frozen=True | 模型不可变(可哈希) |
19.8 学习路线建议
框架生态
| 方向 | 一句话 | 适合场景 |
|---|---|---|
| SQLModel | Pydantic 与 SQLAlchemy 的合并体,一个类既是模型又是 Schema | 中小项目快速起步;复杂关系仍建议纯 SQLAlchemy |
| FastStream | 用 FastAPI 风格写 Kafka / RabbitMQ 消费者 | 需要事件驱动、异步消息 |
| Litestar | 同类 ASGI 框架,插件与 DI 设计更严格 | 愿意为类型严格性换一点生态 |
| Strawberry / Ariadne | GraphQL 层,可与 FastAPI 共存 | 前端字段需求多变 |
工程方向
| 方向 | 起点 |
|---|---|
| 可观测性 | structlog 结构化日志 + OpenTelemetry 链路追踪 + Prometheus 指标 |
| 容器编排 | 把第 17、18 章的 compose 迁移到 K8s:Deployment + Service + Job(迁移)+ HPA |
| 消息队列 | Celery / ARQ / FastStream 处理邮件、报表等耗时任务;注意幂等与重试 |
| 数据库进阶 | 读写分离、pgbouncer 连接池、分区表、EXPLAIN ANALYZE 调优 |
| 契约优先 | 用 OpenAPI 生成前端 SDK(openapi-typescript),接口变更走 CI 校验 |
| 安全加固 | OAuth2 授权码流程、刷新令牌轮换、审计日志、密钥托管(Vault / KMS) |
💡 最有效的下一步:把第 18 章的博客 API 部署到一台真实服务器上,接上域名与 HTTPS,然后压测一轮。生产环境的报错会立刻告诉你哪一块知识是虚的。
本章小结
| 要点 | 说明 |
|---|---|
| 分层是纪律 | api → services → repositories → models,靠目录职责表 + grep 自查维持 |
| 命名是文档 | XxxCreate / XxxUpdate / XxxOut / XxxInDB 一眼看出能否对外 |
| 大小写转换 | alias_generator=to_camel 一处配置,全局 API 输出 camelCase |
| 状态码有语义 | 401 与 403 按"换个身份能不能成功"区分;204 不带 body |
| 分页要统一 | 参数名、起始页、上限、total 口径全项目一致 |
| 错误要统一 | {"detail", "code"},前端只依赖 code 做分支 |
| 性能靠护栏 | 分页上限、selectinload、连接池、ORJSONResponse |
| 安全靠习惯 | 密钥外置、Argon2、JWT 过期、CORS 精确、限流、依赖扫描 |
| 速查表 | 记不住语法是正常的,翻表比搜索快 |
练习题
把第 18 章项目的响应统一改成
camelCase:写出APIModel基类,改造UserOut/PostOut,并验证curl返回的键名与/docs里的 Schema 都变了;同时说明请求体是否也必须用camelCase。为博客 API 制定一份
API_STYLE.md:包含资源命名规则、状态码决策表、分页约定、错误结构,并挑出你项目里三处不符合规范的接口,写出改造方案。用 grep 或 CI 脚本实现三条自动校验:(a)
services/与repositories/中不出现fastapi;(b) 每个路由都声明了response_model;(c) 所有status_code=204的路由函数返回注解是None。从 19.6 的坑点总表中挑出你实际踩过的三条,写出最小复现代码、报错原文与修复后的代码,作为团队内部的踩坑记录。
给第 18 章的项目加一轮安全加固:
slowapi限流(登录 5 次/分钟)、依赖漏洞扫描进 CI、生产关闭/docs,并用curl验证 429 响应带Retry-After。
回到目录
恭喜读完整个教程。建议把第 18 章的实战项目跑通后,回到目录按主题复习。
👉 返回课程目录