← 中文资源

简体中文版本

嵌套 JSON 展平与还原

把嵌套对象转换为点号路径键值并完整还原,遇到路径冲突时显式报错而不是静默覆盖。

阅读英文原文

功能说明

配置对比、环境变量导出、表单字段映射和日志检索经常需要把嵌套 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)