客户端发了字符串,校验居然通过了?¶
上一章建立了一个关键分工:CreatePlanRequest 作为 HTTP 入口的请求模型,负责
声明客户端应该发送什么;LearningGoal 作为 core 的业务对象,负责定义业务逻辑
需要什么输入。适配层在二者之间做翻译,core 对 HTTP 一无所知。
分工清楚了。但有一个问题还没回答:CreatePlanRequest 声明了 weekly_hours: int,
客户端真的只能发整数吗?
你可能觉得答案很明显——声明了 int 当然只接受整数。但 Pydantic 在这件事上有
自己的主张,而且这个主张可能跟你的直觉不一样。
运行环境¶
Python 3.12、FastAPI 0.115+、Pydantic v2。
1. 当声明和实际输入不一致时¶
回到我们的 AI 学习助手。请求模型长这样:
from pydantic import BaseModel
class CreatePlanRequest(BaseModel):
topic: str
weekly_hours: int
focus_areas: list[str] = []
现在来个测试。客户端发了一个"看起来有问题"的请求:
注意 weekly_hours 的值:"12"——一个字符串,不是整数。
先别往下看,预测一下:Pydantic 会接受这个输入,还是拒绝?
request = CreatePlanRequest.model_validate({
"topic": "Python typing",
"weekly_hours": "12",
"focus_areas": ["FastAPI"],
})
print(request.weekly_hours) # 12
print(type(request.weekly_hours)) # <class 'int'>
通过了。不仅通过了,"12" 还被悄悄转成了整数 12。
再试一个更离谱的:
"很多" 被拒绝了。
现在的情况是:
| 输入值 | 声明类型 | 结果 |
|---|---|---|
12(整数) |
int |
接受 |
"12"(字符串) |
int |
接受,转换为 12 |
12.0(浮点数) |
int |
接受,转换为 12 |
12.7(浮点数) |
int |
拒绝(有损转换) |
"很多"(字符串) |
int |
拒绝 |
true(布尔) |
int |
接受,转换为 1 |
这种行为叫做类型转换(coercion)。Pydantic 默认模式下,如果输入值能够无损 地转换为目标类型,就接受并转换;如果无法转换或会丢失信息,就拒绝。
这带来一个微妙的后果:客户端发送了类型"错误"的值(字符串 "12"),但校验
通过了,客户端甚至不知道自己犯了错。从客户端的视角看,它发的所有请求都成功了,
完全没有动力修正自己的实现。
默认转换悄悄扩宽了接受边界——声明的是 int,但实际上 "12"、12.0、true
都能进来。这不一定是坏事,但你至少应该知道它在发生。
2. 用策略收窄边界¶
知道了默认转换的存在,下一个问题是:能不能关掉它?
答案是可以的。Pydantic v2 提供了 strict mode:
from pydantic import BaseModel, ConfigDict
class CreatePlanRequest(BaseModel):
model_config = ConfigDict(strict=True)
topic: str
weekly_hours: int
focus_areas: list[str] = []
同样的输入 {"weekly_hours": "12"}:
在 strict 模式下,"12" 不再被接受。声明了 int,就只接受 int——字符串是
字符串,整数是整数,概不通融。
再看另一个维度:额外字段策略。
默认情况下,Pydantic 模型会忽略未声明的字段:
data = {
"topic": "Python typing",
"weekly_hours": 12,
"focus_areas": ["FastAPI"],
"secret": "admin_password",
}
request = CreatePlanRequest.model_validate(data)
# 通过,secret 被静默丢弃
客户端多发了一个 secret 字段,Pydantic 默认不报错、不保留——直接丢掉。这就是
extra="ignore" 的行为(也是默认行为)。
如果你想严格拒绝任何未声明字段:
class CreatePlanRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
topic: str
weekly_hours: int
focus_areas: list[str] = []
现在把这些策略放在一起看:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| 默认(lax + ignore) | 兼容转换、忽略多余字段 | 面向多样化客户端,减少适配成本 |
strict=True |
拒绝类型不精确匹配 | 严格要求客户端发送正确类型 |
extra="forbid" |
拒绝未声明字段 | 防止脏数据、发现客户端实现错误 |
extra="ignore" |
静默丢弃未声明字段 | 宽松兼容,但可能掩盖客户端 bug |
先停下来想一想:一个面向移动 APP 的公开接口和一个内部微服务间的接口,你会选 不同的策略吗?
面向移动 APP 时,客户端版本分散、更新不可控,用默认的宽松模式可以减少因为旧版
APP 多带了一个废弃字段而导致 400 错误的情况。而内部微服务间通信,双方都在你的
控制下,用 strict=True + extra="forbid" 可以第一时间发现任何不符合约定的
调用——出了问题立刻报错,比静默接受再在下游出诡异 bug 要好得多。
关键认知:策略选择应由数据来源和业务风险决定,不是"严格总比宽松好"。每种 策略都是在"尽早发现错误"和"减少客户端适配摩擦"之间做取舍。
3. 校验通过了,但业务还是失败了¶
到目前为止,我们一直在讨论"输入能不能通过校验"。但通过校验之后呢?
看这个输入:
校验通过了——0 确实是一个合法的 int,类型正确,没有额外字段。Pydantic 的
工作完成了。
但当这个请求被转换为 LearningGoal 传入 build_plan 时:
def build_plan(goal: LearningGoal) -> Plan:
daily_minutes = goal.weekly_hours * 60 // 7
# weekly_hours=0 → daily_minutes=0
# 一个每天学习 0 分钟的计划?这没有意义。
...
weekly_hours=0 意味着"每周学习 0 小时"——一个在业务上毫无意义的计划。更糟糕
的是,如果 build_plan 内部做了除法(比如计算"总学时/每天时长"),weekly_hours=0
甚至可能导致除零错误。
这里有一个容易踩的坑:校验通过 ≠ 可以放心执行。
把这两个层次分开:
| 层次 | 保证什么 | 谁负责 |
|---|---|---|
| Pydantic 校验层 | 字段存在、类型正确、满足声明约束 | 请求模型 |
| 业务规则层 | 值在业务上有意义、满足业务前提 | core 逻辑 |
weekly_hours=0 满足了字段约束(是 int),但不满足业务前提(学习时长必须为正)。
这两个判断是独立的,归属不同的代码层。
你可能会问:为什么不直接在 Pydantic 模型里加个 Field(gt=0) 的约束?
from pydantic import BaseModel, Field
class CreatePlanRequest(BaseModel):
topic: str
weekly_hours: int = Field(gt=0)
focus_areas: list[str] = []
这样 weekly_hours=0 在校验阶段就会被拒绝。确实可以这么做——对于像"学习时长
必须为正"这样简单、稳定、只跟字段值本身相关的规则,放在模型里是合理的。
但不是所有业务规则都适合塞进 Pydantic 模型。比如:
- "同一用户 24 小时内只能创建 3 个计划"——这需要查数据库
- "focus_areas 中的每个话题必须在课程目录中存在"——这需要访问外部数据源
- "weekly_hours 不能超过用户当前订阅等级允许的上限"——这需要查询用户状态
这些规则涉及外部状态和跨系统协作,如果全部塞进 Pydantic 模型,模型会变成一个 庞大的"什么都管"的上帝类,依赖数据库、缓存、外部服务……模型的职责早就不是 "校验输入字段"了。
所以清晰的分工是:
- 模型校验层:保证字段类型正确、格式合法、简单值约束满足
- 业务逻辑层:保证值在业务上下文中有意义、满足涉及外部状态的规则
这两个层独立工作,各自报告各自的失败。至于失败后如何变成 HTTP 响应返回给客户端 ——那是下一章的事。
4. 被拒绝时,错误信息长什么样¶
既然校验会拒绝无效输入,那拒绝时给出的信息质量就很关键。来看一个具体的失败:
from pydantic import ValidationError
try:
CreatePlanRequest.model_validate({
"topic": "Python typing",
"weekly_hours": "很多",
"focus_areas": ["FastAPI"],
})
except ValidationError as e:
print(e.errors())
输出:
[
{
"type": "int_parsing",
"loc": ("weekly_hours",),
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "很多",
"url": "https://errors.pydantic.dev/2/v/int_parsing",
}
]
每个错误项都是结构化的:
| 字段 | 含义 |
|---|---|
type |
错误类型的标识符(如 int_parsing) |
loc |
错误发生在哪个字段(定位到具体位置) |
msg |
人类可读的错误描述 |
input |
导致错误的原始输入值 |
如果客户端同时犯了多个错误呢?
try:
CreatePlanRequest.model_validate({
"weekly_hours": "很多",
# topic 字段缺失
})
except ValidationError as e:
print(e.errors())
[
{
"type": "missing",
"loc": ("topic",),
"msg": "Field required",
"input": {"weekly_hours": "很多"},
},
{
"type": "int_parsing",
"loc": ("weekly_hours",),
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "很多",
},
]
两个错误,每个都精确定位到具体字段、具体原因。客户端拿到这个列表后,可以准确
知道"是 topic 没填"和"weekly_hours 类型不对"——而不是面对一个笼统的
"Invalid input" 不知道该改哪里。
对比两种错误信息:
方案 A(无结构):
HTTP 400: "Invalid input"
方案 B(结构化):
HTTP 422:
[
{"type": "missing", "loc": ["topic"], "msg": "Field required"},
{"type": "int_parsing", "loc": ["weekly_hours"], "msg": "..."}
]
方案 A 告诉客户端"你错了",但不告诉它错在哪里。客户端开发者只能一个字段一个 字段地猜。如果请求有十几个字段,这种猜测就是灾难。
方案 B 告诉客户端"你在这两个地方错了,原因分别是什么"。客户端可以在表单的对应 字段下方精确显示错误提示,用户一眼就知道该改什么。
这就是结构化错误的价值:让调用方可以精确定位失败原因和字段。
FastAPI 默认会将 Pydantic 的 ValidationError 转为 HTTP 422 响应,并把错误
列表放在响应体中。至于这个 422 响应的结构应该如何设计、什么时候用 400 而不是
422、业务错误和校验错误应该用不同的格式吗——这些问题留给下一章统一回答。
5. 已校验的模型如何进入 core¶
最后一个问题:请求通过了校验,得到了一个合法的 CreatePlanRequest 对象。
接下来怎么把它交给 typed core?
最直觉的做法:
# router.py
from core import build_plan
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest):
return build_plan(request) # 直接把请求模型传给 core?
这意味着 build_plan 要接受 CreatePlanRequest 作为参数:
# core.py
from schemas import CreatePlanRequest # ← core 导入了 HTTP 请求模型!
def build_plan(request: CreatePlanRequest) -> Plan:
daily_minutes = request.weekly_hours * 60 // 7
...
先别往下看,预测一下:如果以后要加一个 CLI 入口,会发生什么?
CLI 入口没有 HTTP 请求,没有 JSON,也不会构造 CreatePlanRequest。但
build_plan 要求传入一个 CreatePlanRequest——于是 CLI 入口被迫导入一个
它根本不需要的 HTTP 请求模型,还要手动构造一个实例:
# cli.py
from schemas import CreatePlanRequest # CLI 为什么要知道 HTTP 请求模型?
def main():
request = CreatePlanRequest(topic=args.topic, weekly_hours=args.hours)
plan = build_plan(request)
这不仅别扭,还暴露了真正的问题:core 的输入类型被 HTTP 传输格式绑架了。
请求模型的字段名、验证策略、甚至是否有 model_config 配置,都是为 HTTP 入口
设计的——它们跟业务逻辑无关。
正确的做法是在适配层做转换:
# router.py(适配层)
from core import build_plan, LearningGoal
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest):
# 适配层负责:HTTP 请求模型 → core 业务对象
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return build_plan(goal)
# core.py(typed core)
@dataclass(frozen=True)
class LearningGoal:
topic: str
weekly_hours: int
def build_plan(goal: LearningGoal) -> Plan:
daily_minutes = goal.weekly_hours * 60 // 7
...
core 只知道 LearningGoal——一个纯粹的业务对象。它不知道输入是从 HTTP JSON
来的还是从 CLI 参数来的。请求模型的字段名叫 weekly_hours 还是 hours_per_week,
JSON 用 camelCase 还是 snake_case——core 统统不关心。
看依赖方向:
router.py → import → core.py (LearningGoal, build_plan)
router.py → import → schemas.py (CreatePlanRequest)
core.py 不导入任何 HTTP/请求相关的模块
箭头从适配层指向 core,从适配层指向 schemas。core 自身没有任何指向外部框架或 传输模型的依赖。这保证了:
- CLI 入口可以直接
build_plan(LearningGoal(...)),不需要知道 HTTP 的存在 - 单元测试可以直接构造
LearningGoal,不需要模拟 HTTP 请求 - 将来加消息队列入口,同样直接调用 core 接口
转换的位置在适配层——它知道 HTTP,也知道 core,所以由它负责在两个世界之间 翻译。这个判断和上一章建立的依赖方向原则完全一致。
边界与常见误区¶
误区一:认为默认转换是"bug"应该全局关闭
默认转换是 Pydantic 的设计选择,不是缺陷。JSON 规范中数值只有 number 类型,
不区分 int 和 float;很多前端框架在序列化时也会把整数变成字符串(比如超大 ID)。
默认转换让这些"合理的类型不精确"不会变成硬性错误。全局开启 strict 模式意味着
你在要求所有客户端都完美区分 JSON 的类型边界——这个要求在公开 API 中可能过于
苛刻。
误区二:把所有业务规则都塞进 Pydantic 模型
Pydantic 的 @field_validator 和 @model_validator 确实能实现复杂校验。但
"能做"不等于"该做"。如果一个校验规则需要查数据库、调外部服务或依赖请求上下文,
它就不再是"字段约束"——而是业务逻辑。把业务逻辑伪装成字段约束塞进模型,会让
模型变得不可测试、不可复用、依赖满天飞。
判断标准很简单:这个规则只看字段值本身就能判断吗?如果是,放模型里;如果 需要任何外部信息,放业务层。
误区三:认为 extra="ignore" 是安全的因为"反正多余字段丢了"
多余字段确实被丢弃了,但问题是客户端不知道。如果客户端发了一个拼写错误的字段名
(比如 weeky_hours 而不是 weekly_hours),ignore 模式会静默丢弃它,然后
weekly_hours 使用默认值或报缺失——客户端很难定位到"原来是字段名拼错了"。
在开发阶段用 forbid 可以第一时间暴露这类问题。
误区四:直接把 Pydantic 请求模型传给 core 函数
"反正字段名一样,何必再转换一次?"——这和上一章"何必再设计一个 response_model" 的直觉一样。当前碰巧一样,不代表永远一样。请求模型会因为 API 设计变化而变(加 字段、改名),core 的业务对象会因为业务规则变化而变。让它们各自独立演化,才不会 互相拖累。
本章小结¶
从 P01 建立的"公开契约与内部类型分工"出发,本章深入了请求模型在 HTTP 入口 承担的具体校验角色。
Pydantic 默认对输入执行类型转换:兼容的字符串会被转为整数,浮点数会被截断判断,
布尔值会变成 0/1。这让接受边界比字段声明看起来更宽。strict mode 和额外字段策略
(extra="forbid" / "ignore")提供了收窄或维持边界的手段——选择哪种取决于
数据来源的可信度和业务对脏数据的容忍度。
校验通过保证的是字段类型和声明约束正确,不保证业务规则满足。weekly_hours=0
能通过类型校验但在业务上无意义——这两个层次的判断独立存在、归属不同代码层。
当校验失败时,Pydantic 的 ValidationError 提供结构化错误列表:每个错误精确
定位到字段、类型和原因,让客户端可以按字段展示错误信息。这些结构化数据将成为
下一章设计 HTTP 错误响应的输入。
已校验的请求模型在适配层转换为 core 期望的业务对象,core 不依赖任何请求模型的 类型——这保持了 core 的传输无关性,也延续了上一章建立的依赖方向原则。
练习¶
练习一:预测转换行为¶
以下模型接收 AI 学习助手的反馈评分:
from pydantic import BaseModel
class SubmitRatingRequest(BaseModel):
lesson_id: str
score: int
comment: str | None = None
预测以下输入分别会被接受还是拒绝,如果接受,最终 score 的值是什么:
{"lesson_id": "L01", "score": 4}{"lesson_id": "L01", "score": "5"}{"lesson_id": "L01", "score": 4.0}{"lesson_id": "L01", "score": 4.7}{"lesson_id": "L01", "score": true}
练习二:选择合适的策略组合¶
两个场景:
场景 A:你的 AI 学习助手提供公开 API,供第三方教育平台集成。这些平台使用 不同的技术栈,你无法控制它们的实现质量。
场景 B:AI 学习助手的前端和后端由同一团队开发,通过内部约定保持一致。任何 字段变更都会同步更新前后端。
为每个场景选择 strict 和 extra 策略,并解释理由。
练习三:区分校验层和业务层的职责¶
以下是 CreatePlanRequest 的一组潜在约束规则。判断每条规则应该放在 Pydantic
模型的字段约束中,还是放在业务逻辑层:
weekly_hours必须是正整数topic不能为空字符串focus_areas列表最多包含 5 个元素topic必须是系统已有课程目录中的有效话题- 同一用户 24 小时内创建的计划不能超过 3 个
weekly_hours不能超过用户当前订阅等级的上限
练习四:修正不当的模型传递¶
以下代码将请求模型直接传给了 core:
# router.py
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest):
return planning_service.create(request)
# core/planning.py
from api.schemas import CreatePlanRequest
class PlanningService:
def create(self, request: CreatePlanRequest) -> Plan:
daily = request.weekly_hours * 60 // 7
return Plan(topic=request.topic, daily_minutes=daily, ...)
任务: 1. 指出这种设计在 core 的 import 中暴露了什么问题 2. 重构代码,使 core 不再依赖请求模型(写出修改后的关键代码片段)
练习解析¶
练习一解析¶
| 输入 | 结果 | score 值 |
原因 |
|---|---|---|---|
4(整数) |
接受 | 4 |
类型完全匹配 |
"5"(字符串) |
接受 | 5 |
默认转换:可解析为整数的字符串被转换 |
4.0(浮点) |
接受 | 4 |
无损转换:4.0 转为 4 不丢失信息 |
4.7(浮点) |
拒绝 | — | 有损转换:4.7 转为 4 会丢失小数部分,Pydantic 拒绝 |
true(布尔) |
接受 | 1 |
默认转换:布尔值被视为 0/1 |
关键洞察:默认转换的判断标准是"能否无损转换为目标类型"。4.0 → 4 无损(小数
部分为零),4.7 → 4 有损(会丢失 .7),所以一个通过一个拒绝。这也意味着
score: true 会变成 1——如果你的评分只接受 1-5,这个"技术上合法"的输入
在业务上可能仍需要额外校验。
练习二解析¶
场景 A(公开 API,第三方集成):
strict=False(默认宽松模式):第三方平台技术栈各异,可能在 JSON 序列化时 把整数变成字符串(这在 JavaScript 处理大数时很常见)。宽松模式减少因类型微小 差异导致的集成失败。extra="ignore":第三方可能发送多余字段(旧版 SDK 携带废弃字段),静默丢弃 比硬性拒绝更友好,避免因为你加了新字段而导致旧版客户端全部报错。
场景 B(同团队内部接口):
strict=True:前后端同一团队,类型约定可以严格执行。任何类型不匹配都说明 有人没按约定实现——越早发现越好。extra="forbid":字段变更有同步流程,出现未声明字段必然是 bug(拼写错误、 忘记删除旧字段)。forbid让这类问题在第一次调用时就暴露,而不是静默吞掉 后在下游产生诡异行为。
核心逻辑:策略选择的本质是在"尽早暴露错误"和"容忍合理差异"之间做取舍。控制 力越强的场景越适合严格策略,控制力越弱的场景越需要容错空间。
练习三解析¶
| 规则 | 归属 | 理由 |
|---|---|---|
1. weekly_hours 必须是正整数 |
模型层 | 只看字段值本身就能判断,用 Field(gt=0) 即可 |
2. topic 不能为空字符串 |
模型层 | 只看字段值本身,用 Field(min_length=1) |
3. focus_areas 最多 5 个元素 |
模型层 | 只看字段值本身,用 Field(max_length=5) |
4. topic 必须是已有课程 |
业务层 | 需要查询课程目录(外部数据源) |
| 5. 24 小时内不超过 3 个计划 | 业务层 | 需要查询数据库中该用户的历史记录 |
| 6. 不超过订阅等级上限 | 业务层 | 需要查询用户的订阅状态(外部状态) |
判断标准:这个规则只看请求中的字段值就能做出判断吗?
规则 1-3 的答案是"是"——只需要字段值本身就能判断正负、长度、元素数量。
规则 4-6 的答案是"否"——它们需要额外信息(课程目录、用户历史、订阅状态), 而这些信息不在请求体中。把它们放进 Pydantic 模型意味着模型要注入数据库连接、 外部服务客户端……模型就不再是简单的数据校验器了。
练习四解析¶
问题:core/planning.py 中出现了 from api.schemas import CreatePlanRequest
——core 模块直接导入了 HTTP 层的请求模型。这意味着:
- core 现在依赖了 API 层(依赖方向反转)
- 如果请求模型因 API 设计变化而修改字段名,core 也要跟着改
- CLI、测试、消息队列等非 HTTP 入口被迫构造一个 HTTP 请求模型来调用 core
重构后的关键代码:
# core/planning.py — core 只知道自己的业务类型
from dataclasses import dataclass
@dataclass(frozen=True)
class LearningGoal:
topic: str
weekly_hours: int
class PlanningService:
def create(self, goal: LearningGoal) -> Plan:
daily = goal.weekly_hours * 60 // 7
return Plan(topic=goal.topic, daily_minutes=daily, ...)
# router.py — 适配层负责转换
from core.planning import PlanningService, LearningGoal
from api.schemas import CreatePlanRequest, PlanResponse
@router.post("/plans", status_code=201, response_model=PlanResponse)
async def create_plan(request: CreatePlanRequest):
goal = LearningGoal(topic=request.topic, weekly_hours=request.weekly_hours)
return planning_service.create(goal)
修改后,core 的 import 列表中没有任何 api 或 schemas 的痕迹。转换发生在
router.py(适配层),core 保持了传输无关性。