
nhost 依赖库 jsondiff 深度解析用 Go 生成 RFC 6902 JSON Patch 差异补丁【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost本文围绕 nhost 仓库中直接依赖并 vendor 的 Go 库 jsondiff版本 v0.7.0见 go.mod 与 vendor/github.com/wI2L/jsondiff/README.md展开完整覆盖其核心 API、Kubernetes 动态准入控制器这一典型应用场景、各功能选项Factorize、Rationalize、Invertible、LCS、Ignores 等的行为语义与性能开销。读完本文你将掌握如何用Compare/CompareJSON两个入口函数生成 JSON Patch 补丁并能结合源码理解每个选项在底层Differ中对应的开关字段从而在自己的 Go 项目中正确配置 diff 行为。什么是 jsondiff把两个 JSON 的差异表达为 RFC 6902 操作序列jsondiff 是一个 Go 包其核心职责是计算两个 JSON 文档之间的差异并将差异表达为一系列 RFC 6902JSON Patch操作。它的官方定位场景是生成 Kubernetes Mutating Webhook 的补丁响应但任何对两个同源 JSON 求结构化差异的需求都可以复用它。从 vendor/github.com/wI2L/jsondiff/ 目录下的源码文件看实现拆分为多个职责清晰的模块文件职责从源码结构看compare.go三个对外入口Compare/CompareJSON/CompareWithoutMarshaldiffer.goDiffer结构体与递归 diff 主逻辑option.go全部功能选项functional optionslcs.go最长公共子序列数组比较选项operation.goJSON Patch 操作对象与序列化pointer.goRFC 6901 JSON Pointer 处理patch.go、apply.go补丁的表示与应用equal.go、hash.go、json.go相等性判断、哈希与 JSON 工具在 nhost 仓库中该包被记录为根模块的直接依赖go.mod 中github.com/wI2L/jsondiff v0.7.0未标注// indirect且以 vendor 形式完整收录在 vendor/github.com/wI2L/jsondiff/ 下版本与 go.sum 中的校验哈希锁定一致。版本要求与获取方式安装最新版本的标准命令go get github.com/wI2L/jsondifflatest有一个硬性前提要求 Go 1.21 及以上版本原因是库内部使用了hash/maphash包以及 Go 1.21 引入的any/min/max关键字与内置函数。nhost 仓库自身的 go.mod 声明为go 1.27.0满足该前提。核心 APICompare 与 CompareJSON从 compare.go 的源码看包对外暴露两个主要入口均采用可变参数功能选项opts ...Option// Compare 先序列化再比较返回相对于 source 的差异补丁 func Compare(source, target interface{}, opts ...Option) (Patch, error) // CompareJSON 直接比较两段 JSON 字节返回相对于 source 的差异补丁 func CompareJSON(source, target []byte, opts ...Option) (Patch, error)两者语义一致source是基准文档target是目标文档返回的Patch是从 source 变到 target所需的 RFC 6902 操作列表。区别在于Compare接收任意 Go 值内部先用encoding/json序列化或自定义 func得到最终 JSON 表示再做比较见 compare.go 中的marshalUnmarshal调用链且默认 marshal/unmarshal 函数分别回退为json.Marshal/json.UnmarshalCompareJSON接收原始 JSON 字节跳过序列化步骤直接 unmarshal 后比较。此外源码中还提供了 README 未单独强调的第三个入口compare.go// CompareWithoutMarshal 假定参数只包含 json.Unmarshal 可识别的原生 Go 类型 // 因此比较前不做 marshal/unmarshal func CompareWithoutMarshal(source, target interface{}, opts ...Option) (Patch, error)该入口通过recover将传入非法 JSON 类型的 panic 转换为invalid json type错误返回适合已经持有解析好的 JSON 树、希望省掉一次序列化往返的场景。典型应用场景Kubernetes 动态准入控制器README 给出的最典型用例是比较同一类型的两个值——原始资源与期望变更后的资源从而生成 Kubernetes 动态准入控制器Dynamic Admission Controller返回的 Mutating Webhook 补丁。思路是不手工拼操作而是拷贝一份 source在其副本上施加变更再委托库生成补丁。示例修改 Pod 的镜像与存储介质以包含单个容器的演示 Pod 为例README.mdimport corev1 k8s.io/api/core/v1 pod : corev1.Pod{ Spec: corev1.PodSpec{ Containers: []corev1.Container{{ Name: webserver, Image: nginx:latest, VolumeMounts: []corev1.VolumeMount{{ Name: shared-data, MountPath: /usr/share/nginx/html, }}, }}, Volumes: []corev1.Volume{{ Name: shared-data, VolumeSource: corev1.VolumeSource{ EmptyDir: corev1.EmptyDirVolumeSource{ Medium: corev1.StorageMediumMemory, }, }, }}, }, }第一步拷贝原始值。corev1.Pod自带DeepCopy方法很方便对其他类型README 明确告诫不要浅拷贝 Go 结构体应使用专用深拷贝库或者干脆用json.Marshal存一份预编码的字节副本newPod : pod.DeepCopy() // 或者 podBytes, err : json.Marshal(pod) if err ! nil { // handle error }第二步在副本上施加变更这里修改容器镜像并把shared-data卷的存储介质从内存切回默认// Update the image of the webserver container. newPod.Spec.Containers[0].Image nginx:1.19.5-alpine // Switch storage medium from memory to default. newPod.Spec.Volumes[0].EmptyDir.Medium corev1.StorageMediumDefault第三步生成补丁。注意Compare被调用时会先用encoding/json或自定义函数把source与target序列化再进行比较import github.com/wI2L/jsondiff patch, err : jsondiff.Compare(pod, newPod) if err ! nil { // handle error } b, err : json.MarshalIndent(patch, , ) if err ! nil { // handle error } os.Stdout.Write(b)输出形如[{ op: replace, path: /spec/containers/0/image, value: nginx:1.19.5-alpine }, { op: remove, path: /spec/volumes/0/emptyDir/medium }]这份 JSON Patch 即可直接作为 Webhook 响应 payload 中的补丁部分。注意第二个操作是remove而非replace因为把Medium设为corev1.StorageMediumDefault空字符串后JSON 序列化时该字段被省略库据此生成删除字段的操作。实战坑一可选字段optional fields陷阱上述示例为简化而写成真实准入控制器中应当用AdmissionReview.AdmissionRequest.Object.Raw的原始字节来做 diff。原因是 Go 结构体的性质从 JSON 反序列化得到的水合hydratedcorev1.Pod对象中那些非指针结构体类型的可选字段在 JSON 中并不存在却会在 Go 值里以零值形式出现拿反序列化后的副本互相比对就可能生成针对原始 JSON 中不存在路径的add/ 变更操作最终被 Kubernetes API Server 拒绝。README 转述了社区Reddit 用户 terinjokes 及 kubebuilder 相关 issue对这一问题的原始描述Optional fields being ones that are a struct type, but are not pointers to those structs. These will exist when you unmarshal from JSON, because of how Go structs work, but are not in the original JSON. Comparing between the unmarshaled and copied versions can generate add and change patches below a path not in the original JSON, and the API server will reject your patch.符合生产实践的写法是podBytes, err : json.Marshal(pod) if err ! nil { // handle error } // req is a k8s.io/api/admission/v1.AdmissionRequest object jsondiff.CompareJSON(req.AdmissionRequest.Object.Raw, podBytes)即只要用AdmissionReview对象里的 raw 字节作为 sourcemutate 原始对象还是其副本都由你决定。实战坑二客户端类型版本过旧会误删字段如果 Webhook 引用的client-go或定义目标资源类型的包版本落后于集群版本新版本新增的字段在旧类型中不存在unmarshal 时会丢失marshal 后自然不出现于是 diff 会为其生成remove操作——相当于 Webhook 静默删掉了用户设置的未知字段。README 举例Kubernetes 1.20 允许用户在Service上设置.spec.allocateLoadBalancerNodePort来禁用 LoadBalancer 服务的节点端口分配若 Webhook 仍使用 v1.19.x 的k8s.io/api/core/v1类型生成的补丁里就会出现针对该字段的remove而不是无害地忽略它。结论Webhook 的类型包版本应跟随至少不低于所服务集群的 API 版本。功能选项详解若需要更精细地控制 diff 行为Compare与CompareJSON都接受第三个可变参数——功能选项列表。README 明确任意选项组合都可以无冲突地叠加使用除非特别说明。每个选项在 option.go 中对应一个func(*Differ)闭包向Differ内部的opts结构体置位一个布尔标志行为语义可直接从源码核对选项源码option.go作用Factorize()L7-L9将移除新增归并为move/copy操作Rationalize()L12-L14用更小的字节数准则合并子树操作为单个replaceEquivalent()L18-L20跳过同长度、元素无序相等数组间的操作生成LCS()L24-L26用最长公共子序列比较数组实验性Invertible()L35-L37在remove/replace前插入test操作使补丁可逆MarshalFunc/UnmarshalFuncL43-L57自定义序列化/反序列化函数SkipCompact()L61-L65启用 Rationalize 时跳过输入压缩InPlaceCompaction()L71-L75原地压缩 target 字节切片省去一次拷贝分配Ignores()L80-L91按 JSON Pointer 排除指定字段/值实验性Factorize把删除新增归并为 move / copy默认情况下库不会生成move或copy操作。启用Factorize()后成对出现的移除某值 在别处添加同一值会被归并从而减少操作数量、减小补丁序列化后的体积。例如文档{ a: [ 1, 2, 3 ], b: { foo: bar } }更新为{ a: [ 1, 2, 3 ], c: [ 1, 2, 3 ], d: { foo: bar } }默认生成的补丁是三条操作[ { op: remove, path: /b }, { op: add, path: /c, value: [ 1, 2, 3 ] }, { op: add, path: /d, value: { foo: bar } } ]开启 factorization 后同样语义只需两条[ { op: copy, from: /a, path: /c }, { op: move, from: /b, path: /d } ]注意细节/a的内容是被复制因为/a仍保留在目标中而/b的内容是被移动源路径被移除。Rationalize以字节数为权重选择更小的补丁表示默认的数组/对象比较是递归式的对发现的每处差异都产出一条或多条操作。但在某些情形下把子树内部一堆零散变更换成一条指向父节点的replace操作补丁整体反而更短。Rationalize()选项即为此设计它用一个简单的权重函数决策——把两组操作都序列化为 JSON比较字节长度保留 footprint 更小的那个option.go 中仅置位rationalize标志具体取舍逻辑在 diff 主流程中。示例文档{ a: { b: { c: { 1: 1, 2: 2, 3: 3 } } } }更新为{ a: { b: { c: { x: 1, y: 2, z: 3 } } } }默认输出是a.b.c六个子字段的 remove/add 组合[ { op: remove, path: /a/b/c/1 }, { op: remove, path: /a/b/c/2 }, { op: remove, path: /a/b/c/3 }, { op: add, path: /a/b/c/x, value: 1 }, { op: add, path: /a/b/c/y, value: 2 }, { op: add, path: /a/b/c/z, value: 3 } ]再叠加Factorize()操作数减半[ { op: move, from: /a/b/c/1, path: /a/b/c/x }, { op: move, from: /a/b/c/2, path: /a/b/c/y }, { op: move, from: /a/b/c/3, path: /a/b/c/z } ]最终开启Rationalize()后全部收敛为一条对父对象的replace[ { op: replace, path: /a/b/c, value: { x: 1, y: 2, z: 3 } } ]输入压缩Input compaction压缩补丁体积在网络传输如application/json-patchjson媒体的 HTTP 请求中通常有益因此库默认假设期望的补丁表示是紧凑minifiedJSON。当Rationalize()启用时库会对传入CompareJSON*的 JSON 输入做预压缩处理。如果你的输入本来就是紧凑 JSON应当同时使用SkipCompact()让库跳过压缩步骤——README 称之为漂亮且免费的性能改进。原地压缩In-place compaction默认情况下库不会修改传给CompareJSON*的字节切片而是拷贝target再压缩。为了省去这次额外分配可以使用InPlaceCompaction()让库接管target切片并直接原地修改——注意此时不能在其他 goroutine 中并发使用/更新该切片。从源码注释看option.go该选项与SkipCompact同时使用时无效果跳过压缩后没有原地修改可言。Invertible生成可逆补丁Invertible()选项指示生成器在每个remove和replace操作前插入一个test操作断言该路径当前持有预期值从而使整个补丁可以反向执行、把文档还原为原始形态。限制copy操作本身不可逆其逆操作是remove而remove逆向既可能是add也可能是copy存在歧义。因此启用该选项会禁用copy操作生成即使同时使用Factorize()改用add代替代价是补丁可能变大。对两段文档求 diff{ a: 1, b: 2 }{ a: 3, c: 4 }生成的补丁形如[ { op: test, path: /a, value: 1 }, { op: replace, path: /a, value: 3 }, { op: test, path: /b, value: 2 }, { op: remove, path: /b }, { op: add, path: /c, value: 4 } ]可以看到replace与remove都被前导test保护而add无需test因为它天然可以反向 remove。一个有意思的边角案例在此场景下如果同时启用Rationalize()输出更短且仍然保持可逆[ { op: test, path: , value: { a: 1, b: 2 } }, { op: replace, path: , value: { a: 3, c: 4 } } ]即对根路径空path做一次断言整个旧文档 整体替换。Equivalent深度不等但内容等价的数组某些数据类型可以同时深度不等且等价。例如两个根数组[ a, b, c, d ][ d, c, b, a ]它们在每个索引处取值都不同因此深度比较不相等但就内容而言等价长度相同且第一个数组的每个元素都能在第二个数组中找到、出现次数也一一对应。Equivalent()选项源码注释disables the generation of operations for arrays of equal length and unordered/equal elementsoption.go就是让生成器跳过这类仅顺序不同数组上的操作不产出任何补丁操作。LCS更聪明的数组比较实验性README 将LCS()标注为新/实验性选项未来可能提升为默认行为。默认数组比较算法较为朴素如果删除了数组中间的某个元素其右侧所有元素都会左移一位从而为每个右移项生成一条replace操作。LCS()则指示生成器计算源数组与目标数组的最长公共子序列Longest Common Subsequence并基于它生成更简练、也更忠实反映真实差异的操作列表。实现位于 lcs.go。Ignores按 JSON Pointer 排除字段实验性Ignores()选项允许把一个或多个 JSON 字段/值排除在生成的 diff 之外字段用 RFC 6901 JSON Pointer 字符串标识。要点选项接受可变长的 JSON Pointer 参数列表每个都指向源文档中的一个值若某个 Pointer 在源文档中不存在则视为该值出现在目标文档中从而可以忽略add操作。示例对以下两段文档求 diff{ A: bar, B: baz, C: foo }{ A: rab, B: baz, D: foo }不加选项时补丁为[ { op: replace, path: /A, value: rab }, { op: remove, path: /C }, { op: add, path: /D, value: foo } ]传入jsondiff.Ignores(/A, /B, /C)后这些变更全部被忽略结果为空补丁。从源码看option.go这些 Pointer 被存入一个map[string]struct{}集合空参数列表直接返回不做任何设置并置hasIgnore标志位。MarshalFunc / UnmarshalFunc自定义序列化默认使用标准库encoding/json的json.Marshal/json.Unmarshal。出于性能或定制编解码行为的目的可以用这两个选项替换参数函数原型必须与标准库对应函数一致。一个自定义解码器示例——通过UnmarshalFunc启用UserNumber模式让 JSON 数字解码为json.Number而不是float64避免大整数精度丢失patch, err : jsondiff.CompareJSON( source, target, jsondiff.UnmarshalFunc(func(b []byte, v any) error { dec : json.NewDecoder(bytes.NewReader(b)) dec.UseNumber() return dec.Decode(v) }), )在Compare的内部实现中这两个函数若未显式提供会回退到标准库函数compare.go。性能基准各选项的开销概览README 提供了针对小Small/ 中Medium两种 JSON 文档规模的性能基准用于粗略估计各选项成本。测试在 MacBook Pro 15Apple M1 MaxmacOS 15.1Go 1.23.2 darwin/arm64上运行 10 轮并统计。以下为核心结果摘录场景文档规模/选项组合耗时op分配op操作数Small/default1.462µs1.164 KiB13Small/invertible1.595µs1.914 KiB14Small/factorize2.357µs1.734 KiB27Small/rationalize1.543µs1.172 KiB14Small/equivalent1.460µs1.164 KiB13Small/all2.650µs2.492 KiB29Medium/default4.178µs3.812 KiB24Medium/factorize7.266µs5.654 KiB64Medium/rationalize4.270µs2.359 KiB27Medium/equivalent8.547µs4.562 KiB32Medium/all12.27µs6.451 KiB76几何均值全部用例3.281µs1.277 KiB24.36README 中还区分了是否重置 Differ 复用与无序比较的变体量级相近。可以得到的实践结论rationalize开销接近 default却常能显著缩短补丁字节数Medium 场景分配从 3.812 KiB 降到 2.359 KiB性价比最高factorize与equivalent开销中等factorize在操作数上代价明显Medium 场景 64 条 vs default 的 24 条需权衡操作更少与生成更慢全部选项叠加all开销最大但依然都在微秒量级。如需自行复现README 给出的命令是go get github.com/cespare/prettybench go test -bench. | prettybench基准所用的 JSON 文档存放于上游仓库的testdata/benchs目录vendor 目录出于 vendor 裁剪惯例未包含测试数据本地 vendor/github.com/wI2L/jsondiff/ 下仅保留实现与文档文件。许可与致谢该包采用MIT 许可见 vendor/github.com/wI2L/jsondiff/LICENSE对 nhost 这样自身采用宽松许可策略的开源仓库而言无嵌入障碍。README 的 Credits 部分说明其算法思想借鉴了多种语言的既有 JSON Patch/diff 实现如 jiff、JSON-Patch、json-patch、elm-json-diff、Algorithm::Diff 等。小结jsondiff 在 nhost 仓库中作为一个版本锁定的直接 Go 依赖存在go.mod L56v0.7.0其价值在于把两个 JSON 文档的结构化差异这一通用问题收敛为标准的 RFC 6902 操作序列并通过一组可叠加的功能选项覆盖了生产中的主要变体需求用Factorize压缩操作数、用Rationalize压缩字节数、用Invertible换取可逆性、用LCS/Equivalent处理数组语义、用Ignores做字段级排除、用MarshalFunc/UnmarshalFunc定制编解码。每个选项都能直接对应到 option.go 中Differ上的一个标志位源码可读性高便于按需裁剪与二次验证行为。对于需要生成 Webhook 补丁、配置变更追踪或元数据同步场景的 Go 项目这组 API 与选项的组合方式值得直接参照。【免费下载链接】nhostThe Open Source Firebase Alternative with GraphQL.项目地址: https://gitcode.com/GitHub_Trending/nh/nhost创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考