阅读 1904

为什么没有一款能称心如意的JSONDIFF工具?

背景是最近部门在研发流量回放工具,流量回放简单理解就是要将线上的原始请求信息,原始响应信息录制下来,再到需要回放的环境上回放一遍,来比对前后两次相同的请求是否得到了同样的响应结果,如果响应结果不相同,那么这个工具是需要能够告诉我们这两次结果哪里不相同的。因为部门内部大部分的服务都是通过http+jsonresponse方式对外提供接口,所以比对响应结果也就可以理解成比对两个json结构的字符串。

当然在开源社区如此蓬勃发展的今天,大部分同学如果接到这个需求第一反应一定是github一下,万千开源库总有一个满足你的心意,当然我在接到这个需求的时候也是这么认为,然而现实往往是残忍的,你永远不知道你的目标用户会有多少你不知道的需求。

本文旨在分享对jsondiff原理学习的分享,以及如何满足各式各样用户对jsondiff不同需求的思路。

一. 字符串匹配

那么如何证明两个json是否一样呢?

如果这是一道oj问题,那么这个问题应该有很多种问法,最简单的问法莫过于

给你两个string类型的json格式字符串,如果相等返回true,不相等则返回false。(你可以假设测试用例中Number类型的Value都是Integer)

最简单的做法当然是字符串比较,也许你能将一行代码作为这个问题的答案,当然不能说这个回答完全错了,因为客观上讲它也许真的能过一些送分的测试用例。你提交了如下的代码,决定试探一下测试用例。

func Compare(json0,json1 string)bool{   return json0 == json1}
复制代码

当然你的oj经验一定会告诉你,测试用例一定没有你想的那么善良。比如它一定过不了下列的测试用例。

param0:{"a":1}

param1:{ "a":1}

他们的区别是对象key为a的字段前偷偷的加上了一个不为人知的空格,虽然他很不善良,但是你不得不承认这是一个符合语法的json。

你痛定思痛地、礼貌地问候了测试用例出题人之后,决定清洗一遍一遍测试用例,你打开了ascii码表,删掉了前三十二个控制字符中所有你认为出题人可能会加在测试用例中的干扰因素,恶狠狠的点下了Submit,觉得这一次你一定会看到 通过 但是三秒过后屏幕上依然出现 解答错误,你叹了口气打开了测试用例,发现坏你事的家伙们长下面这样

param0:{"a\t":1}

param1:{"a":1}

原来你删去了影响json语义的控制字符,你想破脑袋也不知道为什么会有这么无聊的人把控制字符加在key或者string类型的value中,你恶狠狠的抓了一把你后脑勺上所剩无几的头发。这次你学的更聪明了,你匹配出了所有双引号内的内容,并且机智的躲过了",以保证这次的控制符删除不会误伤友军,你心里默默的想这次点击Submit后一定会看到 通过,白天盯着A股大盘的你一定不会想到自己有一天会这么希望屏幕上出现绿色。但是你依然错了,测试用例长下面这样。

param0:{"a":1,"b":2}

param1:{"b":2,"a":1}

此刻你脑海中传来一句话json中的对象是一个无序的键值对集合,你反应过来json对象并不会因为key顺序的不一样而变得不一样,这次你决定走心了,你要解析这个json字符串,聪明的你当然不会对他排序后再一一比较,你知道golang反射包里有reflect.DeepEqual这么好用的东西。点击submit,这一次的你终于 绿 了,你站在巨人的肩膀上击败了滑稽的出题人。

你知道站在巨人肩膀上的你远不止这点能力,你相信你自己一定有举一反三的能力。于是你打开了相似题目,自信的点开了标记为Hard的相关题目。

二. json树分析

给你两个string类型的json格式字符串(你可以假设测试用例中Number类型的Value都是Integer),这一次如果两个json不一样,你需要用一种数据结构来描述他们如何不一样,如果他们一样,你可以返回null。

巨人无情地把你扔下了肩膀,你知道DeepEqual帮不了你了,这一次你要自己走。

你深深地吸了一口烟,决定冷静下来分析json这一熟悉又陌生的数据结构,你发现json是一个特殊的树状数据结构,特殊在于这个树的节点有"类型"的概念,类型分别有Object,Array,Number,String,Boolean,并且也不是每一个类型的节点都有子节点,只有Array,Object类型的节点会有子节点,并且父节点对子节点的寻找线索与二叉树的左右子树不同。Object类型的父节点对子节点的索引是字符串类型的key,Array类型的父节点对子节点的索引是int类型的index,你画了一张下面的图表达了你对json的理解。

你发现这道题有一些类似你之前刷过的一道题 相同的树 ,当然如果这道题希望答案给出详细的diff,他们就更像了。你一句话总结了这道题的做法。

先比较两节点值,再分别用同样方式递归比较两节点的左右子节点。当然如果需要给出详细的diff,则你需要额外传入一个描述diff的树类型数据结构。

于是你发现他们同样需要递归比较单个节点。不同的是这次在两个节点比较之前,需要先比对节点value 的值类型,如果值类型不同,当然也没有比较值的意义。你又发现如果需要比对的两个当前节点的值类型都为Object或array,你需要递归的比对他们的子节点。与「相同的树」不同的是,索引不再是Left,Right,而需要根据当前节点的Value类型来分情况讨论。如果Value类型为Object,则你要匹配相同key的子节点,递归进行下一次比较。如果Value类型为array类型,则你要匹配相同Index的子节点,再次递归进行下一次比较。

当然为了给出详细的diff,你也定义了不同diff的数据类型,分别为

  • Added:记录新json的object/array类型节点中因为多余,而匹配不上的节点。

  • Deleted:记录新json的object/array类型节点中因为少去,而匹配不上的节点。

  • Modified: 当两节点类型中有一个类型为基本类型,记录两节点因为值类型不同,或者值类型相同,但是具体值并不完全相同的节点。

  • Object:记录因为子节点中存在diff的当前object节点,因为他要更详细的描述子节点的不同,所以他需要子diff类型字段来更详细得描述diff,这个diff类型包括这个列表中的所有类型。

  • Array:记录因为子节点中存在diff的当前array节点,因为他要更详细的描述子节点的不同,所以他需要子diff类型字段来更详细得描述diff,这个diff类型包括这个列表中的所有类型。

你画了一张图表达了对这种diff数据结构的理解

你终于解开了这道题,望着满键盘掉落的头发,你觉得你变得更强了,你觉得你对jsondiff的理解非常到位,面对所有jsondiff的业务场景你都能迎刃而解,你决定正式开始面对流量回放的真实用户需求,第二天你自信满满的找到领导说,"这个需求,我只要两天"。

两天后,功能上线了,你自信满满,你相信你写的代码一定不会有问题,如果真的有,也是这个世界的问题。

三. 引入噪音

两个小时后,你新功能的第一个用户小赵找到你,描述了他的困惑。

"虽然我的录制response中和回放response中的字段trace_id不一样,但是你没有必要告诉我,这一点我并不需要知道。"

好吧,对于请求response的比对来说,trace_id(可以理解为类似一个随机值)的确是一个没有必要进行比对的字段,看着小赵回放的十万条请求,你很难说服他依靠肉眼忽略掉每一条diff count为1的记录。也因为这该死的trace_id字段,小赵没有办法合理得使用你精心设计的仅看不同功能。你设计这个功能是希望让用户真正看到他们应该关心的存在不同的记录,但是当存在不同记录的不同点变得没有意义的时候,这个功能也仿佛在嘲笑他的设计者。

当晚你回家边吃着你的炸鸡夜宵边思考这个问题,你觉得今天的炸鸡都没有往常的香了。你突然灵光一闪,你可以增加一个叫作噪音的概念,来忽略掉某些虽然客观存在,但是用户却没有必要关心的diff,并且得益于你对diff树状结构的设计,你很自然的想到这个"噪音"可以用jsonpath来描述。于是你在生成一棵jsondiff树后,通过jsonpath找到了用户不需要关心的diff节点,删掉它,并且递归的删除因为缺失了子节点而没有必要存在的父节点,图片描述这个过程如下。

你通宵一晚搞定了这个功能,第二天黑着眼圈和小赵解释了什么叫噪音,虽然小赵没有听懂,但是他知道只需要一个jsonpath就可以忽略掉那该死的trace_id,于是你用一个上午的时间教他配上了trace_id的jsonpath,总算搞定了这个问题。这个时候你觉得你是jsondiff这个领域的专家,再也没有别的奇怪的问题能困扰到你了,你决定中午睡一觉来补回前一天晚上丢失的睡眠,你在梦里见到了你的领导向你亲切的走来,你觉得他一定要给你涨工资了。这时候小赵突然把你拍醒,告诉你你写的代码又出问题了。

四. 基于lcs的数组匹配策略

这次小赵的原始记录和回放记录长下图这样。

old:[0,1,2,3]

new:[1,2,3]

他看到的diff是这样的。

(橘黄色背景代表不同,粉色背景代表删除,绿色背景代表新增)

他希望看到的diff是这样的。

按照你的设计,两个json比对的diff树应该长下面这样。

你觉得这个结果并没有什么问题,完全符合了你的设计,在你最初的设计中,数组类型的value比对,在匹配阶段的时候,是按照相同的index进行匹配比对,你觉得这没有什么问题。但是小赵说,这种显示结果对他来说毫无意义,他希望结果能告诉他left数组中0是多了出来的值,而别的值是能准确匹配上的。

你觉得小赵的用户需求是合理的,小赵虽然关心顺序,但他关心的不是绝对顺序,而是相对顺序,这个世界并没有问题,的确是你的设计出了问题,于是你决定解决这个问题,多年的刷题经验告诉你,这个问题与著名的lcs非常相似,你也的确在某一天刷过这道题 最长公共子序列

转换到array diff,就是按照最大公共子序尽量匹配,因为你有lcs的dp表,所以你可以根据dp表的增长趋势,找到新旧数组的匹配对,剩余匹配不上的元素/节点,如果在新表中,你就将它记为Added类型的diff,如果在旧表中,你就将他记录为Deleted类型的diff,图例如下:

在这个例子中,你找到了新旧数组下表分别为(0,1),(1,2),(2,3)的匹配对,于是你将没有出现在匹配对中的其他旧数组中的元素记录为Deleted,新数组中没有出现再匹配对中的元素记录为Added。

你偷偷摸摸的上了线,但是你并没有告诉小赵你已经解决了这个问题,因为今晚的你想睡个好觉。但是你没有想到,小赵偷偷得关注了你的gitlab,你刚上线这个功能,小赵就第一时间使用了,并且告诉你,你的功能又出问题了!这次的问题json是这样的:

old:[1,2,3,4]

new:[4,3,2,1]

按照你对array类型节点进行lcs的策略比对的时候,这个例子中只有一个元素/节点能够匹配上,剩余的元素都是Added/Deleted类型的diff,但是在这个例子中,小赵并不关心新旧节点的顺序,他希望新旧节点中只要能匹配上,就要匹配上,在他的预期中,这两个json数组应该能做到完全匹配,你知道今晚注定又是一个不眠夜了,因为你是一个不服输的男人。

五. 双重遍历数组匹配策略

于是你想,是时候建立一种新的Array比对策略了。

你想通过建立Map的方式,将一个数组中的所有元素作为Key,遍历另一个数组中的每个元素查找是否Map中存在当前元素,基于哈希表的特性,单次查找时间复杂度可以做到O(1)​,总体时间复杂度能做到​O(n+m),​空间复杂度为O(MIN(n,m))​。

但是你在跑测试用例的时候,测试用例告诉你数组中的元素不一定是简单类型,如果存在Object类型或Array类型的元素,分别在golang中被解析成map[string]interface{}[]interface{}类型的时候,因为数组类型和map类型是unhashable的特殊类型,也不支持运算符==!=,因此没有办法被作为Map的Key。所以很可惜你只能用

​时间复杂度的方案来两重遍历两个数组,分别挑出能够完全匹配的元素,并记录位置。新旧json中没有被记录位置的元素,分别记录为Added和Deleted。

并且你提供了一种机制,让用户根据array的jsonpath,自由选择通过相对顺序匹配,还是顺序无关匹配。虽然解决完这个问题已是凌晨三点,但是这一次你觉得你真的赢了,你脑补了小赵挑不出毛病的无奈表情,你觉得失去的睡眠都是值得的。

六. 基于相似度+lcs的数组匹配策略

又过了一天,你向小赵普及了array策略比对的使用办法,总算解决了前一天下午的问题。你觉得以后的日子都清静了,但是没过几分钟,小赵又来了。这次小赵碰到的问题是这样的:

old:[{"a":1,"b":2},{"c":1,"d":0}]

new:[{"c":2,"d":0},{"e":3}]
复制代码

在你设计的两个array比对策略中,这个case的比对结果都是新旧json完全匹配不上,具体看上去就是新json相对旧json增加了两个元素,减少了两个元素,对应到Diff结构体就是2个Added类型的diff元素,2个Deleted类型的diff元素(图例如下)。并且你觉得这十分合理,但小赵的需求是,old数组中的下标1,应该匹配new数组中的下标0,你问他为什么?他说因为很像啊,正常人都会这么比较,此刻你非常想要锤他一顿,但是理智告诉你他的需求也有一定的道理,于是你决定接受他的要求。

(现状)

(预期)

你仔细得分析了他的问题,你发现你把这个问题想简单了,这个问题根本原因是因为array中的元素,不知道应该如何定位另一个数组中应该与它相匹配的元素的位置。

于是你仔细查阅了新华词典中的含义,你决定将这个定义抽象为相似度,来量化。你把相似度定义为一个0-1的浮点数,如果相似度为1,则代表他们完全一样,如果相似度为0则代表他们完全不一样。你定义了如下相似度计算方法。

  • Added:0

  • Deleted:0

  • Modified:至少位置一样,所以你给了0.3分的基础分,如果他们值类型相同,你又给了0.3分安慰分,根据具体值不一样的程度,你再酌情给出剩下的0.4分。

  • Object:根据各field得分之和除以field总数 ​sum(fieldssimilarity)/count(fields)sum(fields'similarity)/count(fields)

  • Array: 根据各field得分之和除以field总数​ ​ sum(fieldssimilarity)/count(fields)sum(fields'similarity)/count(fields)

你把json数组diff的元素匹配这件事抽象成了追求最高相似度的过程,两个数组中每一步寻求匹配的决策都是为了争取数组整体相似度的更大值,于是你为了记录旧数组中的每一个元素和新数组中的每一个元素相比能获取的相似度,很自然的建立了一个规模为n*m的相似度表scoreTable​ ,scoreTable[i][j]代表旧数组中下标为i与新数组中下标为j 的元素的相似度。你发现这个问题也可以用动态规划来解决,你将dp[i][j]定义为旧数组前i 个元素与新数组前j 个元素的最大相似度之和,状态转移方程为 dp[i][j] = MAX( scoreTable[i][j] + dp[i-1][j-1] ,dp[i][j-1],dp[i-1][j])​,并且根据dp数组的上升趋势(具体做法类似前面出现的dp做法,这里不画图了),你找到了最佳策略的匹配对,将匹配对中的元素进行深度递归匹配,没有出现在匹配对中的旧数组元素标记为了Deleted,没有出现在匹配对中的新数组元素标记为了Added,于是小赵看到了符合预期的匹配结果,心有不甘的回了工位。

直到三天以后,小赵又找到了你,告诉你,你基于相似度+lcs的老策略又适应不了他了!他又碰到了新的问题。

七. 基于相似度+八皇后的数组匹配策略

这次小赵碰到的问题是下面这样的

(现状)

(预期)

你仔细的跑了小赵的测试用例,单步调试一步一步看是不是你的代码出了问题,结果你发现你的代码并没有问题,最终的结果也符合预期,小赵的测试用例的确是按照相似度+LCS的数组匹配策略正确地执行了,于是你虚心得向小赵请教原委。

小赵说,他并不关心数组中的顺序,并且这个测试用例中很显然旧数组下标为1的元素和新数组下标为0的元素长得很像,那么在不关心顺序的时候,他们显然应该被匹配上,并且进行进一步的比较。

他解释到,旧数组下标0匹配新数组下标1,旧数组下标1匹配新数组下标0,并且因为数组的匹配没有了顺序,这种情况下的diff你应该用各种各样的颜色来表达,让人能够一眼定位。

你这一次觉得小赵有些无理,但你是个不懂拒绝的男人,所以你决定再一次支持小赵的需求。

你分析了两天这个问题,你觉得相似度的概念不能抛弃,因为小赵虽然没有顺序的要求,但他依然有相似度的要求,于是你决定在决策匹配阶段换一种算法,你知道这一次可能要像之前遍历数组匹配(第五节)的时候穷举每一种可能性。只不过之前的需求只需要匹配能够完全相等的元素,抛弃所有不能完全相等的元素。但这一次因为引入了相似度的概念,你需要穷举每一次的匹配对得分之和,并且记录最大值和与其匹配的匹配对数组,于是你画了如下的表格。

你发现这个问题和 八皇后 十分相似,所以你给这种匹配方法起名为相似度+八皇后。不同之处在回溯求出每一种皇后站位的可能性之后,你需要记录他们的总得分和,并且最终返回总得分和最大的那一种皇后站位的可能性。当然在总得分和最大的选择的组合数组中,需要将得分为 0 也就是完全无法匹配组合记录为 Added/Deleted,但是这种方法的时间复杂度过高的缺点很明显,但是你暂时没有想到更好的方法,不过总算又一次解决了小赵的问题,你觉得小赵再也不会来找你了。

八. 开放相似度算法

……

没错他又来了,他带着他的测试用例又来了,这次他的测试用例输出结果是这样的。

(现状)

(预期)

你看着小赵的测试用例满脸疑惑,你给出的数组匹配决策既满足了小赵对数组无序匹配的要求,又满足了小赵对整体相似度最大的需求,你完全不知道哪里出了问题。

小赵看着充满疑惑的你露出了满意的神色,慢悠悠得说,虽然你给出的决策在某种程度上是对的,但是在这个例子中,他希望id字段对应的值相同的元素进行匹配,因为这个例子中数组中对象元素的主键为ID,只有ID相同的对象才有匹配比对的需要。

你分析了这个看上去十分刁难你的问题,你觉得导致这样结果的原因是因为相似度计算算法过分通用,他也许能满足大部分业务场景,但是少部分业务场景没有办法通用。于是你决定把键盘交给小赵,让小赵自己实现自定义的相似度计算算法。当然你不放心让小赵修改你的git仓库,也怕小赵为了自己需求对代码的修改影响了其他人的需求,于是你决定让小赵把相似度计算算法写在其他地方,并且让他定义什么json路径的array数组需要使用这种特别的相似度计算算法,当你的匹配逻辑执行到这个路径的数组diff的时候,相似度计算替换成小赵自定义的方式。你开始后悔大学没有好好学编译原理,不过好在有很多现成的抽象语法树包能供你调用。于是小赵有了定义了自己的相似度算法的能力。代码大致如下。

(a,b)=>a.id==b.id?0.999:0

九. 预定义相似度算法

虽然上述做法也许能够解决小赵的问题,但是这种解决做法看上去过于low level,用户需要知道你的代码实现原理,才知道如何写出他想要的代码,对于用户来说显然有很高的学习门槛。因此在它的基础上我们也可以抽象出一些相对high level的方法,通过配置提供给用户选择。

小结

本文艺术化加工了部门内部在研发流量回放时jsondiff功能时碰到的各类用户问题的解决思路的思考,并对问题进行了分析,故事情节纯属胡编乱造。

回答标题的问题,为什么没有一款能称心如意的jsondiff工具?因为开源社区内几乎所有的jsondiff库定义的jsondiff规则都偏向于通用化,当然如果你需要对比的json数量不多,哪怕通用化的工具没有办法满足你所有的diff需求,你也可以用肉眼比对的方式解决,这当然是可以接受的。但我们碰到的业务场景几乎都是成千上万的json比对,在这个量级的比对场景下,肉眼比对显然无法接受。因此我们对jsondiff功能的期望是尽可能满足用户各类自定义的需求。

本文所述解决思路还没来得及完全实现,部分思路借鉴于 github.com/yudai/gojso… 的作者,在二次开发 gojsondiff 库的时候也发现了一些bug ,issue:github.com/yudai/gojso…

最后如果有同学有更好的解决思路或者问题也欢迎私聊一起探讨~