Python 读取 JSON 的三个边界:重复键、NaN 与小数精度
读取 JSON 时,json.loads() 没有抛出异常,只能说明这段输入被当前解析器接受。对配置校验、接口联调和数据导入工具来说,还需要约定重复字段如何处理、是否接受非标准数值,以及小数以什么类型保留。下面用 Python 标准库实现一个可以直接运行的小例子。
1. 重复键要在生成字典之前发现
例如,json.loads('{"timeout":10,"timeout":30}') 默认得到 {"timeout":30}。如果随后才遍历字典,前一个值已经丢失,无法再证明原始文本里出现过重复字段。
这里选择“发现重复键就拒绝输入”的规则。object_pairs_hook 接收到对象的有序键值对,可以在构建字典时检查。同一对象内重复才报错;两个不同对象分别包含 id 完全正常。该回调也会作用于嵌套对象,以及数组里的对象。
参考:Python json 文档:重复名称的处理:https://docs.python.org/3/library/json.html#repeated-names-within-an-object。
2. NaN 与 Infinity 需要单独拒绝
Python 的 JSON 解码器默认接受 NaN、Infinity 和 -Infinity,这三个标记并不是标准 JSON 数值。读取时可用 parse_constant 拒绝它们。不要把 allow_nan=False 用在 json.loads() 上:它是序列化时使用的选项。
另外,strict=True 主要控制字符串中的控制字符,并不会自动完成重复键检测或非标准数值拦截。
参考:Python json 文档:非有限数值:https://docs.python.org/3/library/json.html#infinite-and-nan-number-values、JSONDecoder 参数:https://docs.python.org/3/library/json.html#json.JSONDecoder。
3. 从原始数字文本构造 Decimal
如果希望保留十进制小数,可以设置 parse_float=Decimal。这样 1.10 直接从原始文本构造为 Decimal("1.10"),无需先经过二进制浮点数。整数仍沿用默认的 int,布尔值和 null 的解析也不受影响。
注意:拒绝 Infinity 字面量与限制数值范围是两件事。例如 1e400 是另一种输入;下面的实现会把它解析成有限的 Decimal,但具体业务是否允许这样大的数,仍应另外校验。
4. 完整实现
import json
from decimal import Decimal
def no_duplicate_keys(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError(f"duplicate JSON key: {key!r}")
result[key] = value
return result
def reject_constant(value):
raise ValueError(f"non-standard JSON constant: {value}")
def load_json(text):
return json.loads(
text,
object_pairs_hook=no_duplicate_keys,
parse_constant=reject_constant,
parse_float=Decimal,
)
data = load_json('{"amount":1.10,"enabled":true}')
assert data["amount"] == Decimal("1.10")
assert isinstance(data["amount"], Decimal)
assert data["enabled"] is True
object_hook 接收到的已经是字典,不能用来恢复被覆盖的重复字段;若同时设置两个 hook,object_pairs_hook 优先。
5. 用反例验证约定
把下面代码接在实现后面,即可验证几类必须拒绝的输入。JSONDecodeError 也是 ValueError 的子类,因此这里同时接住语法错误与自定义校验错误。
bad_inputs = [
'{"x":1,"x":2}',
'{"outer":{"x":1,"x":2}}',
'[{"x":1,"x":2}]',
r'{"a":1,"\u0061":2}',
"NaN",
"Infinity",
"-Infinity",
'{"v":NaN}',
'{"x":1,}',
]
for text in bad_inputs:
try:
load_json(text)
except ValueError:
pass
else:
raise AssertionError(f"unexpected acceptance: {text}")
assert load_json('{"a":{"x":1},"b":{"x":2}}') == {
"a": {"x": 1}, "b": {"x": 2}
}
assert load_json("1e400").is_finite()
Unicode 转义的例子尤其容易漏掉:"a" 与 "\u0061" 解码后是同一个键,应按重复处理。验证时既要准备应被拒绝的输入,也要准备不同对象使用同名字段等合法输入,防止规则过度拦截。本文实现及相关边界在本地 Python 3.14.6 上完成了 17 项检查,均通过。
6. 接入工具时还要明确的边界
这个函数只落实了文中三项解析约定。接收不可信输入时,仍需限制请求或文件大小,按使用场景限制嵌套深度,并校验字段、类型与数值范围。深度限制应由适合的解析层落实,不能依靠完整解析后的检查来保护解析阶段。
Decimal 也不会被标准库 json.dumps() 自动序列化。不要为了消除异常就统一转成 float,否则可能重新引入精度变化;转成字符串则会改变 JSON 字段类型。应先约定输出契约,再选择匹配的序列化方式。
重复键错误目前只返回键名,不含完整父路径;如果产品需要精确定位到行列与路径,还需要更完整的解析诊断设计。日志中也应避免直接打印完整的敏感输入。
本文示例和说明使用 AI 辅助整理,并通过实际运行核对结果。
- 点赞
- 收藏
- 关注作者
评论(0)