JSON 能解析就能上线吗?用 Python 拦住重复键与非有限数值
从一个容易被忽略的配置问题说起
在准备部署配置时,我们经常把“json.loads 没报错”当作检查通过。但语法解析和配置可信是两件事。例如同一个对象里两次出现 port,Python 标准库默认保留最后一个值,前面的配置会悄悄丢掉。这不一定导致程序立即崩溃,却可能让人工审阅与程序实际运行的端口不一致。
本文在本地构建一个只依赖 Python 标准库的配置预检器,实际验证重复键、非标准数值、浮点溢出、编码和文件大小边界。它不调用云服务,不上传配置,也不修改原始文件。以下实现与测试由 AI 辅助编写,示例已在 Python 3.14 环境实际运行。这里没有声称完成生产部署或云上性能测试。
先复现默认行为
import json
print(json.loads('{"port": 80, "port": 443}'))
# {'port': 443}
print(json.loads('{"value": NaN}'))
# {'value': nan}
print(json.loads('{"value": 1e400}'))
# {'value': inf}
第一种情况丢失了重复键的信息。第二种中的 NaN 不是标准 JSON 数值,但 Python 默认接受它。第三种更隐蔽:1e400 是合法的 JSON 数字写法,转换成 Python 浮点数时却溢出成无穷大。仅设置 parse_constant 不能覆盖第三种情况。
RFC 8259 建议对象成员名称唯一;不同实现对重复名称的处理可能不同。本文选择比通用解析器更严格的配置策略:发现重复键立即失败。这个策略应在团队中说明,而不能宣称所有重复名称文本都会被所有解析器拒绝。
为什么不能先解析后检查重复键
如果先 json.loads,再遍历字典,重复键已经被覆盖,无法可靠恢复。应使用 object_pairs_hook,在每个对象的键值对转成字典之前检查名称是否重复。这个钩子会用于嵌套对象,因此数组中包含的对象也能检查。
键名的比较发生在 JSON 字符串转义解码之后,所以 port 与 \u0070ort 会被视为同一个名称。不同对象中使用相同名称则没有问题,例如两个服务各自包含 port。这一点可以避免把“字段名称复用”误判为冲突。
完整的本地预检实现
保存下面代码为 strict_json.py。命令行接受一个文件路径:成功退出码为 0,失败为 1。失败时只输出问题类别或行列号,避免把配置正文、敏感键名、文件路径直接打印到日志。
"""Local JSON preflight: reject duplicate keys and non-finite numbers."""
import argparse
import json
import math
from pathlib import Path
MAX_BYTES = 1024 * 1024
class InvalidConfig(ValueError):
pass
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
# Avoid echoing configuration keys, which may themselves be sensitive.
raise InvalidConfig("duplicate object key")
result[key] = value
return result
def finite_float(token):
value = float(token)
if not math.isfinite(value):
raise InvalidConfig("non-finite number")
return value
def reject_constant(_token):
raise InvalidConfig("non-standard numeric constant")
def parse_config(raw):
if len(raw) > MAX_BYTES:
raise InvalidConfig("file exceeds 1 MiB limit")
try:
value = json.loads(
raw.decode("utf-8-sig"),
object_pairs_hook=unique_object,
parse_float=finite_float,
parse_constant=reject_constant,
)
except json.JSONDecodeError as exc:
raise InvalidConfig(
f"invalid JSON at line {exc.lineno}, column {exc.colno}"
) from None
except (UnicodeDecodeError, RecursionError, ValueError) as exc:
if isinstance(exc, InvalidConfig):
raise
raise InvalidConfig("invalid encoding, nesting or numeric limit") from None
if not isinstance(value, dict):
raise InvalidConfig("configuration root must be an object")
return value
def read_config(path):
with Path(path).open("rb") as stream:
return parse_config(stream.read(MAX_BYTES + 1))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path")
args = parser.parse_args()
try:
read_config(args.path)
except (InvalidConfig, OSError) as exc:
# OSError may contain private paths; only emit its type.
reason = str(exc) if isinstance(exc, InvalidConfig) else type(exc).__name__
print("FAIL: " + reason)
return 1
print("OK: JSON preflight passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
运行方式:python strict_json.py config.json。
成功输出 OK: JSON preflight passed。重复键会输出 FAIL: duplicate object key。语法错误只报告行列号,不回显原始数据。文件不存在等文件系统异常只打印异常类型,不输出可能含有用户名的本地路径。
四个容易漏掉的实现细节
一是同时使用 parse_constant 和 parse_float。前者拒绝 NaN、Infinity、-Infinity;后者在转换后使用 math.isfinite 拒绝溢出。拒绝无穷大并不等于实现十进制精确运算,0.1 等普通小数仍使用二进制浮点。如果涉及货币,应另行评估 decimal.Decimal、精度策略及下游序列化。
二是对文件做有界读取。先读取 MAX_BYTES + 1 个字节,再判断是否超过限制,避免先把很大的文件整体读入内存。这里的 1 MiB 是演示工具的策略参数,不是 JSON 标准限制,也不是对敌意输入的完整资源隔离。
三是把 UTF-8 BOM 的处理写成明确策略。utf-8-sig 同时接受普通 UTF-8 和带 BOM 的 UTF-8,不自动猜测其他字符编码。无效 UTF-8 会明确失败,避免使用替换字符悄悄损坏配置内容。
四是区分通用 JSON 与应用配置。JSON 的顶层本来可以是数组或标量;这里强制顶层对象,是本工具的配置约束。若业务允许数组,应修改这一条,而不是把数组称作不合法 JSON。
实际验证结果
本地使用 unittest 运行了 14 个测试方法,全部通过。其中子测试还覆盖了多种数值、根类型和错误输入。检查项包括:普通嵌套对象、顶层重复键、嵌套重复键、转义后的重复键、不同对象中的同名字段、三种非标准数值、1e400 溢出、大整数、UTF-8 BOM、语法及编码错误、根对象约束、大小上限、命令行退出码与敏感名称不回显、有界文件读取。
其中两个边界尤其值得固定成回归测试:9007199254740993 在 Python 整数中应保持原值,但不能据此推断 JavaScript Number 或后续数据库也保持精度;port 与转义后的同名键必须被识别为重复,而不同对象的同名键应通过。
放进部署流程时还缺什么
这个工具只回答配置文本是否满足上述解析规则,不检查端口范围、必填字段、网络可达性或云账号权限。实际使用时,应继续加入 schema、业务约束和独立的服务健康检查。例如 port 应是 1 到 65535 的整数,enabled 不应接受字符串 true。
它也不展开环境变量,不执行表达式,不从网络下载配置。在 CI 中可把退出码作为后续步骤的前置条件,但不要把“预检通过”写成“部署成功”。对于不可信输入,还需要进程级资源限制和更严格的深度限制;捕获 RecursionError 只用于把解析失败转换成可读错误,并不能代替沙箱。
参考资料:Python 官方文档中 json 模块章节;RFC 8259(The JavaScript Object Notation (JSON) Data Interchange Format)。
- 点赞
- 收藏
- 关注作者
评论(0)