Pydantic v2 实战笔记:字段校验、模型校验与环境变量配置

文章目录

      • [第一阶段:为什么需要 Pydantic?(背景与痛点)](#第一阶段:为什么需要 Pydantic?(背景与痛点))
        • [1. 场景引入:裸奔的 Python 数据处理](#1. 场景引入:裸奔的 Python 数据处理)
        • [2. Pydantic 的解法:声明式数据校验](#2. Pydantic 的解法:声明式数据校验)
      • 第二阶段:核心功能全景图
      • 第三阶段:逐功能实战学习
        • [🔹 1. 基础模型与类型强制转换(Type Coercion)](#🔹 1. 基础模型与类型强制转换(Type Coercion))
        • [🔹 2. Field 字段约束](#🔹 2. Field 字段约束)
        • [🔹 3. 自定义校验器(Validators)](#🔹 3. 自定义校验器(Validators))
          • [3.1 字段级校验器 `@field_validator`](#3.1 字段级校验器 @field_validator)
          • [3.2 模型级校验器 `@model_validator`(跨字段校验)](#3.2 模型级校验器 @model_validator(跨字段校验))
        • [🔹 4. 嵌套模型与复杂结构](#🔹 4. 嵌套模型与复杂结构)
        • [🔹 5. 序列化与导出](#🔹 5. 序列化与导出)
        • [🔹 6. 配置管理(Settings)](#🔹 6. 配置管理(Settings))
        • [🔹 7. 高级类型技巧](#🔹 7. 高级类型技巧)
          • [7.1 Discriminated Union(标签联合)](#7.1 Discriminated Union(标签联合))
          • [7.2 泛型模型](#7.2 泛型模型)

第一阶段:为什么需要 Pydantic?(背景与痛点)

1. 场景引入:裸奔的 Python 数据处理

假设你正在开发一个用户注册接口,接收如下 JSON 数据:

python 复制代码
user_data = {
    "name": "Alice",
    "age": "25",          # 注意:前端传来的是字符串
    "email": "alice@example.com",
    "signup_ts": "2024-06-01T12:00:00",
    "tags": "vip,early-bird"  # 注意:前端传来的是逗号分隔字符串
}

在没有 Pydantic 时,你需要写多少防御性代码?

python 复制代码
# 😩 传统做法:手动校验 + 类型转换地狱
def parse_user(data):
    # 1. 检查必填字段
    for key in ["name", "age", "email"]:
        if key not in data:
            raise ValueError(f"Missing {key}")
    
    # 2. 类型转换
    try:
        age = int(data["age"])
    except (ValueError, TypeError):
        raise ValueError("age must be an integer")
    
    # 3. 业务校验
    if age < 0 or age > 150:
        raise ValueError("Invalid age range")
    
    # 4. 日期解析
    from datetime import datetime
    signup_ts = datetime.fromisoformat(data["signup_ts"])
    
    # 5. 列表拆分
    tags = [t.strip() for t in data["tags"].split(",")]
    
    return {"name": data["name"], "age": age, "email": data["email"], 
            "signup_ts": signup_ts, "tags": tags}

痛点总结:

  • 类型不安全 :Python 是动态语言,dict 里的值可能是任何类型。
  • 校验逻辑分散:转换、校验、业务规则混在一起,难以维护。
  • 重复造轮子:每个接口都要写一遍类似的校验代码。
  • 错误信息不友好:报错只告诉你 "Invalid age",不告诉你是哪个字段、什么值导致的。
2. Pydantic 的解法:声明式数据校验

Pydantic 的核心哲学是:用类型注解(Type Hints)定义数据结构,校验和转换自动完成。

python 复制代码
from pydantic import BaseModel, EmailStr, field_validator
from datetime import datetime

class User(BaseModel):
    name: str
    age: int
    email: EmailStr
    signup_ts: datetime
    tags: list[str]

    @field_validator("tags", mode="before")
    @classmethod
    def split_tags(cls, v):
        if isinstance(v, str):
            return [t.strip() for t in v.split(",")]
        return v

# ✅ 一行搞定:校验 + 转换 + 结构化
user = User(**user_data)
print(user)
print(type(user.age))       # <class 'int'>  自动将 "25" 转为 25
print(type(user.signup_ts)) # <class 'datetime.datetime'>
print(user.tags)            # ['vip', 'early-bird']

🧪 动手验证 1 :将上面两段代码分别运行,对比代码量和可读性。尝试把 age 改为 "abc",观察 Pydantic 抛出的错误信息有多详细。

(注:EmailStr 需安装 pip install email-validator,若不想安装可暂时将 EmailStr 改为 str)


第二阶段:核心功能全景图

功能模块 解决的问题 关键词
基础模型 定义结构、自动类型强制转换 BaseModel, Type Coercion
字段约束 值范围、长度、正则等细粒度校验 Field, constr, conint
自定义校验器 复杂业务规则、跨字段校验 @field_validator, @model_validator
嵌套模型 处理复杂层级 JSON 模型组合、list[SubModel]
配置管理 环境变量加载、别名映射 Settings, alias, ConfigDict
序列化/导出 转 dict/JSON、排除敏感字段 model_dump, model_dump_json
高级类型 Union、Literal、泛型、 discriminated union Union, Literal, Generic

第三阶段:逐功能实战学习

🔹 1. 基础模型与类型强制转换(Type Coercion)

Pydantic 不是 严格的类型检查器,而是数据解析器。它会尽力将输入转换为目标类型。

python 复制代码
from pydantic import BaseModel

class Product(BaseModel):
    id: int
    price: float
    in_stock: bool

# 🧪 测试:安全的自动转换
p = Product(
    id="123",           # str → int ✅
    price="9.99",       # str → float ✅
    in_stock="yes"      # str → bool ✅ ("yes","true","1","on" → True)
)

print(p.model_dump())
# {'id': 123, 'price': 9.99, 'in_stock': True}

⚠️ 注意 :如果你想要严格模式 (禁止所有隐式转换),可以使用 StrictStr, StrictInt 或设置 model_config = ConfigDict(strict=True)

python 复制代码
from pydantic import BaseModel, StrictInt

class StrictProduct(BaseModel):
    id: StrictInt

# StrictProduct(id="123")  # ❌ ValidationError! 不接受字符串
StrictProduct(id=123)      # ✅ 仅接受真正的 int

🧪 动手验证 2 :测试 in_stock 传入 "no", "false", "0", "" 分别得到什么布尔值。


🔹 2. Field 字段约束

当类型正确但值不合法 时,用 Field 添加约束。

python 复制代码
from pydantic import BaseModel, Field

class CreateUser(BaseModel):
    username: str = Field(
        min_length=3, 
        max_length=20,
        pattern=r'^[a-zA-Z0-9_]+$',  # 正则:仅字母数字下划线
        description="用户名"
    )
    age: int = Field(ge=0, le=150)     # >= 0 且 <= 150
    score: float = Field(gt=0, lt=100) # > 0 且 < 100
    bio: str = Field(default="", max_length=500)  # 可选+默认值

# ✅ 合法
u = CreateUser(username="alice_01", age=25, score=88.5)
print(u.username)

# ❌ 非法 - 试试以下每种情况(取消注释逐个测试)
# CreateUser(username="ab", age=25, score=88.5)      # 太短
# CreateUser(username="alice@!", age=25, score=88.5)  # 非法字符
# CreateUser(username="alice_01", age=-1, score=88.5) # 年龄超范围

🧪 动手验证 3 :创建一个密码字段,要求至少8位、包含数字和字母(提示:用 pattern 正则实现)。


🔹 3. 自定义校验器(Validators)
3.1 字段级校验器 @field_validator

@field_validator 可以理解为:你给某个字段"挂"了一段函数 ,Pydantic 在创建模型时会自动调用它。

它常用来做两件事:

  • 清洗/标准化输入:返回一个"处理后的值",这个值才会最终赋给字段(例如去空格、大小写统一)。
  • 拦截非法输入 :如果在校验器里 raise ValueError(...),Pydantic 会把它包装成 ValidationError,模型不会创建成功。

@classmethod 在这里的意义是:字段校验发生在构造模型的过程中 ,校验函数以"类方法"的签名被调用(第一个参数是 cls),因此不依赖实例 self

(你也可以把它理解为:还没拿到完整的 Order(...) 实例之前,就需要先把每个字段的值校验/清洗好。)

python 复制代码
from pydantic import BaseModel, field_validator

class Order(BaseModel):
    product_name: str
    quantity: int

    @field_validator("product_name")
    @classmethod
    def normalize_name(cls, v: str) -> str:
        """自动清洗商品名"""
        return v.strip().title()

    @field_validator("quantity")
    @classmethod
    def check_quantity(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("数量必须大于0")
        return v

o = Order(product_name="  iphone 15 pro  ", quantity=2)
print(o.product_name)  # "Iphone 15 Pro" ✅ 自动格式化
3.2 模型级校验器 @model_validator(跨字段校验)

当字段之间有依赖关系时使用:

@model_validator 可以理解为:对"整个模型"做一次统一校验,适合写"跨字段规则"(字段之间的一致性约束)。

  • 为什么不用 @field_validatorfield_validator 更偏向"单字段清洗/校验"。跨字段规则如果硬塞到某个字段的 validator 里,会依赖其它字段是否已校验、可读性也更差;用 model_validator 意图最清晰。
  • mode="after" 是什么意思 :表示在 Pydantic 完成各字段的解析/校验之后再运行;此时实例已创建,所以方法用 self,可以同时访问多个字段。
  • 为什么要 return selfafter 模式下需要返回校验后的实例(把它当作"创建完成后的最后一道检查/钩子")。

小提示:这里 start_date/end_datestr,比较的是字符串字典序;在 YYYY-MM-DD 格式下一般没问题,但更稳的写法是用 date/datetime 类型再比较。

python 复制代码
from pydantic import BaseModel, model_validator

class DateRange(BaseModel):
    start_date: str
    end_date: str

    @model_validator(mode="after")
    def check_dates(self):
        """after 模式:所有字段已校验完毕,可访问 self"""
        if self.start_date > self.end_date:
            raise ValueError(
                f"开始日期({self.start_date})不能晚于结束日期({self.end_date})"
            )
        return self

# ✅
DateRange(start_date="2024-01-01", end_date="2024-12-31")

# ❌ 取消注释测试报错
# DateRange(start_date="2024-12-31", end_date="2024-01-01")

🧪 动手验证 4 :创建一个 Transfer 模型,包含 from_account, to_account, amount,用 model_validator 确保两个账户不相同且金额 > 0。


🔹 4. 嵌套模型与复杂结构

真实世界的 JSON 都是嵌套的:

python 复制代码
from pydantic import BaseModel, Field

class Address(BaseModel):
    city: str
    street: str
    zipcode: str = Field(pattern=r'^\d{6}$')

class Company(BaseModel):
    name: str
    address: Address              # 嵌套单个模型
    branch_addresses: list[Address] = []  # 嵌套模型列表

data = {
    "name": "Acme Corp",
    "address": {"city": "Beijing", "street": "Chang'an Ave", "zipcode": "100000"},
    "branch_addresses": [
        {"city": "Shanghai", "street": "Nanjing Rd", "zipcode": "200000"},
        {"city": "Guangzhou", "street": "Beijing Rd", "zipcode": "510000"},
    ]
}

company = Company(**data)
print(company.address.city)              # Beijing
print(company.branch_addresses[1].city)  # Guangzhou

🧪 动手验证 5 :给 Company 添加一个 ceo: str | None = None 字段,测试传 None 和不传两种情况。


🔹 5. 序列化与导出

Pydantic V2 使用 model_dump() 系列方法。

model_dump()model_dump_json() 的区别可以先简单记两句话:

  • model_dump() :返回 Python dict(给程序继续处理用)
  • model_dump_json() :返回 JSON 字符串 str(给打印/传输/保存用)

因此 model_dump() 里你看到的值仍是 Python 对象(例如 SecretStr(...)datetime 等),而 model_dump_json() 会把这些值序列化成 JSON 能表示的形式(并且 SecretStr 会以脱敏形式输出)。

python 复制代码
from pydantic import BaseModel, Field, SecretStr
from datetime import datetime

class UserProfile(BaseModel):
    name: str
    email: str
    password: SecretStr        # 敏感字段
    created_at: datetime = Field(exclude=True)  # 导出时排除
    internal_id: int = Field(default=0, exclude=True)

user = UserProfile(
    name="Alice", 
    email="a@b.com", 
    password="super_secret",
    created_at=datetime.now()
)

# 转 dict
print(user.model_dump())
# {'name': 'Alice', 'email': 'a@b.com', 'password': SecretStr('**********')}

# 转 JSON(SecretStr 自动脱敏)
print(user.model_dump_json())

# 按条件排除
print(user.model_dump(exclude={"email"}))

# 只包含指定字段
print(user.model_dump(include={"name", "email"}))

🧪 动手验证 6 :测试 model_dump(mode="json") 与普通 model_dump() 的区别(前者会将 datetime 转为 ISO 字符串)。


🔹 6. 配置管理(Settings)

Pydantic 内置了强大的配置管理,特别适合读取环境变量。

这里我们不再继承 BaseModel,而是使用 pydantic-settings 提供的 BaseSettings

  • BaseModel:适合"你手动传入一份数据然后校验/解析"。
  • BaseSettings :适合"应用配置"。你可以直接 AppSettings(),它会按规则从环境变量 (以及你配置的 .env、CLI 等来源)读取值,然后再进行同样的校验/类型转换。

SettingsConfigDict(...) 就是这个 Settings 模型的"读取规则开关",常用来配置:

  • env_prefix :环境变量前缀(例如 APP_),避免变量名冲突并方便分组
  • case_sensitive:环境变量名是否区分大小写
  • env_file :从 .env 文件加载(见后面的动手验证)
bash 复制代码
# 先在终端设置环境变量
export APP_NAME="MyService"
export APP_DEBUG="true"
export APP_DATABASE_URL="postgresql://localhost/mydb"
export APP_REDIS_PORT="6379"
python 复制代码
from pydantic_settings import BaseSettings, SettingsConfigDict
# ⚠️ 需额外安装: pip install pydantic-settings

class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="APP_",       # 只读取 APP_ 前缀的环境变量
        case_sensitive=False,    # 不区分大小写
    )
    
    name: str
    debug: bool = False
    database_url: str
    redis_port: int = 6379

settings = AppSettings()
print(settings.name)         # MyService
print(settings.debug)        # True (自动转换)
print(settings.database_url) # postgresql://localhost/mydb
print(settings.redis_port)   # 6379

🧪 动手验证 7 :创建一个 .env 文件写入配置,测试 SettingsConfigDict(env_file=".env") 是否能正确加载。

你可以在项目内创建一个 .env 文件(例如放在 learning/.env),内容类似:

bash 复制代码
APP_NAME=MyService
APP_DEBUG=true
APP_DATABASE_URL=postgresql://localhost/mydb
APP_REDIS_PORT=6379

然后把 model_config 改成(只展示关键参数):

python 复制代码
model_config = SettingsConfigDict(env_prefix="APP_", env_file="learning/.env")

这样即使你没有在终端 export ...,也能通过 .env 文件加载配置。


🔹 7. 高级类型技巧
7.1 Discriminated Union(标签联合)

当一个字段可能是"多种不同结构"之一时,可以用 Union[...] 表达;而 Literal 充当"标签字段",让 Pydantic 能根据输入自动选对模型解析。

  • pet_type: Literal["cat"] :表示 pet_type 这个字段只能是 "cat",否则校验失败
  • pet_type: Literal["dog"] :同理只能是 "dog"
  • Pet = Union[Cat, Dog] :表示 pet 可能是 CatDog

当输入里包含 pet_type 时,Pydantic 会据此判断用哪个模型:

  • {"pet_type": "dog", ...} → 解析成 Dog(...)
  • {"pet_type": "cat", ...} → 解析成 Cat(...)
python 复制代码
from pydantic import BaseModel
from typing import Literal, Union

class Cat(BaseModel):
    pet_type: Literal["cat"]
    meow_volume: int

class Dog(BaseModel):
    pet_type: Literal["dog"]
    bark_frequency: float

Pet = Union[Cat, Dog]

class Owner(BaseModel):
    name: str
    pet: Pet

# Pydantic 根据 pet_type 自动选择正确的模型
owner = Owner(name="Bob", pet={"pet_type": "dog", "bark_frequency": 3.5})
print(type(owner.pet))  # <class 'Dog'> ✅
7.2 泛型模型

泛型(Generic)的核心用途是:复用同一个数据"外壳"结构,但让内部 items 的元素类型可变

  • T = TypeVar("T"):声明一个"类型占位符",表示"某一种类型,但先不固定是哪一种"。
  • class PaginatedResponse(..., Generic[T]):把模型声明成"带类型参数"的泛型模型。
  • items: list[T] :表示 items 里的元素类型由 T 决定。

当你写 PaginatedResponse[Article](...) 时,相当于把 T 指定为 Article,此时 items 就会被当作 list[Article] 来解析/提示,因此可以安全访问 articles_page.items[0].title

这种写法非常适合 API 的分页响应:外层结构(total/page/size/items)固定,但 items 可能是 ArticleUserOrder 等不同模型。

python 复制代码
from pydantic import BaseModel
from typing import Generic, TypeVar

T = TypeVar("T")

class PaginatedResponse(BaseModel, Generic[T]):
    items: list[T]
    total: int
    page: int
    size: int

class Article(BaseModel):
    title: str
    content: str

# 复用分页结构
articles_page = PaginatedResponse[Article](
    items=[Article(title="Hello", content="World")],
    total=1, page=1, size=10
)
print(articles_page.items[0].title)  # Hello ✅

🧪 动手验证 8 :基于上面的泛型,创建一个 PaginatedResponse[User](使用前面定义的 User 模型),验证类型提示是否正确。