Python JSON 严格读取:重复字段被覆盖之前,先把歧义拦下来

10-01 3阅读

解析成功,未必代表输入没有歧义

一份任务配置先写了重试次数二,后面又写了一次五。程序没有报错,最终只读到五,审核者却可能只注意到前面的二。此时问题不在字典查询,而在原始文本转换成字典时,重复字段的信息已经丢失,后续校验再认真也找不回来。

Python 的 JSON 解码器默认允许对象里出现重复名称,保留最后一个值,也接受并非标准 JSON 数字的 NaN 和 Infinity。如果配置要求字段唯一、数值有限,就需要明确补上这些约束。参数 strict 的名称容易让人误会,它主要控制字符串内未转义控制字符的接受方式。

在字典形成之前检查名称

下面保存为 Python 文件直接运行,使用标准库,本文在三点十二验证。函数接收文本,先让解码器把每个对象的键值对交给检查函数,再创建字典。重复判断发生在同一个对象内部,因此外层和内层分别使用相同字段名是允许的。

示例同时安排顶层重复、嵌套重复、特殊浮点常量和普通数字溢出。合法配置会原样读出,其余输入都必须产生明确异常。这样能验证拒绝规则确实覆盖不同入口,而不是只对某一份手工样本有效。

Python JSON 严格读取:重复字段被覆盖之前,先把歧义拦下来

AI概念配图,非真实界面

import json
import math

def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError('duplicate key: ' + key)
        result[key] = value
    return result

def reject_constant(token):
    raise ValueError('nonstandard number: ' + token)

def finite_float(token):
    value = float(token)
    if not math.isfinite(value):
        raise ValueError('number out of range')
    return value

def load_config(text):
    value = json.loads(text, object_pairs_hook=unique_object,
                       parse_constant=reject_constant,
                       parse_float=finite_float)
    if not isinstance(value, dict):
        raise ValueError('top level must be an object')
    return value

assert json.loads('{"tries":2,"tries":5}') == {'tries': 5}
good = load_config('{"tries":2,"rate":0.5,"nested":{"tries":1}}')
assert good == {'tries': 2, 'rate': 0.5, 'nested': {'tries': 1}}
print('valid:', good)

bad = [
    ('duplicate', '{"x":1,"x":2}'),
    ('nested duplicate', '{"a":{"x":1,"x":2}}'),
    ('NaN', '{"x":NaN}'),
    ('Infinity', '{"x":Infinity}'),
    ('negative infinity', '{"x":-Infinity}'),
    ('overflow', '{"x":1e400}'),
    ('top-level list', '[]'),
]
for label, text in bad:
    try:
        load_config(text)
    except ValueError:
        print('rejected:', label)
    else:
        raise AssertionError(label)
print('all JSON checks passed')

特殊常量与溢出需要两道检查

object_pairs_hook 收到的是有序键值对列表,此时两个同名字段还都在。若先用普通 loads 得到字典,再数键的个数,已经无法证明原文是否出现过重复。嵌套对象也会经过这个钩子,因此不必自己递归扫描字符串,更不应拿正则去解析 JSON。

parse_constant 只处理 NaN、Infinity 和负无穷这些特殊记号。像一后面乘十的四百次方,语法上仍是普通 JSON 数字,却可能在转换为双精度浮点数时得到无穷。示例因此另设 parse_float,在转换后用 isfinite 检查结果。

这项规则刻意选择“拒绝超出浮点表示范围”,并没有承诺保留任意精度。如果业务需要很长的小数,可以在设计数值模型时考虑 Decimal,再统一后续计算与输出约定。不要为了让导入通过而悄悄把异常数值换成零,那会把坏输入伪装成有效配置。

格式校验之后还有业务约束

代码要求最外层必须是对象,因为当前场景是配置。JSON 标准也允许最外层是列表或单个值,拒绝它们属于应用规则,并非纠正 JSON 语法。若你的接口约定返回列表,应调整这一步,而不是照搬后认为所有非对象文档都有问题。

字段唯一和数字有限也不等于字段正确。重试次数是否为整数、有没有上限、允许哪些字段,仍需单独验证;尤其布尔值在 Python 中与整数存在类型继承关系,不能只凭宽松的整数判断就接受。建议把解析错误与业务错误分开记录,方便定位责任阶段。

对外部输入还应在读取前限制字节大小,并根据实际场景限制嵌套深度与处理耗时。钩子是在解析过程中运行的,不是资源消耗的全面防护。日志通常只需保存错误位置或配置标识,避免为了排错把整份可能含私密配置的原文写入公共日志。

接入现有服务时,先拿真实但已脱敏的配置做回归,确认以前靠后值覆盖的写法是否需要迁移。最终保留一份合法样本和每种拒绝样本,让配置生产方知道错误会在哪里被发现,也让今后的解析器调整有可复核的边界。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。