用 dataclass + Pydantic 做配置校验与序列化
很多项目的配置管理是这样的:一个巨大的config.py,里面全是DB_HOST = os.getenv("DB_HOST", "localhost"),端口用的时候临时int()一下,布尔值靠== "true"判断。跑起来没问题,直到某天线上把DB_PORT写成了"5432 "(多个空格),或者DEBUG=1被当成字符串"1"永远为真——排查半天才发现是配置类型没校验。
配置本质上是"外部输入",和用户请求一样不可信。这篇讲怎么用dataclass和 Pydantic 给配置加上类型校验和序列化,让脏数据在启动时就报错,而不是在半夜的生产环境里发作。
朴素写法的问题
先看一段典型的手写配置:
importosclassConfig:DB_HOST=os.getenv("DB_HOST","localhost")DB_PORT=int(os.getenv("DB_PORT","5432"))# 万一环境变量是空字符串,int() 直接崩DEBUG=os.getenv("DEBUG","false")=="true"# DEBUG=True / DEBUG=1 都会被判成 FalseMAX_CONN=os.getenv("MAX_CONN")# 忘了转 int,后面拿去做数值比较就出错问题很集中:类型转换散落各处、没有统一校验、错误信息含糊(int()报的是invalid literal for int(),不告诉你是哪个配置项)。而且这种类没法方便地序列化成 dict 或 JSON。
先用 dataclass 把结构立起来
dataclass是标准库的方案,零依赖,适合结构清晰、校验需求简单的场景。它自动生成__init__、__repr__、__eq__:
fromdataclassesimportdataclass,field,asdict@dataclassclassDBConfig:host:str="localhost"port:int=5432max_conn:int=10# 可变默认值必须用 field(default_factory=...),不能直接写 []tags:list[str]=field(default_factory=list)cfg=DBConfig(host="db.internal",port=6432)print(asdict(cfg))# 序列化成 dict:{'host': 'db.internal', 'port': 6432, ...}但dataclass有个关键局限:它只做类型标注,不做类型校验。下面这行不会报错:
cfg=DBConfig(port="not a number")# 完全通过,port 现在是个字符串print(cfg.port+1)# 用的时候才 TypeError,离出错现场很远了想让 dataclass 校验,得手写__post_init__:
@dataclassclassDBConfig:host:str="localhost"port:int=5432def__post_init__(self):# __post_init__ 在 __init__ 之后自动调用,适合做校验和归一化ifnotisinstance(self.port,int):raiseTypeError(f"port 必须是 int,收到{type(self.port).__name__}")ifnot(1<=self.port<=65535):raiseValueError(f"port 超出范围:{self.port}")能用,但每个字段都手写校验很快就失控了。字段一多,__post_init__会变成一大坨 if。这时候该上 Pydantic。
Pydantic:声明式校验 + 自动类型转换
Pydantic 的核心价值是:你只声明类型,它负责校验和转换。而且转换是智能的——环境变量拿到的字符串"6432"会被自动转成 int。
frompydanticimportBaseModel,Field,field_validatorclassDBConfig(BaseModel):host:str="localhost"port:int=Field(default=5432,ge=1,le=65535)# ge/le 直接声明数值范围max_conn:int=Field(default=10,gt=0)tags:list[str]=[]# 字符串 "6432" 自动转 int,校验通过cfg=DBConfig(port="6432")print(cfg.port,type(cfg.port))# 6432 <class 'int'># 越界或转换失败,报错清晰地指出是哪个字段、错在哪try:DBConfig(port=70000)exceptExceptionase:print(e)# port: Input should be less than or equal to 65535 ...需要自定义校验逻辑时,用field_validator,比 dataclass 的__post_init__清晰得多:
classDBConfig(BaseModel):host:str="localhost"@field_validator("host")@classmethoddefstrip_and_check(cls,v:str)->str:v=v.strip()# 顺手去掉环境变量里常见的首尾空格ifnotv:raiseValueError("host 不能为空")returnvprint(DBConfig(host=" db.internal ").host)# "db.internal",空格被清理直接从环境变量加载:pydantic-settings
配置的来源通常是环境变量。Pydantic 官方的pydantic-settings能自动读环境变量并完成校验,这是它相比 dataclass 最实用的一步(需要pip install pydantic-settings):
frompydantic_settingsimportBaseSettings,SettingsConfigDictfrompydanticimportFieldclassSettings(BaseSettings):model_config=SettingsConfigDict(env_prefix="APP_",# 只读 APP_ 开头的环境变量env_file=".env",# 也支持从 .env 文件读)db_host:str="localhost"db_port:int=5432debug:bool=False# "1"/"true"/"yes" 都能正确转成 Truemax_conn:int=Field(default=10,gt=0)# 假设环境里有 APP_DB_PORT=6432 APP_DEBUG=truesettings=Settings()print(settings.db_port)# 6432(自动转 int)print(settings.debug)# True(智能解析布尔)关键收益:配置错误在Settings()实例化那一刻(应用启动时)就抛出,而不是等到运行时某段代码去用它。这就是"让错误尽早暴露"的价值。
序列化:dump 出 dict 和 JSON
配置经常要落盘、打日志或传给别的服务,Pydantic 的序列化开箱即用:
settings=Settings(db_host="db.internal",db_port=6432)# 转 dictprint(settings.model_dump())# {'db_host': 'db.internal', 'db_port': 6432, 'debug': False, 'max_conn': 10}# 转 JSON 字符串print(settings.model_dump_json(indent=2))# 打日志时排除敏感字段print(settings.model_dump(exclude={"db_password"}))反过来,从 dict/JSON 恢复对象也很直接:Settings(**some_dict)或Settings.model_validate_json(json_str),而且恢复过程同样会走一遍完整校验。
dataclass 还是 Pydantic?
不是二选一,按场景选:
- 纯内部数据结构、性能敏感、不接收外部输入:用
dataclass。它更轻(标准库、无第三方依赖),实例化更快,适合大量创建的临时对象。 - 配置、API 请求体、任何来自外部的输入:用 Pydantic。校验和类型转换是刚需,
pydantic-settings直接对接环境变量,省掉大量样板代码。 - 想要 Pydantic 的校验又想保持 dataclass 的写法?Pydantic 提供了
@pydantic.dataclasses.dataclass,是两者的折中。
小结
dataclass只标注类型不校验,校验得手写__post_init__,字段一多就失控;优点是零依赖、轻量。- Pydantic声明式校验 + 自动类型转换,
Field(ge=, le=, gt=)声明约束,field_validator写自定义逻辑。 pydantic-settings直接从环境变量/.env加载并校验,让配置错误在应用启动时就暴露。model_dump()/model_dump_json()开箱即用地序列化,还能exclude敏感字段。- 一句话记忆:配置是外部输入,别信它——用 Pydantic 在启动时把脏数据挡在门外。