功能说明
配置对比、环境变量导出、表单字段映射和日志检索经常需要把嵌套 JSON 变成扁平的键值对。例如 server.tls.enabled 这样的点号路径便于存入只支持字符串键的存储,也便于生成差异列表。展平函数递归处理字典,把每一层的键拼接到前缀上;空字典作为叶子保留,因为空对象本身也携带配置语义。列表不展开,整体作为一个值保留,避免索引路径与业务点号冲突。
还原函数按点号拆分路径,逐层创建字典。如果某个中间节点已经是标量,或同一路径重复出现,就抛出 ValueError,而不是覆盖已有值。
关键代码
flatten 用 prefix 判断是否位于顶层,顶层键不加前导点号。value 是非空字典时递归合并子结果,否则直接写入扁平字典。unflatten 遍历每个路径,cursor 从根字典开始,对除最后一段外的每一段先取现有值;不存在就创建空字典,存在但不是字典则说明路径冲突。最后一段若已存在也视为冲突,防止 a 与 a.b 同时出现时丢失数据。main 演示正常往返与冲突拒绝。
使用注意
点号必须与业务键字符集隔离,如果原始键本身可能含点号,应改用可转义的编码或改用 JSON Pointer。展平不处理列表,需要索引路径时应扩展语法并同步还原逻辑。键的顺序在 Python 字典中保留,但往返后嵌套结构的分组顺序可能与原对象不同,比较时应使用结构相等而不是字符串比较。空对象与缺失键语义不同,本例保留空对象。还原前应限制路径深度和总键数,避免恶意输入造成过深嵌套。
验证方式
运行脚本,展平结果包含 server.host、server.ports、server.tls.enabled、name 和 empty 五个键。随后打印 True,说明还原对象与源对象结构相等。冲突示例输出 rejected 信息,证明 a 与 a.b 不能同时存在。可再加入含列表的字段,确认列表被原样保留。
代码示例
from typing import Any
def flatten(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
flat: dict[str, Any] = {}
for key, value in data.items():
path = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict) and value:
flat.update(flatten(value, path))
else:
flat[path] = value
return flat
def unflatten(flat: dict[str, Any]) -> dict[str, Any]:
root: dict[str, Any] = {}
for path, value in flat.items():
parts = path.split(".")
cursor = root
for part in parts[:-1]:
existing = cursor.get(part)
if existing is None:
existing = {}
cursor[part] = existing
if not isinstance(existing, dict):
raise ValueError(f"path conflict at {part!r} in {path!r}")
cursor = existing
leaf = parts[-1]
if leaf in cursor:
raise ValueError(f"duplicate or conflicting path: {path!r}")
cursor[leaf] = value
return root
if __name__ == "__main__":
source = {
"server": {"host": "localhost", "ports": [8080, 8081], "tls": {"enabled": True}},
"name": "api",
"empty": {},
}
flat = flatten(source)
print(flat)
print(unflatten(flat) == source)
try:
unflatten({"a": 1, "a.b": 2})
except ValueError as error:
print("rejected:", error)