4、Pydantic模型与数据验证:字段类型、嵌套模型、验证器、自定义校验、序列化与反序列化、模型配置
在FastAPI的架构体系中,Pydantic模型是连接外部请求与内部业务逻辑的核心枢纽。它不仅是数据载体,更是一道强制的类型安全边界。本章将深入剖析Pydantic v2的核心机制,从字段声明、嵌套结构、验证器到序列化与模型配置,帮助你构建既严谨又灵活的API数据层。理解这些内容,意味着你将能从“能跑”迈向“健壮”,在源头拦截非法数据,并为团队协作提供清晰的契约。
上图展示了Pydantic在FastAPI请求生命周期中的核心位置:数据从外部进入,经过模型验证引擎的层层检查,最终要么生成可信赖的模型实例,要么被拒绝并返回错误。下方的序列化与配置模块则决定了模型如何输出以及如何适应不同的使用场景。接下来,我们将逐一拆解这些环节。
4.1 字段类型系统:从基础到泛型
核心原理
Pydantic v2的字段类型声明不仅限于Python内置类型。它通过Field()函数和类型注解的组合,实现了对数据格式的精细约束。例如,str类型可以附加min_length、max_length、pattern(正则)等约束;int类型可以附加ge(大于等于)、le(小于等于)等数值范围限制。这种声明式校验将业务规则直接嵌入数据模型,使得代码自文档化。
操作流程
- 定义基础字段:使用类型注解声明字段,如
name: str。 - 添加约束:通过
Field(default=..., min_length=2, max_length=50)细化规则。 - 使用复合类型:利用
List[int]、Dict[str, float]、Optional[str]等表达复杂数据结构。 - 应用泛型:对于分页等通用结构,使用
Generic[T]定义可复用的模型模板。
案例拆解:用户注册模型
from pydantic import BaseModel, Field, EmailStr, field_validator
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=20, pattern=r"^[a-zA-Z0-9_]+$")
email: EmailStr
age: int = Field(..., ge=0, le=120)
tags: list[str] = Field(default_factory=list, max_length=5)
score: float = Field(0.0, ge=0.0, le=100.0)
在这个案例中,username通过正则限制了字符集,age限制了生理合理范围,tags限制了数量,score限制了百分比区间。这些约束在API入口处即生效,避免了业务层重复的防御性代码。
EmailStr、UrlStr等Pydantic内置的语义类型,它们比手动正则更可靠且可读性更强。但需注意,这些类型依赖email-validator库,部署时需额外安装。4.2 嵌套模型与递归验证
核心原理
嵌套模型允许将一个Pydantic模型作为另一个模型的字段类型。当外部模型被验证时,Pydantic会递归地验证所有嵌套模型,确保整个数据树的结构和内容都符合契约。这特别适合表达具有层级关系的业务对象,如“订单包含多个商品,每个商品有价格和数量”。
判断方法
判断是否需要嵌套模型,可以看数据是否存在“一对多”或“整体-部分”的关系。如果API请求体中出现明显的层级结构,例如{"user": {...}, "items": [{...}, {...}]},就应该定义对应的嵌套模型。
实战场景:订单创建
class OrderItem(BaseModel):
product_id: int
quantity: int = Field(..., ge=1)
unit_price: float = Field(..., gt=0)
class OrderCreate(BaseModel):
user_id: int
items: list[OrderItem]
coupon_code: str | None = None
@field_validator("items")
@classmethod
def check_items_not_empty(cls, v):
if not v:
raise ValueError("订单必须包含至少一个商品")
return v
这里OrderCreate嵌套了OrderItem列表。验证时,Pydantic会先验证user_id的类型,然后遍历items列表,对每个元素执行OrderItem的验证。如果某个商品的数量为0或价格为负,整个请求会被拒绝,并返回精确的错误路径(如items.2.quantity)。
model_validator(mode="before")在解析前进行预处理,或使用TypeAdapter进行局部验证。4.3 验证器:从字段级到模型级
核心原理
验证器是Pydantic提供的最强大的扩展点。field_validator允许对单个字段进行自定义校验,而model_validator则允许在模型整体层面进行跨字段校验(如确认密码一致)。在v2版本中,验证器通过装饰器模式注册,并支持mode="before"(在类型转换前执行)和mode="after"(在类型转换后执行)两种模式。
操作流程
- 单字段校验:使用
@field_validator("field_name")装饰一个类方法,方法接收字段值,返回修改后的值或抛出ValueError。 - 多字段校验:使用
@model_validator(mode="after")装饰一个类方法,方法接收整个模型实例,可访问所有字段。 - 前置校验:使用
mode="before"处理原始输入,例如将字符串"1,2,3"转换为列表[1,2,3]。
案例拆解:密码确认与数据清洗
class RegisterForm(BaseModel):
username: str
password: str = Field(..., min_length=8)
confirm_password: str
referral_code: str | None = None
@field_validator("password")
@classmethod
def validate_password_strength(cls, v):
if not any(c.isdigit() for c in v):
raise ValueError("密码必须包含数字")
return v
@model_validator(mode="after")
def check_passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("两次输入的密码不一致")
return self
@field_validator("referral_code", mode="before")
@classmethod
def strip_referral(cls, v):
if isinstance(v, str):
return v.strip().upper() or None
return v
这个案例展示了三种验证场景:password的强度校验、confirm_password的跨字段一致性校验,以及referral_code的前置清洗(去除空格并转为大写)。注意,model_validator必须返回模型实例,否则会导致验证失败。
Field参数),减少自定义验证器的数量。如果必须使用,优先选择mode="before",因为它可以避免不必要的类型转换。4.4 序列化与反序列化:模型与外部世界的桥梁
核心原理
序列化(model_dump())将模型实例转换为Python字典或JSON字符串,用于API响应;反序列化(model_validate())将外部数据(字典、JSON字符串或对象)转换为模型实例,用于请求处理。Pydantic v2的序列化器支持by_alias、exclude_unset、exclude_none等参数,精细控制输出内容。
对比表:序列化与反序列化
| 维度 | 序列化 (model_dump) | 反序列化 (model_validate) |
|---|---|---|
| 方向 | 模型 → 字典/JSON | 字典/JSON → 模型 |
| 常用场景 | API响应、日志输出 | API请求、配置加载 |
| 关键参数 | exclude_unset, by_alias | strict, from_attributes |
| 错误处理 | 通常不会失败 | 失败抛出ValidationError |
实战场景:响应模型与敏感字段过滤
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
is_active: bool = True
model_config = {"from_attributes": True}
# 在路由中使用
@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: int):
# 假设从数据库获取ORM对象
user_orm = get_user_from_db(user_id)
return user_orm # FastAPI自动调用model_validate
这里通过model_config = {"from_attributes": True}允许从ORM对象直接创建模型实例。FastAPI的response_model参数会自动调用序列化,确保返回给客户端的数据只包含UserOut中声明的字段,从而隐藏password_hash等敏感信息。
response_model,而不是直接返回数据库模型。这能有效防止数据过度暴露,并解耦内部存储结构与外部API契约。4.5 模型配置:定制模型行为
核心原理
model_config是Pydantic v2中控制模型全局行为的配置类。通过设置extra(处理多余字段)、str_strip_whitespace(自动去除字符串首尾空格)、validate_assignment(在赋值时重新验证)等选项,可以显著改变模型的容错性和严格性。
配置项清单
extra="forbid":拒绝未知字段,适合严格契约的API。extra="ignore":忽略未知字段(默认行为)。extra="allow":将未知字段存入__pydantic_extra__。str_strip_whitespace=True:自动清理用户输入。validate_assignment=True:在模型实例创建后修改属性时也进行验证。populate_by_name=True:允许使用字段名或别名进行初始化。
案例拆解:严格模式与容错模式
class StrictConfig(BaseModel):
model_config = {"extra": "forbid", "str_strip_whitespace": True}
name: str
age: int
class LenientConfig(BaseModel):
model_config = {"extra": "ignore"}
name: str
age: int
# 测试
data = {"name": " Alice ", "age": 30, "unknown_field": 1}
try:
s = StrictConfig(**data) # 会报错,因为unknown_field
except Exception as e:
print("Strict failed:", e)
l = LenientConfig(**data) # 正常,忽略unknown_field
print(l.name) # 输出 " Alice "(未strip)
这个对比展示了配置对API健壮性的影响。对于对外API,推荐使用extra="forbid"来捕获客户端传参错误;对于内部服务间调用,可以使用extra="ignore"以兼容版本演进。
extra="forbid"。如果API需要向前兼容(例如客户端会多传字段),这会导致不必要的400错误。建议在版本迭代中逐步收紧策略,并在文档中明确说明。4.6 综合实战:构建一个带完整验证的用户注册API
背景与目标
假设我们正在开发一个SaaS平台的用户服务,需要提供一个注册端点。业务约束包括:用户名唯一且格式受限、密码强度要求、年龄需在18-100岁之间、邀请码可选但必须符合特定格式。同时,响应中不能泄露密码哈希。
实现方案
from pydantic import BaseModel, Field, EmailStr, field_validator, model_validator
from datetime import datetime
class UserCreate(BaseModel):
model_config = {"extra": "forbid", "str_strip_whitespace": True}
username: str = Field(..., min_length=3, max_length=20, pattern=r"^[a-z0-9_]+$")
email: EmailStr
password: str = Field(..., min_length=8)
confirm_password: str
age: int = Field(..., ge=18, le=100)
invite_code: str | None = Field(None, pattern=r"^INV-\d{6}$")
@field_validator("username")
@classmethod
def username_lowercase(cls, v):
return v.lower()
@field_validator("password")
@classmethod
def password_complexity(cls, v):
if not any(c.isupper() for c in v) or not any(c.isdigit() for c in v):
raise ValueError("密码必须包含大写字母和数字")
return v
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.confirm_password:
raise ValueError("两次密码输入不一致")
return self
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
age: int
created_at: datetime
model_config = {"from_attributes": True}
@app.post("/register", response_model=UserOut, status_code=201)
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
# 检查用户名唯一性
existing = db.query(User).filter(User.username == user_data.username).first()
if existing:
raise HTTPException(status_code=400, detail="用户名已存在")
# 创建用户(密码哈希在业务层处理)
new_user = User(**user_data.model_dump(exclude={"confirm_password"}), password_hash=hash_password(user_data.password))
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
结果与复盘
该端点实现了:输入严格性(未知字段直接拒绝)、数据清洗(用户名自动转小写、去除首尾空格)、业务规则(密码复杂度、年龄范围、邀请码格式)、安全输出(响应模型不包含密码字段)。通过model_dump(exclude={"confirm_password"}),我们避免了将确认密码传入数据库层。
代价与风险:严格模式可能导致客户端在升级过程中出现兼容性问题,需要与前端团队紧密配合。此外,密码复杂度规则可能增加用户流失率,建议在UI层提供实时提示。
复盘经验:将验证逻辑全部放在Pydantic模型中,使得单元测试变得非常简单——只需构造不同的字典输入,断言是否抛出ValidationError。这比在业务代码中编写if-else判断要清晰得多,也更容易维护。