广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Pydantic 中如何精准校验 JSON 可序列化字典类型?

在构建 API 网关、配置中心或通用数据上报接口时,我们经常看到 data: dict[str, Any] 这样的字段。这个设计本意是保留灵活的数据承载能力,但 Any 的宽泛让校验形同虚设。一旦业务方把 datetime、Decimal、set 甚至自定义对象塞进字典,模型校验照样通过,直到 json.dumps 阶段才崩溃。要真正实现 JSON 可序列化字典的精准校验,需要把 JSON 原生类型作为边界,而不是依赖 Any。

Pydantic 中如何精准校验 JSON 可序列化字典类型?

一、Dict[str, Any] 的校验边界在哪里

Pydantic v2 中的 dict[str, Any] 只会检查外层容器是否为字典,并且尝试把键当作字符串处理。对于值来说,Any 意味着完全不设限制,模型校验器不会去递归检查每一个值的具体类型。因此 datetime.now() 会被原样保留,set 这样的不可 JSON 序列化类型也不会被拦截。

看一个最典型的翻车场景:

from datetime import datetime
from typing import Any
from pydantic import BaseModel
class Payload(BaseModel):
    data: dict[str, Any]
p = Payload(data={
    "created_at": datetime.now(),
    "tags": {1, 2},
})
print(p.model_dump())
# 输出中 created_at 仍是 datetime 对象,tags 仍是 set
import json
# json.dumps(p.model_dump())  # 抛出 TypeError

这里的问题并不在于 Pydantic 没有工作,而是 Any 本身就没有表达任何类型约束。精准校验的关键在于显式声明 JSON 的合法值域,而不是依赖外层字典结构。

二、使用递归验证函数做运行时检查

由于 Python 类型系统很难用一行类型别名简洁地表示递归 JSON 结构,实际项目中最直观、最可维护的方式是写一个递归验证函数。这个函数遍历字典的每一层,逐一检查值是否为 JSON 原生支持的类型,同时还能输出清晰的错误路径。

下面是一个完整的递归校验实现,它把键、数值、容器和浮点特殊值都纳入考虑:

import math
def validate_json_value(value, path="$"):
    if value is None or isinstance(value, (str, bool, int)):
        return value
    if isinstance(value, float):
        if not math.isfinite(value):
            raise ValueError(f"{path} 包含非有限浮点数")
        return value
    if isinstance(value, list):
        for i, item in enumerate(value):
            validate_json_value(item, f"{path}[{i}]")
        return value
    if isinstance(value, dict):
        for k, v in value.items():
            if not isinstance(k, str):
                raise ValueError(f"{path} 的键必须是字符串")
            validate_json_value(v, f"{path}.{k}")
        return value
    raise ValueError(
        f"{path} 包含不可 JSON 序列化类型: {type(value).__name__}"
    )

这个函数有几个容易忽视的细节。第一,bool 是 int 的子类,如果先判断 int,就会把 True 和 False 混入整数分支,虽然 JSON 也允许布尔值,但逻辑上应当分开处理。第二,float('nan') 和 float('inf') 会被 Python 的 json.dumps 输出为非标准文本,严格 JSON 校验必须拒绝。第三,字典的键必须显式要求为 str,否则后续序列化时会出现隐式转换,掩盖数据错误。

把该函数接入 Pydantic 模型非常简单,只需要一个 field_validator:

from typing import Any
from pydantic import BaseModel, field_validator
class Payload(BaseModel):
    data: dict[str, Any]
    @field_validator("data")
    @classmethod
    def check_json_serializable(cls, v):
        validate_json_value(v)
        return v

这种方式直观、易于调试,适合字段较少、模型结构相对固定的场景。它的缺点是每个字段都要手动挂载验证器,如果系统里有十几个类似字段,维护成本会逐渐升高。

三、用 Annotated 与 TypeAdapter 封装可复用类型

对于需要复用校验逻辑的场景,Pydantic v2 的 Annotated 可以把验证器封装成自定义类型。这样模型字段可以直接使用 dict[str, JsonSerializable],语义更清晰,也不需要在每个模型里重复写 field_validator。

from typing import Annotated, Any
from pydantic import BaseModel, PlainValidator, TypeAdapter
JsonSerializable = Annotated[
    Any,
    PlainValidator(validate_json_value),
]
class ConfigModel(BaseModel):
    settings: dict[str, JsonSerializable]
config = ConfigModel(settings={
    "timeout": 30,
    "feature_toggle": True,
    "weights": [0.1, 0.9],
})
print(config.model_dump())

这里使用 PlainValidator 而不是 BeforeValidator 或 AfterValidator,是因为我们只需要在原始数据上做一次性检查,不涉及类型转换。PlainValidator 直接接收并返回原始值,因此它不会把字符串 "30" 强转成整数 30,正好符合 JSON 原生类型的严格语义。

如果需要在模型之外单独校验一段字典数据,可以借助 TypeAdapter:

adapter = TypeAdapter(dict[str, JsonSerializable])
valid = adapter.validate_python({"a": 1, "b": {"c": None}})
print(valid)

这种封装方式非常适合消息消费入口、配置加载器或 FastAPI 依赖项中的防御性校验。它把校验规则集中到一处,模型字段只需要引用类型即可,团队协作时也不容易遗漏。

四、这些边界情况必须注意

除了 datetime、set 这类明显的不可序列化类型,实际项目中还有一些更隐蔽的坑。非有限浮点数就是其中之一:float('nan') 在 Python 里是合法的 float,但它不属于严格 JSON 标准,很多大数据平台或前端解析器会直接报错。因此递归函数里一定要用 math.isfinite 做过滤。

字典键的类型同样重要。JSON 对象只接受字符串键,而 Pydantic 在部分场景下可能会把整数键自动转成字符串。如果你需要保证前端拿到的数据与数据库里完全一致,就应当拒绝非字符串键,避免 1 和 "1" 被混为一谈。

还有一类容易混淆的概念是 Pydantic 自身的 model_dump(mode="json")。这种模式会将 datetime 等类型转换为 ISO 字符串,看起来似乎解决了序列化问题。但如果你的数据最终还要经过标准库 json.dumps 或其他严格 JSON 工具处理,mode="json" 并不能覆盖所有场景。精准校验 JSON 可序列化字典,本质上是要让模型层只接受 JSON 原生类型,从源头消除隐患。

广告位 · 文章底部(后台-模块管理替换此区域)