跳转至

客户端发了字符串,校验居然通过了?

上一章建立了一个关键分工:CreatePlanRequest 作为 HTTP 入口的请求模型,负责 声明客户端应该发送什么;LearningGoal 作为 core 的业务对象,负责定义业务逻辑 需要什么输入。适配层在二者之间做翻译,core 对 HTTP 一无所知。

分工清楚了。但有一个问题还没回答:CreatePlanRequest 声明了 weekly_hours: int, 客户端真的只能发整数吗?

你可能觉得答案很明显——声明了 int 当然只接受整数。但 Pydantic 在这件事上有 自己的主张,而且这个主张可能跟你的直觉不一样。

运行环境

Python 3.12、FastAPI 0.115+、Pydantic v2。

python -m pip install "fastapi[standard]>=0.115" "pydantic>=2,<3"

1. 当声明和实际输入不一致时

回到我们的 AI 学习助手。请求模型长这样:

from pydantic import BaseModel

class CreatePlanRequest(BaseModel):
    topic: str
    weekly_hours: int
    focus_areas: list[str] = []

现在来个测试。客户端发了一个"看起来有问题"的请求:

{"topic": "Python typing", "weekly_hours": "12", "focus_areas": ["FastAPI"]}

注意 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

再试一个更离谱的:

{"topic": "Python typing", "weekly_hours": "很多", "focus_areas": ["FastAPI"]}
# 这次会抛出 ValidationError
# Input should be a valid integer, unable to parse string as an integer

"很多" 被拒绝了。

现在的情况是:

输入值 声明类型 结果
12(整数) int 接受
"12"(字符串) int 接受,转换为 12
12.0(浮点数) int 接受,转换为 12
12.7(浮点数) int 拒绝(有损转换)
"很多"(字符串) int 拒绝
true(布尔) int 接受,转换为 1

这种行为叫做类型转换(coercion)。Pydantic 默认模式下,如果输入值能够无损 地转换为目标类型,就接受并转换;如果无法转换或会丢失信息,就拒绝。

这带来一个微妙的后果:客户端发送了类型"错误"的值(字符串 "12"),但校验 通过了,客户端甚至不知道自己犯了错。从客户端的视角看,它发的所有请求都成功了, 完全没有动力修正自己的实现。

默认转换悄悄扩宽了接受边界——声明的是 int,但实际上 "12"12.0true 都能进来。这不一定是坏事,但你至少应该知道它在发生。

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"}

# ValidationError: Input should be a valid integer
# input_value='12', input_type=str

在 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] = []
# ValidationError: Extra inputs are not permitted
# input_value='admin_password', input_type=str

现在把这些策略放在一起看:

策略 行为 适用场景
默认(lax + ignore) 兼容转换、忽略多余字段 面向多样化客户端,减少适配成本
strict=True 拒绝类型不精确匹配 严格要求客户端发送正确类型
extra="forbid" 拒绝未声明字段 防止脏数据、发现客户端实现错误
extra="ignore" 静默丢弃未声明字段 宽松兼容,但可能掩盖客户端 bug

先停下来想一想:一个面向移动 APP 的公开接口和一个内部微服务间的接口,你会选 不同的策略吗?

面向移动 APP 时,客户端版本分散、更新不可控,用默认的宽松模式可以减少因为旧版 APP 多带了一个废弃字段而导致 400 错误的情况。而内部微服务间通信,双方都在你的 控制下,用 strict=True + extra="forbid" 可以第一时间发现任何不符合约定的 调用——出了问题立刻报错,比静默接受再在下游出诡异 bug 要好得多。

关键认知:策略选择应由数据来源和业务风险决定,不是"严格总比宽松好"。每种 策略都是在"尽早发现错误"和"减少客户端适配摩擦"之间做取舍。

3. 校验通过了,但业务还是失败了

到目前为止,我们一直在讨论"输入能不能通过校验"。但通过校验之后呢?

看这个输入:

request = CreatePlanRequest(topic="Python typing", weekly_hours=0)
print(request.weekly_hours)  # 0

校验通过了——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 的值是什么:

  1. {"lesson_id": "L01", "score": 4}
  2. {"lesson_id": "L01", "score": "5"}
  3. {"lesson_id": "L01", "score": 4.0}
  4. {"lesson_id": "L01", "score": 4.7}
  5. {"lesson_id": "L01", "score": true}

练习二:选择合适的策略组合

两个场景:

场景 A:你的 AI 学习助手提供公开 API,供第三方教育平台集成。这些平台使用 不同的技术栈,你无法控制它们的实现质量。

场景 B:AI 学习助手的前端和后端由同一团队开发,通过内部约定保持一致。任何 字段变更都会同步更新前后端。

为每个场景选择 strictextra 策略,并解释理由。

练习三:区分校验层和业务层的职责

以下是 CreatePlanRequest 的一组潜在约束规则。判断每条规则应该放在 Pydantic 模型的字段约束中,还是放在业务逻辑层:

  1. weekly_hours 必须是正整数
  2. topic 不能为空字符串
  3. focus_areas 列表最多包含 5 个元素
  4. topic 必须是系统已有课程目录中的有效话题
  5. 同一用户 24 小时内创建的计划不能超过 3 个
  6. 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 列表中没有任何 apischemas 的痕迹。转换发生在 router.py(适配层),core 保持了传输无关性。

参考资料