design-pattern
design-pattern/assertion/validator-vs-assertion.md
medium
assertion-validator-vs-assertion
Pydantic Schema validator 与 Asserts 各自的边界 + 5 个不该用断言的反例。Use when 写 Python 后端代码 / 评审涉及 `validator-vs-assertion` 的 PR。
validatorassertspydanticfieldschema校验断言反例
paths
backend/**/*.pypy/**/*.pypy/schemas/**/*.pypy/asserts/**/*.py
Asserts vs Pydantic · 边界与反例
分工总则
| 校验类型 | 由谁负责 |
|---|---|
| 单字段类型 / 长度 / 范围 / 正则 | Pydantic Field(max_length=, ge=, le=, pattern=) |
| 必填字段(field=...) | Pydantic(不要 Asserts 重复) |
| 枚举值合法性(如 status in {...}) | Pydantic Literal[...] 或 Enum |
| 跨字段(如 password_confirm == password) | Pydantic @field_validator / @model_validator |
| 跨实体存在性(如 textbook_id 对应的课本是否存在) | Asserts |
| 业务状态机(如 outline 是否可编辑) | Asserts |
| 业务规则(如积分够不够 / 是否自邀) | Asserts |
| 动态结构(dict[str, Any] 校验) | Asserts |
简单口诀
Pydantic 校验"输入本身合法",Asserts 校验"输入对应的业务状态合法"。
不该用断言的 5 个反例
反例 1:性能敏感的循环内
# ❌ 在 10 万行 CSV 循环里每行调 Asserts
for row in rows:
Asserts.matches(row.email, EMAIL_PATTERN, code="V001", message="...")
# ✅ 批量校验:失败的收集起来一次抛
invalid_rows = [r for r in rows if not EMAIL_PATTERN.fullmatch(r.email)]
ImportAsserts.no_invalid_emails(invalid_rows)
每行调 Asserts 开销大(抛异常的成本)。改批量收集。
反例 2:第三方库异常透传
# ❌ 把 Qdrant / OpenAI / Tortoise 内部 exception 包装为 ApiException
try:
await qdrant.search(...)
except QdrantException as e:
raise ApiException("X009", str(e)) # 把底层错误字面量送到 UI
# ✅ 让底层异常向上传播,由 global handler 兜底 X009
global_error_handler 已经会兜底为 X009,业务层重复包装反而把底层细节泄漏给前端。
反例 3:I/O 错误 / 网络错误
# ❌ 把 ConnectionError 当业务异常处理
try:
response = await httpx.get(url)
except ConnectionError as e:
raise ApiException("E001", "网络异常")
# ✅ 让其向上传播
response = await httpx.get(url) # 失败由 global handler 处理
ConnectionError / TimeoutError / asyncio.CancelledError 属于运行时不可控错误,统一记 500。
反例 4:可恢复降级路径
# ❌ 用断言把"找不到 → 走默认值"的路径阻断
font = await Font.get_or_none(id=font_id)
SettingsAsserts.font_exists(font) # 找不到就抛 D001
return font
# ✅ 直接降级
font = await Font.get_or_none(id=font_id)
return font or DEFAULT_FONT
如果业务有"找不到也能继续"的语义,别加断言。
反例 5:Pydantic 已校验的字段
class OutlineCreateReq(BaseModel):
title: str = Field(..., max_length=200) # 已校验长度
slide_count: int = Field(..., ge=3, le=20) # 已校验范围
async def generate(req: OutlineCreateReq):
# ❌ 重复校验
OutlineAsserts.title_within_limit(req.title)
OutlineAsserts.slides_min_count([1] * req.slide_count)
# ✅ Asserts 只做 Pydantic 表达不了的跨字段 / 跨实体规则
OutlineAsserts.chapter_ids_belong_to(req.chapter_ids, chapters)
反例 6:内部不变量
# ❌ 把"客户端不该传脏数据"当业务异常
def _render_element(slide, element, theme_config):
el_type = element.get("type")
handler = handlers.get(el_type)
if not handler:
# 内部不变量违反(schema 应该提前过滤)
raise ApiException("B008", f"未支持元素类型: {el_type}") # 不要
# ✅ 让 ValueError 抛上去,由 global_error_handler 兜底 X009 + 加注释
if handler:
handler(slide, element, theme_config)
else:
# 不变量违反(schema 应该提前过滤):让 global handler 兜底 X009
# 不抛 ApiException 是因为这是内部 bug 信号,不该把字面量送 UI
raise ValueError(f"Unsupported element type: {el_type}")
内部不变量 = "本不该发生的"。把它送 UI 等于把脏数据信号当业务错误,模糊问题归属。让 X009 兜底,开发期通过日志排查。
自检
- [ ] 单字段校验用 Pydantic 不用 Asserts?
- [ ] 跨实体 / 业务状态机用 Asserts 不用 Pydantic?
- [ ] 性能敏感循环改批量校验?
- [ ] 第三方库异常 / I/O 错误让其向上传播?
- [ ] 可恢复降级用
or DEFAULT而非断言? - [ ] 内部不变量保留 raise ValueError + 注释说明?