《Python编程实战》7.1 Pydantic V2 模型设计

上一章我们用 SQLAlchemy 打通了数据库,这一节回到 HTTP 边界:用 Pydantic V2(2.13.5)设计请求/响应模型。覆盖 Field 约束与 Annotated 复用、field_validator 与 model_validator、ConfigDict 关键开关、别名、computed_field,以及 model_dump 的取舍与 from_attributes。

本节目标:把 Pydantic V2 从「会用」推进到「设计得好」,掌握请求/响应模型拆分、约束复用、校验器、序列化取舍与 ORM 集成。
适用版本:Python 3.12+(实测 3.14.6);pydantic 2.13.5、fastapi 0.143.0

7.1 Pydantic V2 模型设计

第 6 章我们把数据库这一层做扎实了:SQLAlchemy 2.1.4 的类型化模型、事务与连接池、Alembic 迁移。但数据库模型(ORM)是「存储视角」的,它带着一堆不该暴露给外部的东西——自增主键、软删除标记、内部状态。真正对外的契约由 Pydantic 模型定义。本节就专门讲怎么把这层模型设计好。

7.1.1 请求模型与响应模型必须分开

新手最容易犯的错,是「一个模型走天下」:拿 ORM 对象直接 return 给前端。这会一次性踩三个坑:

  1. 泄露内部字段:hashed_password、is_deleted、internal_note 全被序列化出去。
  2. 耦合存储与契约:数据库改个列名,API 就跟着变,客户端全挂。
  3. 无法差异化校验:创建时要 name 必填,更新时 name 可选,同一个模型做不到。

正确做法是入参(In)与出参(Out)各建一个模型:

from pydantic import BaseModel, ConfigDict, Field


class ItemIn(BaseModel):
    """客户端提交的创建请求:只允许这些字段,多一个都拒收。"""
    model_config = ConfigDict(extra="forbid")

    sku: str = Field(pattern=r"^SKU-\d{4}$", examples=["SKU-0001"])
    name: str = Field(min_length=1, max_length=64)
    price: float = Field(gt=0, examples=[299.0])


class ItemOut(BaseModel):
    """返回给客户端:不含任何内部字段,且补上服务端派生的字段。"""
    sku: str
    name: str
    price: float
    currency: str = "CNY"

ItemIn 用 extra="forbid" 把「多余字段」直接挡在门外;ItemOut 则是一个纯粹的「投影」,想加 currency 就加,跟数据库列无关。这两个模型的字段集合可以完全不一致,这正是解耦的意义。

7.1.2 用 Field 加约束,用 Annotated 复用约束

Field 是给单个字段挂约束的地方,最常用的几个:

参数适用类型含义
gt / ge数值大于 / 大于等于
lt / le数值小于 / 小于等于
min_length / max_length字符串、列表长度下限 / 上限
pattern字符串正则匹配(注意是部分匹配,要锚定就写 ^...$)
default_factory任意可变默认值(列表、字典必须用它)
examples任意只影响 OpenAPI 文档,不参与校验

同一套约束如果在多个字段上重复,抽成 Annotated 类型别名最省事:

from typing import Annotated
from pydantic import BaseModel, Field

# 金额:> 0 且 <= 100 万
Money = Annotated[float, Field(gt=0, le=1_000_000)]


class Item(BaseModel):
    price: Money
    cost: Money          # 复用同一套约束,改一处全生效

Annotated 的第一个参数是真实类型,后面都是「元数据」。Pydantic 会读取这些元数据;静态检查器只看到 float。这样既不丢类型安全,又能集中管理业务约束——金额上限要从 100 万调到 1000 万时,只改 Money 一行。

7.1.3 自定义校验:field_validator 与 model_validator

内置约束覆盖不了的规则,自己写。字段级用 field_validator,整模型级用 model_validator。下面的实测代码把两个都用上了:

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator


class SKU(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, populate_by_name=True)

    sku_id: str = Field(alias="skuId", pattern=r"^SKU-\d{4}$")
    name: str = Field(min_length=1, max_length=64)
    price: float = Field(gt=0, le=1_000_000)
    stock: int = Field(default=0, ge=0)
    tags: list[str] = Field(default_factory=list)

    @field_validator("tags", mode="before")
    @classmethod
    def split_csv(cls, v: object) -> object:
        # 兼容 "a,b,c" 这种逗号串,先拆再进后续校验
        if isinstance(v, str):
            return [t.strip() for t in v.split(",") if t.strip()]
        return v

    @field_validator("name")
    @classmethod
    def no_reserved_word(cls, v: str) -> str:
        if v.lower() == "test":
            raise ValueError("name 不能叫 test")
        return v

    @model_validator(mode="after")
    def check_stock_rule(self) -> "SKU":
        if self.stock == 0 and "预售" not in self.tags:
            raise ValueError("缺货商品必须打上「预售」标签")
        return self

关键区别在 mode:

  • mode="before":在 Pydantic 做类型转换之前运行,输入是原始值(可能是任何类型)。适合「先把 CSV 串拆成列表」这类预处理。
  • mode="after"(field_validator 默认):在字段已转成目标类型之后运行,拿到的是干净的值。
  • model_validator(mode="after"):等所有字段都校验完再跑,能访问整个模型,专治跨字段规则(缺货必须带预售标签、结束日期要晚于开始日期)。

实测跑一遍,看真实输出:

== 正常构造 ==
sku_id='SKU-0001' name='机械键盘' price=299.0 stock=5 tags=['gaming', '有线']
dump(默认): {'sku_id': 'SKU-0001', 'name': '机械键盘', 'price': 299.0, 'stock': 5, 'tags': ['gaming', '有线']}
dump(by_alias): {'skuId': 'SKU-0001', 'name': '机械键盘', 'price': 299.0, 'stock': 5, 'tags': ['gaming', '有线']}

== extra=forbid ==
extra_forbidden -> Extra inputs are not permitted

== 跨字段校验 ==
value_error -> Value error, 缺货商品必须打上「预售」标签

注意 tags 传的是字符串 "gaming, 有线",before 校验器把它拆成了列表;str_strip_whitespace 顺手去掉了 name 的首尾空格。校验器负责语义,配置负责格式,各司其职。

7.1.4 model_config:ConfigDict 的四个关键开关

model_config = ConfigDict(...) 是模型的全局行为开关。工程上最该先定下来的四个:

开关作用什么时候用
extra="forbid"拒绝未声明的字段所有入参模型,防止客户端塞垃圾
str_strip_whitespace=True自动去字符串首尾空白用户输入类模型
populate_by_name=True允许用字段名而非别名填充内部代码用 sku_id、外部用 skuId
from_attributes=True允许从任意对象属性构造ORM 转换(见 7.1.7)

extra 的三个取值要分清楚:forbid 报错、ignore 静默丢弃、allow 原样保留。入参用 forbid,出参可以用 ignore——对客户端宽容、对自己严格,是接口设计的常见取向。

7.1.5 别名:对外字段名与内部字段名解耦

前端习惯 camelCase,Python 习惯 snake_case。别名字段解决这个矛盾:

sku_id: str = Field(alias="skuId")

打开 populate_by_name=True 后,SKU(skuId="SKU-0001", ...) 和 SKU(sku_id="SKU-0001", ...) 都能构造。序列化时用 by_alias=True 就能输出 skuId:

dump(默认):      {'sku_id': 'SKU-0001', 'name': '机械键盘', ...}
dump(by_alias):  {'skuId': 'SKU-0001', 'name': '机械键盘', ...}

Field 还支持更细的 validation_alias / serialization_alias,可以做到「入参读 skuId、出参写 sku」,但绝大多数项目用 alias + populate_by_name 就够了,不必过度设计。

7.1.6 model_dump 的取舍:一次说清

model_dump 的参数决定「导出的形状」。实测一组对比:

from datetime import datetime, timezone
from pydantic import BaseModel, ConfigDict, Field, computed_field


class Order(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    order_id: str
    qty: int = Field(gt=0)
    unit_price: float = Field(gt=0)
    coupon: str | None = None
    created_at: datetime = Field(default_factory=lambda: datetime(2026, 9, 26, 10, 0, tzinfo=timezone.utc))

    @computed_field
    @property
    def total(self) -> float:
        return round(self.qty * self.unit_price, 2)


o = Order(order_id="A-1", qty=3, unit_price=19.9)

各选项的真实输出:

默认:            {'order_id': 'A-1', 'qty': 3, 'unit_price': 19.9, 'coupon': None,
                  'created_at': datetime.datetime(2026, 9, 26, 10, 0, tzinfo=datetime.timezone.utc), 'total': 59.7}
mode=json:       {'order_id': 'A-1', ..., 'created_at': '2026-09-26T10:00:00Z', 'total': 59.7}
exclude_none:    {'order_id': 'A-1', 'qty': 3, 'unit_price': 19.9, 'created_at': ..., 'total': 59.7}
exclude={'coupon'}: {'order_id': 'A-1', 'qty': 3, 'unit_price': 19.9, 'created_at': ..., 'total': 59.7}
include={'order_id','total'}: {'order_id': 'A-1', 'total': 59.7}
json 字符串:      {"order_id":"A-1","qty":3,"unit_price":19.9,"coupon":null,"created_at":"2026-09-26T10:00:00Z","total":59.7}
exclude_unset:   {'order_id': 'A-2', 'qty': 1, 'unit_price': 9.0, 'total': 9.0}

逐条解读取舍:

  • mode="json":把 datetime、Decimal、UUID 转成 JSON 原生类型。直接给 json.dumps 用,或返回给 FastAPI 响应,几乎总是要开。
  • exclude_none=True:去掉值为 None 的字段。适合「可选字段不占位」的响应风格,但注意客户端要能接受字段缺失。
  • exclude / include:按字段名裁剪,做「一个模型、多种视图」时很顺手。
  • exclude_unset=True:只导出显式传入的字段。这是 PATCH 局部更新的核心——客户端只发了 name,就只更新 name,其余字段不动。

注意最后一行的坑:exclude_unset 输出里仍然有 total,因为 computed_field 永远算「已设置」。派生字段不受 exclude_unset 影响,做局部更新时若不想带上它,得显式 exclude={"total"}。

7.1.7 computed_field:只读派生字段

上面的 total = qty * unit_price 用了 @computed_field。它的特点是:

  • 不进 __init__:构造时不能传 total,只能由别的字段算出来。
  • 出现在 dump 和 JSON Schema 里:对外表现和普通字段一样,前端能读到。
  • 始终是只读:不能被赋值。

用它的判断标准很简单:这个值完全由其他字段决定,且不需要存库。金额合计、full_name、is_expired 都属于这一类。反过来,如果它需要独立查询或持久化,就该是一个普通字段。

7.1.8 from_attributes:打通 ORM 到 Pydantic

第 6 章的 SQLAlchemy 模型是普通 Python 对象,属性名和 Pydantic 字段一致。加一行配置,就能直接转换:

class Row:                      # 模拟 ORM 行对象
    def __init__(self) -> None:
        self.order_id = "B-9"
        self.qty = 2
        self.unit_price = 5.0
        self.coupon = None

o2 = Order.model_validate(Row())   # 直接吃对象,不用手写 dict
validate(Row) -> order_id='B-9' qty=2 unit_price=5.0 coupon=None created_at=... total=10.0

model_config = ConfigDict(from_attributes=True) 之后,model_validate 既能吃 dict,也能吃「带对应属性的任意对象」——ORM 行、dataclass、SimpleNamespace 都行。这样从数据库查到记录后,ItemOut.model_validate(orm_row) 一步到位,不用 dict(row.__dict__) 那种脆弱的转换。第 6 章讲的 ORM 模型与这里的出参模型就此接上了。

7.1.9 模型设计检查清单

落笔设计一组 API 模型时,按这张表过一遍:

  • 入参、出参分开建模型,绝不直接返回 ORM 对象。
  • 入参模型统一 extra="forbid";用户输入类模型开 str_strip_whitespace。
  • 重复约束抽成 Annotated 别名,集中管理。
  • 跨字段规则写 model_validator(mode="after"),别塞进单个字段校验器。
  • 响应序列化走 mode="json";PATCH 用 exclude_unset=True 并显式排掉 computed 字段。
  • 对外字段名与内部不一致时用 alias + populate_by_name。
  • 性能敏感、只读的场景可加 ConfigDict(frozen=True, slots=True);但先测量再优化,别过早加。

延伸阅读:Python 类型系统与 Pydantic V2 从类型系统角度讲得更全,本节聚焦「接口模型怎么设计」。

小结

  • 请求模型与响应模型必须分开:extra="forbid" 的入参挡垃圾,出参只做投影,杜绝泄露内部字段。
  • Field 管单字段约束,Annotated 复用约束;field_validator(before/after)管字段级规则,model_validator(mode="after") 管跨字段规则。
  • ConfigDict 的 extra / str_strip_whitespace / populate_by_name / from_attributes 是工程上最该先定的四个开关。
  • model_dump 的 mode="json"、exclude_none、exclude_unset 各有用途;exclude_unset 是 PATCH 局部更新的关键,但要注意 computed_field 不受它影响。
  • computed_field 放「由其他字段派生、不需持久化」的值;from_attributes=True 让 ORM 行一步转成出参模型。

模型定义好了,它就不只是校验工具,而是可以被机器读取的接口契约。下一节我们就把这层契约导出成 OpenAPI,并据此生成客户端代码。

阅读导航:上一节:Alembic 迁移与数据演进 · 下一节:OpenAPI 契约与客户端代码生成 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时