首页
/ JSON for Modern C++ 实战指南:nlohmann::basic_json::diff 生成 RFC 6902 JSON Patch

JSON for Modern C++ 实战指南:nlohmann::basic_json::diff 生成 RFC 6902 JSON Patch

2026-09-06 20:38:02作者:瞿蔚英Wynne

本文基于 JSON for Modern C++(nlohmann/json)仓库中 diff 接口文档 展开,系统讲解静态成员函数 nlohmann::basic_json::diff 的语义、参数与返回值约定,并结合 single_include/nlohmann/json.hpp 中的实际实现,深入剖析它如何递归地比较两个 JSON 值、按类型分支生成 replace / remove / add 三类操作,以及为什么 source.patch(diff(source, target)) == target 这一往返(round-trip)性质总能成立。读完本文,你可以直接在项目中用 diff + patch 组合实现文档增量比对与同步。

接口定义与核心语义

diffbasic_json 的静态方法,用于计算两个 JSON 值之间的差异,并产出一个符合 RFC 6902 (JSON Patch) 规范的补丁:

static basic_json diff(const basic_json& source,
                       const basic_json& target);

其核心语义是:构造一个 JSON Patch,使得对 source 应用该补丁后得到 target。对于任意两个 JSON 值 sourcetarget,下面的恒等式始终成立:

source.patch(diff(source, target)) == target;

这正是该接口最有价值的性质——它是可验证的往返保证:你不需要信任生成过程,只需验证应用结果。patch 接口负责对副本应用补丁并返回新值,patch_inplace 则就地修改当前对象。

参数

参数 方向 说明
source in 比较的起点,即需要被修改的源 JSON 值
target in 比较的目标,即期望达到的 JSON 值

返回值

一个 JSON 补丁(JSON 数组),应用后可将 source 转换为 target。若两值完全相同,返回空数组(见下文源码分析)。

异常安全

强保证(Strong guarantee):若抛出异常,JSON 值不会发生任何改变。由于 diff 是纯计算函数、不修改入参,这一保证在实现上自然成立。

复杂度

sourcetarget 的长度成线性关系。实现上是递归遍历,每个节点最多被访问两次(source 一次、target 一次)。

当前限制(Notes)

按文档说明,当前版本只会生成 removeaddreplace 三种操作,不生成 movecopytest。这对实际使用意味着:生成的补丁是自包含的、无顺序依赖的(除了数组删除),可以直接交给任何符合 RFC 6902 的外部工具执行。

完整示例:从文档差异到补丁

官方示例位于 examples/diff.cpp,完整代码如下:

#include <iostream>
#include <iomanip>
#include <nlohmann/json.hpp>

using json = nlohmann::json;
using namespace nlohmann::literals;

int main()
{
    // the source document
    json source = R"(
        {
            "baz": "qux",
            "foo": "bar"
        }
    )"_json;

    // the target document
    json target = R"(
        {
            "baz": "boo",
            "hello": [
                "world"
            ]
        }
    )"_json;

    // create the patch
    json patch = json::diff(source, target);

    // roundtrip
    json patched_source = source.patch(patch);

    // output patch and roundtrip result
    std::cout << std::setw(4) << patch << "\n\n"
              << std::setw(4) << patched_source << std::endl;
}

该示例比较两个对象:sourcebaz 的值是 "qux",且多出键 footargetbaz 变为 "boo",且新增键 hello。运行后输出(与 diff.output 一致):

[
    {
        "op": "replace",
        "path": "/baz",
        "value": "boo"
    },
    {
        "op": "remove",
        "path": "/foo"
    },
    {
        "op": "add",
        "path": "/hello",
        "value": [
            "world"
        ]
    }
]

{
    "baz": "boo",
    "hello": [
        "world"
    ]
}

三条操作与文档描述一一对应:共有键 baz 值不同 → replacefoo 只在 source 中存在 → removehello 只在 target 中存在 → addsource.patch(patch) 的结果与 target 完全一致,验证了往返性质。

源码解析:diff 是如何逐步生成补丁的

实现位于 single_include/nlohmann/json.hpp,其逻辑可以清晰地拆分为四层判断:

1. 快路径:相等则返回空补丁

// if the values are the same, return an empty patch
if (source == target)
{
    return result;
}

若两值完全相等,直接返回空数组。空补丁应用于任何文档都是无操作,因此递归到子节点时不会产生冗余操作——这是补丁保持“最小化”的基础。

2. 类型不同:整体替换

if (source.type() != target.type())
{
    // different types: replace value
    result.push_back(
    {
        {"op", "replace"}, {"path", path}, {"value", target}
    });
    return result;
}

一旦 sourcetarget 的 JSON 类型不同(例如对象变为数组、字符串变为数字),不再深入比较,而是直接生成一条 replace 操作把整个值替换掉。path 是递归过程中逐层累积出来的 JSON Pointer,递归入口处默认值为空字符串 "",即文档根节点。

3. 数组:先对齐公共前缀,再删除多余、追加新增

case value_t::array:
{
    // first pass: traverse common elements
    std::size_t i = 0;
    while (i < source.size() && i < target.size())
    {
        // recursive call to compare array values at index i
        auto temp_diff = diff(source[i], target[i],
            detail::concat<string_t>(path, '/', detail::to_string<string_t>(i)));
        result.insert(result.end(), temp_diff.begin(), temp_diff.end());
        ++i;
    }

    // remove my remaining elements
    const auto end_index = static_cast<difference_type>(result.size());
    while (i < source.size())
    {
        // add operations in reverse order to avoid invalid indices
        result.insert(result.begin() + end_index, object(
        {
            {"op", "remove"},
            {"path", detail::concat<string_t>(path, '/', detail::to_string<string_t>(i))}
        }));
        ++i;
    }

    // add other remaining elements
    while (i < target.size())
    {
        result.push_back(
        {
            {"op", "add"},
            {"path", detail::concat<string_t>(path, "/-")},
            {"value", target[i]}
        });
        ++i;
    }
    break;
}

这里有三处值得注意的设计细节:

  1. 公共前缀递归比较:索引相同的元素逐对递归 diff,子补丁直接拼接进结果。
  2. 尾部删除的插入位置source 中多出的元素生成 remove 操作,但这些操作通过 result.insert(result.begin() + end_index, ...) 插到本轮子补丁序列的最前面。从源码结构看,这是为了让删除操作先于后续追加操作执行,从而避免索引在补丁应用顺序下失效——因为后续 add 都使用 /-(追加到数组末尾)路径,与删除的索引互不影响。
  3. /- 追加语义target 中多出的元素统一用 JSON Pointer 的 /- 特殊 token 表示“追加到数组末尾”,这正是 RFC 6902 定义的合法路径。

4. 对象:双向遍历键集,键名先转义

case value_t::object:
{
    // first pass: traverse this object's elements
    for (auto it = source.cbegin(); it != source.cend(); ++it)
    {
        // escape the key name to be used in a JSON patch
        const auto path_key = detail::concat<string_t>(path, '/', detail::escape(it.key()));

        if (target.find(it.key()) != target.end())
        {
            // recursive call to compare object values at key it
            auto temp_diff = diff(it.value(), target[it.key()], path_key);
            result.insert(result.end(), temp_diff.begin(), temp_diff.end());
        }
        else
        {
            // found a key that is not in o -> remove it
            result.push_back(object(
            {
                {"op", "remove"}, {"path", path_key}
            }));
        }
    }

    // second pass: traverse other object's elements
    for (auto it = target.cbegin(); it != target.cend(); ++it)
    {
        if (source.find(it.key()) == source.end())
        {
            // found a key that is not in this -> add it
            const auto path_key = detail::concat<string_t>(path, '/', detail::escape(it.key()));
            result.push_back(
            {
                {"op", "add"}, {"path", path_key},
                {"value", it.value()}
            });
        }
    }
    break;
}

对象分支的处理与示例输出完全对应:

  • 第一遍遍历 source 的键:键在 target 中也存在 → 递归比较对应值;键只在 source 中存在 → 生成 remove(示例中的 /foo)。
  • 第二遍遍历 target 的键:键只在 target 中存在 → 生成 add 并携带完整值(示例中的 /hello)。
  • JSON Pointer 转义:键名在拼接到 path 前会经过 detail::escape(...) 处理,对 ~/ 做 RFC 6901 规定的转义(~~0/~1)。这意味着键名中包含 /~ 的对象也不会破坏补丁的合法性。

5. 原始类型:替换整个值

对于 null、字符串、布尔、整数、无符号数、浮点数、binary 与 discarded 类型,同类型但值不同(类型不同已在第 2 层拦截)时统一生成 replace

// both primitive types: replace value
result.push_back(
{
    {"op", "replace"}, {"path", path}, {"value", target}
});

与 patch 的衔接

patch 的实现在 single_include/nlohmann/json.hpp

/// @brief applies a JSON patch to a copy of the current object
basic_json patch(const basic_json& json_patch) const
{
    basic_json result = *this;
    result.patch_inplace(json_patch);
    return result;
}

patch 先复制当前对象再就地应用补丁,因此调用方持有的 source 不变,返回值即“应用后的新值”——这正是 diff 文档中往返恒等式能够安全书写的底层支撑。patch_inplace 内部按 add / remove / replace / move / copy / testpatch_operations 分支执行,并会对非法操作抛出异常(如操作值为 unsuccessfultest 会触发 501 类 other_error)。

往返性质在测试中的大规模验证

文档承诺的 source.patch(diff(source, target)) == target 并非空话。单元测试 tests/src/unit-json_patch.cpp 覆盖了大量 RFC 6902 场景(RFC 4.1 add、4.2 remove、4.3 replace 等小节),其中数十处断言反复验证同一模式,例如:

CHECK(doc.patch(json::diff(doc, expected)) == expected);

这些断言散布于该测试文件的 117、143、172、229、271、621 等行,覆盖对象、数组、嵌套结构等多种文档形态;另有反向用例 json::diff(target, source)(650 行附近)验证差异生成的对称方向。可以说,往返恒等式是这套 diff/patch 机制在测试层面被持续守护的不变量。

适用场景与相关接口

diff 的典型使用场景是文档版本同步:服务端保存旧版本 source,客户端提交新版本 target 后,用 json::diff(source, target) 得到最小操作序列,再持久化或转发给其他系统以 patch 重放。相比直接存储整份新文档,补丁只记录变化部分,天然适配审计、消息队列传输等场景。

相关接口可进一步对照阅读:

  • patch:对一个副本应用 JSON Patch 并返回结果;
  • patch_inplace:就地应用 JSON Patch,不产生中间副本;
  • merge_patch:应用 RFC 7386 JSON Merge Patch,语义不同——Merge Patch 用 null 表示删除、整体递归合并,操作粒度远粗于 diff 生成的逐键补丁。

选型上的简单判据:需要精确、可审计、可外部执行的变更操作时用 diff + patch;只是“用补丁对象覆盖原对象的部分字段”这类宽松合并需求时,merge_patch 更省事。

版本与限制说明

  • diffversion 2.0.0 引入(见原文档 Version history)。
  • 当前实现只产出 remove / add / replace 三种操作;若你的下游系统支持 move / copy,也不会从 diff 得到这些操作,需要自行优化补丁。
  • diff 比较的是结构值相等source == target 的语义),对象键的顺序不参与比较——这与 RFC 6902 关于对象成员顺序无关的约定一致,tests/src/unit-json_patch.cpp 的 "4. Operations" 小节也专门验证了操作对象内键序不影响等价性。
  • 数组差异基于位置对齐(按索引逐元素比较),而不是序列对齐算法(LCS 类):两个仅在元素顺序上不同的数组,可能生成较“重”的替换序列,这是理解其行为边界时的一个重要前提。

以上结论均可在当前仓库的 diff 文档示例代码单头文件实现 中逐行核对。

登录后查看全文
热门项目推荐
相关项目推荐