Utilo

按结构对比 JSON,而不是按行

为什么文本 diff 会被键顺序和格式化骗到,数字变成字符串时怎样藏在一行绿色里,以及对比之后何时该用这份载荷生成类型。

· 5 分钟阅读

文本 diff 不是拿来看 JSON 的。它比的是行。JSON 的含义不在换行里。同一组键换了顺序,或同一份文档过了一遍格式化,就会变成一整页红绿,描述的是打印方式,不是数据。你真正在意的变化,比如一个字段从数字变成了字符串,可能藏在 diff 说「整行都改了」的那一行里面。

JSON 对比 在解析之后比较两个值。这篇讲怎样读这个结果,以及下一步何时该用 JSON 转类型,而不是再看一遍颜色。

两种「不一样」不是一种

从一份文本 diff 会弄乱的载荷开始:

{
  "id": 42,
  "active": true,
  "plan": "pro",
  "seats": 3
}

以及部署之后拿到的:

{
  "active": true,
  "id": "42",
  "plan": "pro",
  "seats": 3,
  "trial": false
}

按行比较几乎每一行都在动,因为 id 和 active 换了位置,而且 42 和 "42" 是否醒目要看运气。按结构比较只有两件事:

  • $.id 的类型变了,从数字变成字符串。
  • $.trial 是新增的,值是 false。

键的顺序不是第三件事。JSON 对象没有顺序。把顺序报成差异,是在回答这种格式并没有问的问题,除非你就是要比较字节,例如一份要原样哈希的夹具。空白、缩进、冒号后面的空格,也是同一类不构成事实的东西。

解析之后 1 和 1.0 是同一个数字。"1" 和 1 不是。第二种才是会弄坏客户端的回归:代码写了 typeof value === "number",或解码进一个整数字段。应当把它叫成类型变化,而不是小小的编辑。看起来几乎一样,类型不是。

null 和缺键也不是一回事。缺键表示发送方不再带这个字段。null 表示发送方带了,但没有值。用「键在不在」表示「用户设置过」的客户端会把两者分开处理。结构对比可以显示「删除」和「变成 null」。文本 diff 显示的是删掉一行,运气不好时旁边一行只是逗号变了。

数组一按下标比就会错

对象有名字。数组只有位置。在用户列表最前面插入一个元素,按下标比较会说后面每一个用户都变了。他们没有变。列表多了一个头。

元素是带稳定 id 的对象时,按这个键配对。插入的用户是新增。其余的即使下标变了也还在。数组是一组标签时,顺序是噪音,要比的是标签少了还是重复了,同时忽略顺序。数组是序列时,顺序就是数据:任务步骤、折线上的点。这时忽略顺序会把缺陷藏起来。

工具不知道你是这三种里的哪一种。路径 $.items[2].price 用的是下标。如果你的意思是「id 为 sku-2 的那一项」,就先按 id 配对再读路径。否则你会去「修」一个其实属于另一行的价格。

空数组和混类型数组也要说实话。空数组说明不了元素类型。一会儿是字符串、一会儿是对象的列表是联合类型,不该被抹成一种。

有意忽略什么

API 响应里有些字段每次调用都变,对这次比较没有意义:requestId、traceId、generatedAt,以及被抄进正文的耗时。留着它们,每次对比都是脏的,你就不再读了。按名字忽略这些键。不要因为这个星期某个键很吵就忽略它,如果吵的原因是价格真的在变。

大整数是另一件事。JSON 没有和数字分开的整数类型,JavaScript 也不能精确表示所有大于 2^53 的整数。文档里的雪花 ID 可能在对比之前、在任何一次解析时就已经被舍入了。ID 重要的话,接口应当把它当成字符串发出来。对比说两个 ID 相等,可能只是两个被舍入成同一个值的数,和原来的两个都不同。

对比之后,用新的形状生成类型

结构对比告诉你契约动了。它不会替你改客户端里的结构体。把新载荷贴进 JSON 转类型,看推断怎样处理同样的边界:

  • 某个键在数组的一部分元素上有、另一部分没有,会变成可选。对一份样本这是合理的猜测;如果服务器只是因为那些行太旧才没给这个键,这个猜测就是错的。要核对。
  • 一行上是 null、下一行上是字符串,应当变成可空字符串,而不是 any。
  • 空数组的元素类型是未知的。从 [] 发明出 string[] 是样本并不支持的谎言。
  • 嵌套对象会变成独立的命名类型。对比里从数字变成字符串的字段,在生成的结构体里应当是字符串。如果仍是数字,说明你用的是旧载荷。

Go、Rust、Zod 和 Pydantic 的语法不同,不该共享一个错误的可空性。用你刚刚认定是新契约的那份载荷来生成,然后再改名字。生成器只有一份样本。它看不见你还没收到的字段。

什么时候文本 diff 才是对的工具

散文、源代码、别人请你看的 unified diff,以及字节本身就是契约的 JSON(例如签名前要哈希的规范形式),用按行比较。 签名那篇 就是这种:正文哈希针对的是字节,格式是签名的一部分,结构对比会把导致签名失败的那一点字节差异藏起来。

问的是「数据变了什么」时,用结构对比。问的是「现在该声明成什么样」时,生成类型。这两个问题都不是格式化工具打出来的一堵绿墙能回答的。

相关指南