基于百度脑图的用例增量保存 + diff 展示整体设计

背景
现在所在公司用上了滴滴出品的 agileTC ,整体上非常好用。但有个功能大家都在呼唤:支持测试任务中修改用例集内容,并同步修改到完整用例集中。所以有了这篇文章。
先说明下具体使用的场景,让大家更理解为什么要做这个功能,目的是什么:
首先,测试集、测试任务这块是本身 agileTC 的已有设计,测试集用于存储测试用例,测试任务用于执行用例。测试任务可以在用例集中筛选/全选用例,进行每个用例的测试结果登记。但测试用例不能修改用例,要修改用例必须到用例集编辑界面(此界面无筛选功能)
在我司的实际项目使用中,常见用法是:
1、项目前期主要是少数 1-2 人参与,他们根据需求及技术方案,完成初版的完整用例,并在里面标记开发自测用例。用例评审用的也是这一版。
2、提测前开发自测时,测试会创建对应的开发自测用例并给开发参照执行、登记结果。
3、提测后,用例会分给多人执行(人数可能不止前期的 1-2 人),会通过自定义标签形式记录这批用例是谁负责。此时会通过测试任务的筛选条件来筛选到只剩下此人负责的用例,单独进行执行和登记。
实际执行中,可能会由于需求变更、部分需求细节需要补充等原因,在 2、3 步经常需要更新用例集。虽然也可以到测试集进行更新,但测试集合计会有过千个用例,而测试任务则只有数百个,因此在测试任务中就进行更新,相对而言会更为方便。
由于总用例集需要用于进行一些数据统计和二轮测试用,所以此时也需要更新。基于此,所以做这个增量保存的功能。
这块功能之前自己其实也有大致想过,但一直没有想得特别透彻,这次做的过程中也是一开始想着直接用现成的 json-patch 直接就可以满足,但自己随机测试一下就出现用例被覆盖且没有任何冲突提示的问题,所以后期干脆彻底梳理了一遍整体设计思路,再重新写代码、加单测。
这块可能其他有做基于百度脑图的 xmind 用例管理平台相关的同学也有遇到,所以在此分享一下,也期望大家有更好的思路,可以下面评论交流下。
全文较长,建议可以先看目录了解大概,再具体看内容 设计方案思考
现有技术方案是这样的:
1、现在用例集和测试任务,涉及 2 个表,test_case 和 exec_record。
2、test_case 表存储每个完整的用例集 json 内容,包括节点、标签、优先级。这个完整的 json 可以直接被脑图组件完整加载和展示。
3、exec_record 表存储每个测试任务的信息,包括筛选条件、各节点的执行结果。其中各节点执行结果存储方式是节点 id+ 测试结果,示例:

{"bv8nxhi3c800":"9","c8tws927cpc0":"9","c8tws7dgbm80":"9"}

4、在前端界面展示的测试任务内容,实际是经过 用例集表 json 根据筛选条件筛选节点->筛选后节点 json 和测试任务的测试结果进行合并 两个步骤得出。这个合并和筛选是实时的,每次刷新加载测试任务的脑图编辑界面,都会做一遍。
5、对于用例集的多人协作,滴滴本身也自带多人同时编辑用例的功能(集成在前端编辑器中,通过 websocket 实时存储 diff 和更新),但因为之前使用时发现会出现用例丢失、用例重复之类的问题,原因猜测和一些网络不稳定导致同步可能不够实时有关。由于前端编辑器没有开源,无法真正寻找到根源及修复,加上一些对脑图编辑器二次调整的需要,所以改用了另一个基于 kityminder + vue 改的脑图组件,也因此无法使用这个自带的多人同时编辑用例功能(这个功能要求编辑器实时上报用户的每一次操作改动,这个功能只有 agileTC 的脑图编辑器组件才具备)。
以前用过的另一个用例管理平台,模型会简单很多:
1、不区分用例和任务,用例本身就带有登记测试结果功能,数据库只需要存用例内容。
2、保存时,会自动根据服务端内容计算出本次保存和打开界面时版本的 diff ,然后把这个 diff 和最新用例内容进行自动合并存储。若合并发现冲突,则反馈冲突内容,让用户在前端界面手动解决冲突后存储。
在实际实践中,会出现合并冲突的情况极少,绝大部分情况都是可以直接自动合并存储的。所以大家的实际用法,也基本都是主测先创建一个简单的 xmind 并分好每个人负责的一级节点,然后各负责人员再去往这个一级下面扩展具体的用例内容。
基于上面的这些历史经验和方案,整体设计方案有两个大方向:
方向一:最小改动原则。用例集每次保存都是增量保存(包括任务中编辑、用例集中编辑),由服务端自动通过 base 版本和保存版本得出 diff ,再应用到最新的用例集中。
方向二:简化整体模型原则。直接去掉测试任务概念,回归之前用过的直接用例集保存测试结果,然后在此基础上,再应用增量保存功能。
考虑到目前大家已经有测试任务的使用习惯,且提出任务可以改用例需求的组,也比较认可测试任务这个工具。因此决定,采用方向一。
整体方案设计
基于百度脑图的用例增量保存 + diff 展示整体设计
文章图片

整体方案看起来比较简单,改动点主要有:
1、保存从全量保存变为增量保存
2、保存时可以检测冲突
3、类似 git ,不冲突的部分可以直接保存,冲突部分再单独引导用户手动处理。
但这里面的增量保存、冲突检测、diff 展示,都是一些技术难点。
技术难点及解决
难点一:如何检测生成两个版本 json 的 diff 分析 针对 json 的增量保存,刚好业内也有其他业务场景用到(通过增量同步 json 变更,减少网络带宽占用),目前已有两种官方正式协议:RFC 6902 (JSON Patch) 和 RFC 7396 (JSON Merge Patch)
json-patch 格式说明:https://atbug.com/json-patch/ 、https://datatracker.ietf.org/...(官方协议定义文档)
json-merge-patch 格式说明:https://datatracker.ietf.org/...(官方协议定义文档)
两者对比:https://erosb.github.io/post/...
相关 java 实现库:
https://github.com/flipkart-i... ——仅支持 json-patch 格式
https://nicedoc.io/java-json-... ——支持 json-patch + json-merge-patch 格式
简单总结下,两者的区别:
json-patch :生成两个 json 间的变化,并把每个变化点通过操作记录的方式来记录。如:
{ "op": "replace", "path": "/baz/1", "value": "boo" } { "op": "move", "from": "/biscuits", "path": "/cookies" }

op 代表操作。支持 add、remove、replace、copy、move、test 共 6 种操作。其中 test 仅作为校验用,不表达 json 变化。
针对 add、replace、test,会带上 path 和 value 字段。示例:{ "op": "replace", "path": "/baz", "value": "boo" }
其中 path 内容遵循另一个叫做 json-pointer 的规范。这个规范简单的说,就是所在对象为 object 的用 key 定位,为 array 的用下标定位,父子之间用 / 间隔。举例:"/biscuits" 、"/biscuits/0/name" 、""(代表整个 json )
value 字段则直接就是对应的 value ,可以是单个值、json object 或者 json array 。
针对 remove ,只有 path ,没有 value
针对 copy、move,用 from 指代源位置,path 指代目标位置。示例:{ "op": "move", "from": "/biscuits", "path": "/cookies" }
json-merge-patch:直接指示新的 json 中,各个 key 对应 value 变成的结果。无变化的不出现。如:
// 这个 patch 会把根节点下 key 为 a 的值替换为 z ,再把 c 下面的 f 删掉 { "a":"z", "c": { "f": null } }

key 代表要应用的位置。如果有嵌套,则 patch 内也要对应嵌套。
value 代表要改为的新值。其中 null 表示删除,非 null 表示要改的值
如果遇到某个对象是 array ,由于 key 不具备指代 array 中单个元素的能力,所以 patch 中必须完整地把新的 array 完整记录进来,直接进行完整的替换。
解决方案 json-merge-patch
特点一:不会出现冲突,因为指代的就是要改成什么样了
特点二: array 需要完整记录,脑图的 children 节点是 array 类型的,而且很可能很庞大,用这个基本相当于把一级节点外的所有其他节点都全量更新了,不符合场景需要。
json-patch
特点一:原子化,每个改动对应一个 op
特点二:对 array 也可以支持(难点二会提到,实际还是要废掉这个支持,筛选后脑图 json 的下标和原始下标会有很大差异)
因此,最终选择 json-patch ,选用 zjsonpatch 这个库。
关键代码如下:
ObjectMapper mapper = new ObjectMapper(); String convertedBaseContent = convertChildrenArrayToObject(baseContent); String convertedTargetContent = convertChildrenArrayToObject(targetContent); JsonNode base = mapper.readTree(convertedBaseContent); JsonNode result = mapper.readTree(convertedTargetContent); // OMIT_COPY_OPERATION: 每个节点的 id 都是不一样的,界面上的 copy 到 json-patch 应该是 add ,不应该出现 copy 操作。 // ADD_ORIGINAL_VALUE_ON_REPLACE: replace 中加一个 fromValue 表达原来的值 // 去掉了默认自带的 OMIT_VALUE_ON_REMOVE ,这样所有 remove 会带上原始值,在 value 字段中 EnumSet flags = EnumSet.of(OMIT_COPY_OPERATION, ADD_ORIGINAL_VALUE_ON_REPLACE); JsonNode originPatch = JsonDiff.asJson(base, result, flags);

难点二:如何在应用 diff 时发现冲突,并尽可能应用无冲突的部分 分析 针对难点一使用了 json-patch ,意味着每个改动点都会有一个原子的 patch 进行记录,整体改动会是一个数组模式,每个元素对应一次原子改动
解决方案 那这个方案就变得比较简单了:一次只应用整体 patch 数组中的一次改动,如果出错,则跳过并记录为冲突,不出错,则应用并更新用例内容
关键代码:
/** * 逐个应用 patch 到目标 json 中,并自动跳过无法应用的 patch 。 * @param patch patch json * @param baseContent 需要应用到的 json * @param flags EnumSet,每个元素为 ApplyPatchFlagEnum 枚举值。用于指代应用 patch 过程中一些特殊操作 * @return ApplyPatchResultDto 对象,包含应用后的 json 、应用成功的 patch 和跳过的 patch * @throws IOException json 解析错误时,抛出此异常 */ public static ApplyPatchResultDto batchApplyPatch(String patch, String baseContent, EnumSet flags) throws IOException { baseContent = convertChildrenArrayToObject(baseContent); ApplyPatchResultDto applyPatchResultDto = new ApplyPatchResultDto(); ObjectMapper mapper = new ObjectMapper(); JsonNode patchJson = mapper.readTree(patch); JsonNode afterPatchJson = mapper.readTree(baseContent); List conflictPatch = new ArrayList<>(); List applyPatch = new ArrayList<>(); for (JsonNode onePatchOperation : patchJson) { try { if (onePatchOperation.isArray()) { afterPatchJson = JsonPatch.apply(onePatchOperation, afterPatchJson); } else { // 外面包一个 array afterPatchJson = JsonPatch.apply(mapper.createArrayNode().add(onePatchOperation), afterPatchJson); } applyPatch.add(mapper.writeValueAsString(onePatchOperation)); } catch (JsonPatchApplicationException e) { conflictPatch.add(mapper.writeValueAsString(onePatchOperation)); } }String afterPatch = mapper.writeValueAsString(afterPatchJson); afterPatch = convertChildrenObjectToArray(afterPatch); applyPatchResultDto.setJsonAfterPatch(afterPatch); applyPatchResultDto.setConflictPatch(conflictPatch); applyPatchResultDto.setApplyPatch(applyPatch); return applyPatchResultDto; }

难点三:怎么保障无冲突的部分应用后正确 分析 某个角度来说,这个才是最难的。冲突的有人工兜底,没冲突就真的纯靠系统识别了,等到人工发现可能已经过了好多个版本,不好追溯和恢复。
需要先尽可能穷举所有可能的改动场景,并一一分析是否有问题。
首先,每次改动,从原子操作角度,可能产生的情况有:
增加新节点(包括从零编辑和通过复制粘贴得到的,因 id 值会不一样,从 json object 角度都认为是新增节点)
修改已有节点内容(文字、标签、优先级等属性)、
删除已有节点
移动已有节点(节点的 id 不变,只是位置变了)
四种场景。对应 json-patch 里面的 op :add、replace、remove、move(特别留意,这里也有暗坑,实际实现库有可能用 remove + add 来取代 move 操作,这样 add 就带上了内容的绝对值且无法比对是否和数据库一致)
考虑到多人协作,有可能 base 版本实际非数据库中实际最新版,因此每个原子操作进行分析时,增加 path 或 value 的 base 值,和数据库当前最新版一致/不一致的场景
add
影响因素:path + value
path:
和数据库不一致:直接提示冲突,没问题。
和数据库一致(问题一):object 时 key 都是唯一的,如果被其他人删掉导致无 key 会直接产生冲突,没问题。但 array 时根据下标定位,测试任务筛选条件可能会导致 children 节点在完整用例里有 3 个,任务里只有 1 个(实际对应完整用例第三个),引起下标指向错误。
value:
和数据库不一致:value 只会是此用户修改得出,数据库原来无值,此场景不存在。
和数据库一致:只有单人修改,value 属于独占内容,不会缺失或受其他人影响,可直接应用。无问题。
replace
影响因素:path + value
path(问题一):同 add 中的 path
value(问题二):
和数据库一致:无从知道是否和数据库一致,无原有值记录
和数据库不一致:可修改的有 text(文字)、priority(优先级)、resource(自定义标签)等,本质上都是节点 object 下 data 字段的子属性。因为 replace 并不会记录原值,所以可能存在 replace 后的新值覆盖了中途某人修改过的值,且不产生冲突的问题。
remove
影响因素:path + value(原 json-path 不考虑 value ,但为了保障删除内容和删除者意愿一致,需要校验一下)
path(问题一):同 add 的问题。
value:
和数据库一致:无问题,直接删除即可。
和数据库不一致(问题二):可能原因是别人有改动过且先保存,或者处于测试任务筛选条件导致内容和用例全集不一致。此时可能出现错删除了作者见不到,但实际存在的子节点。
move
影响因素:from、path。value 因为本身只是想表达移动节点意愿,可以无需校验。
from(问题一):同 add 的问题。
path(问题一):同 add 的问题。
总结起来,存在两个问题:
问题一:path 描述 array 时,数组下标由于用例可能被筛选过,只是子集,很可能不准
问题二:replace 及 remove 时,并没有记录原来的值,而是直接操作。有可能出现其实作者改动的源值和实际数据库最新值不一致的问题。
补充一个测试 java 库自动生成 patch 的规则时发现的问题:
问题三:自动生成的 patch ,可能会使用 remove + add 取代 move 。此时 add 带有的绝对值,可能会出现类似问题二的直接覆盖导致缺失问题。
解决方案 问题一:path 描述 array 时,数组下标由于用例可能被筛选过,只是子集,很可能不准
生成 patch 时,把 array 改为 object ,object 中每个子元素的 key 都为这个节点本身的 id 属性(脑图中每个节点的 id 属性会保证整个 json 全部节点中绝对的唯一)。生成完 patch 再改回来。
示例:
// 原脑图格式: {"root": {"data": {"id": "nodeA"}, "children": [{"data": {"id": "nodeAa"}, "children": []}, {"data": {"id": "nodeAb"}, "children": []}]}}// 把 array 改为 object 后格式: {"root": {"data": {"id": "nodeA"}, "childrenObject": {"nodeAa": {"data": {"id": "nodeAa"}, "childrenObject": {}, "order": 0}}, {"nodeAb": {"data": {"id": "nodeAb"}, "childrenObject": {}, "order": 1}}}}

关键代码:
/** * 把 children 从 array 改为 object (array中每个元素外面多加一个 key ,key 的值为元素中的 data.id ),解决 json-pointer 针对数组用下标定位,会不准确问题 * 示例: * 转换前:{"root": {"data": {"id": "nodeA"}, "children": [{"data": {"id": "nodeAa"}, "children": []}, {"data": {"id": "nodeAb"}, "children": []}]}} * 转换后:{"root": {"data": {"id": "nodeA"}, "childrenObject": {"nodeAa": {"data": {"id": "nodeAa"}, "childrenObject": {}, "order": 0}}, {"nodeAb": {"data": {"id": "nodeAb"}, "childrenObject": {}, "order": 1}}}} * @param caseContent 完整用例 json ,需包含 root 节点数据 * @return 转换后 children 都不是 array 的新完整用例 json */ public static String convertChildrenArrayToObject(String caseContent) { return convertChildrenArrayToObject(caseContent, true); }private static String convertChildrenArrayToObject(String caseContent, Boolean withOrder) { JSONObject caseContentJson = JSON.parseObject(caseContent); JSONObject rootData = https://www.it610.com/article/caseContentJson.getJSONObject("root"); rootData.put("childrenObject", convertArrayToObject(rootData.getJSONArray("children"), withOrder)); // 把旧数据直接删掉,换成新数据 rootData.remove("children"); return JSON.toJSONString(caseContentJson); }// 递归把 array 改为 object ,key 为原来子元素的 id private static JSONObject convertArrayToObject(JSONArray childrenArray, Boolean withOrder) {// 把 children 这个 array 换成 Object JSONObject childrenObject = new JSONObject(); // children 中每个子元素都变为 object for (int i=0; i

问题二:replace 及 remove 时,并没有记录原来的值,而是直接操作。有可能出现其实作者改动的源值和实际数据库最新值不一致的问题。
解决思路:
patch 中增加原值校验相关字段。原值一致才允许应用,原值不一致则认为冲突不允许应用。
考虑到改动 json-patch 的实现库比较麻烦且容易埋坑,改为使用 test 这个 op 字段来进行校验,即原来单纯的 replace/remove 变为 test + replace/remove ,test 用于校验原有字段值。至于 test 原字段值,则通过生成的 patch 内容拿
相关代码:
/** * 给所有 replace 或 remove 的 patch ,能校验原始值的,都加上 test * @param allPatch ArrayNode 形式的所有 patch 内容 * @return 添加完 test 后的所有 patch 内容 */ private static ArrayNode addTestToAllReplaceAndRemove(ArrayNode allPatch) { ObjectMapper mapper = new ObjectMapper(); ArrayNode result = mapper.createArrayNode(); for (JsonNode onePatch : allPatch) { // 实际应用 patch 时,不会管 replace 本身的 fromValue 字段。得手动前面加一个 test 的校验应用前的原内容是否一致,并在外面再用一个 array 包起来。 // 即 [.., {op: replace, fromValue: .., path: .., value: ..}] 改为 [.., [{op: test, path: .., value: }, {op: replace, path: .., value: }]] // 如果没有 fromValue 字段,那无法校验,直接按原来样子记录即可 if ("replace".equals(onePatch.get("op").asText()) && onePatch.get("fromValue") != null) { ArrayNode testAndReplaceArray = mapper.createArrayNode(); ObjectNode testPatch = mapper.createObjectNode(); testPatch.put("op", "test"); testPatch.put("path", onePatch.get("path").asText()); testPatch.set("value", onePatch.get("fromValue")); testAndReplaceArray.add(testPatch); testAndReplaceArray.add(onePatch); result.add(testAndReplaceArray); continue; }// remove 同理,有 value 的前面都加一个 test if ("remove".equals(onePatch.get("op").asText()) && onePatch.get("value") != null) { ArrayNode testAndRemoveArray = mapper.createArrayNode(); ObjectNode testPatch = mapper.createObjectNode(); testPatch.put("op", "test"); testPatch.put("path", onePatch.get("path").asText()); testPatch.set("value", onePatch.get("value")); testAndRemoveArray.add(testPatch); testAndRemoveArray.add(onePatch); result.add(testAndRemoveArray); continue; }result.add(onePatch); }return result; }

问题三:自动生成的 patch ,可能会使用 remove + add 取代 move 。此时 add 带有的绝对值,可能会出现类似问题二的直接覆盖导致缺失问题。
经过查看 zjsonpatch 库里 move 的实现,原理还是确认 add 和 remove 的 value 是否有完全一样,如果有,则两者合并成 move 。
之所以会无法合并,原因是前面的 array 转 object 里面加入的 order 字段会变化。
所以,可以做一次不带有 order 字段的转换,先得出 move 字段。然后再把带 order 字段转换中 path 和 move 的 from 或者 path 重合的去掉。
衍生问题:order 位置未被更新(比如原来位置 order 为 5 ,新位置 order 为 3 ,但因为 move 是原版直接挪,所以 move 完内容的 order 还是 5)。放到问题四单独分析解决
相关代码:
// OMIT_COPY_OPERATION: 每个节点的 id 都是不一样的,界面上的 copy 到 json-patch 应该是 add ,不应该出现 copy 操作。 // ADD_ORIGINAL_VALUE_ON_REPLACE: replace 中加一个 fromValue 表达原来的值 // OMIT_MOVE_OPERATION: 所有 move 操作,都还是维持原来 add + remove 的状态,避免一些类似 priority 属性值的一增一减被认为是 move 。 // 去掉了默认自带的 OMIT_VALUE_ON_REMOVE ,这样所有 remove 会在 value 字段中带上原始值 JsonNode originPatch = JsonDiff.asJson(base, result, EnumSet.of(OMIT_COPY_OPERATION, ADD_ORIGINAL_VALUE_ON_REPLACE, OMIT_MOVE_OPERATION)); // 借助去掉 order 的内容,正确生成 move 操作 JsonNode baseWithoutOrder = mapper.readTree(convertChildrenArrayToObject(baseContent, false)); JsonNode targetWithoutOrder = mapper.readTree(convertChildrenArrayToObject(targetContent, false)); List allFromPath = new ArrayList<>(); List allToPath = new ArrayList<>(); List allMoveOprations = new ArrayList<>(); // 需要生成 move 操作,去掉原有 flags 里面的忽略 move 标记 JsonNode noOrderPatch = JsonDiff.asJson(baseWithoutOrder, targetWithoutOrder, EnumSet.of(OMIT_COPY_OPERATION, ADD_ORIGINAL_VALUE_ON_REPLACE)); for (JsonNode oneNoOrderPatch: noOrderPatch) { if ("move".equals(oneNoOrderPatch.get("op").asText())) { allFromPath.add(oneNoOrderPatch.get("from").asText()); allToPath.add(oneNoOrderPatch.get("path").asText()); allMoveOprations.add(oneNoOrderPatch); } }ArrayNode finalPatch = mapper.createArrayNode(); // 先把所有 move 加进这个最终的 patch 中 for (JsonNode movePatch : allMoveOprations) { finalPatch.add(movePatch); }for (JsonNode onePatch : originPatch) { // 和 move 匹配的 add 中,根节点 order 字段需要变为 replace 存下来,避免丢失顺序 if ("add".equals(onePatch.get("op").asText()) && allToPath.contains(onePatch.get("path").asText())) { // 获取 add 中 value 第一层的 order 值。此时 value 实际是移动的整体 object ,order 就在第一层 int newOrder = onePatch.get("value").get("order").asInt(); ObjectNode replaceOrderPatch = mapper.createObjectNode(); replaceOrderPatch.put("op", "replace"); replaceOrderPatch.put("path", onePatch.get("path").asText() + "/order"); replaceOrderPatch.put("value", newOrder); // 这种情况下就不用管 replace 的原来值是什么了,所以不设定 fromValue finalPatch.add(replaceOrderPatch); // 这个 add 的作用已经被 move + replace 达成了,所以不需要记录这个 add continue; }// move 的源节点删除操作,需要忽略,因为 move 已经起到相应的作用了 if ("remove".equals(onePatch.get("op").asText()) && allFromPath.contains(onePatch.get("path").asText())) { continue; }// 如果 order 没变,那不去除 order 的 patch 有可能也有 move 。这个时候这个 move 需要去掉,避免重复 if ("move".equals(onePatch.get("op").asText()) && allMoveOprations.contains(onePatch)) { continue; }// 其他不需要调整的,直接加进去就可以了 finalPatch.add(onePatch); }

问题三解决方案的衍生问题四:move 操作的元素,因为是整体内容挪过来的,会导致 order 位置未被更新(比如原来位置 order 为 5 ,新位置 order 为 3 ,但因为 move 是原版直接挪,所以 move 完内容的 order 还是 5)。
如果不用 move 操作,则会出现 add + replace(如果 order 有变更)+ remove 。
所以,解决方法只需要重新应用 replace 操作即可,并且要保障 replace 放在 move 后,避免节点已经被 move 应用失败。
由于生成的 replace 操作有可能作用在原有位置,因此匹配的 path 需要改为新位置。
相关代码:
... 前面是问题三中生成了 move patch 的相关逻辑,其中 allToPath 指代所有 move 中的 path 路径,即移动到的新位置 path for (JsonNode onePatch : originPatch) { // 和 move 匹配的 add 中,根节点 order 字段需要变为 replace 存下来,避免丢失顺序 if ("add".equals(onePatch.get("op").asText()) && allToPath.contains(onePatch.get("path").asText())) { // 获取 add 中 value 第一层的 order 值。此时 value 实际是移动的整体 object ,order 就在第一层 int newOrder = onePatch.get("value").get("order").asInt(); ObjectNode replaceOrderPatch = mapper.createObjectNode(); replaceOrderPatch.put("op", "replace"); replaceOrderPatch.put("path", onePatch.get("path").asText() + "/order"); replaceOrderPatch.put("value", newOrder); // 这种情况下就不用管 replace 的原来值是什么了,所以不设定 fromValue finalPatch.add(replaceOrderPatch); continue; }// move 的源节点删除操作,可以忽略 if ("remove".equals(onePatch.get("op").asText()) && allFromPath.contains(onePatch.get("path").asText())) { continue; }// 其他不需要调整的,直接加进去就可以了 finalPatch.add(onePatch); }

难点四:测试任务带有筛选条件,有可能只是完整用例集的子集。对子集的修改应用到全集时,可能部分内容会对不上引起冲突。 分析 首先,筛选条件目前只有两类:优先级/自定义标签。筛选的的子集和全集相比,在节点 data 内容层面不会有任何不同,只有在节点 children 这个数组层面会减少内容(数量上的减少,子元素内容不会少)。
内容减少,只会引起数组下标的变化,即上一个问题解决方案中 childrenObject 子元素的 order 值不正确,进而引起如果增量改动里有改动 order 会引起冲突(子集的原始值和全集里的原始值不一致)。
举例:
全集:root 节点下一级,依次有 A、B、C 节点。只有 A、C 符合筛选条件
子集:root 节点下一级,只有 A、C 两个节点
操作 1:在 C 后面增加节点。新节点会以 add 操作增加到 root 下面的 children 中,order 会为 3 甚至更大的值。因为是新增的,不会有冲突,但因为 order 可能大于原有 array 的 size ,只需要转换回 array 时只要把没应用上的都在后面补回去即可。
操作 2:在 A、C 之间增加节点。新节点 add 和操作 1,但会引起 C 节点的 replace ,order 从 2 变 3 。由于全集里 C 的 order 其实是 3,这个 replace 会在验证原始值时失败认为冲突。这个冲突其实无关紧要,加一个忽略即可。
解决方案 1、操作 1:在 C 后面增加节点。新节点会以 add 操作增加到 root 下面的 children 中,order 会为 3 甚至更大的值。因为是新增的,不会有冲突,但因为 order 可能大于原有 array 的 size ,只需要转换回 array 时只要把没应用上的都在后面补回去即可。
相关代码:
// 递归把每个 object 改回 array ,去掉 object 中第一层的 key private static JSONArray convertObjectToArray(JSONObject childrenObject, Boolean withOrder) { JSONArray childrenArray = new JSONArray(); List keyMoved = new ArrayList<>(); // object 中每个子元素,重新放回到 array 中 for (int i=0; i

2、操作 2:在 A、C 之间增加节点。新节点 add 和操作 1,但会引起 C 节点的 replace ,order 从 2 变 3 。由于全集里 C 的 order 其实是 3,这个 replace 会在验证原始值时失败认为冲突。这个冲突其实无关紧要,加一个忽略即可。
相关代码:
for (JsonNode onePatchOperation : patchJson) { try { if (onePatchOperation.isArray()) { afterPatchJson = JsonPatch.apply(onePatchOperation, afterPatchJson); } else { // 外面包一个 array afterPatchJson = JsonPatch.apply(mapper.createArrayNode().add(onePatchOperation), afterPatchJson); } applyPatch.add(mapper.writeValueAsString(onePatchOperation)); } catch (JsonPatchApplicationException e) { // 检查是否是对 order 的操作。如果是,那就忽略这个冲突 if (flags.contains(IGNORE_REPLACE_ORDER_CONFLICT) && onePatchOperation.isArray() && onePatchOperation.get(0).get("path").asText().endsWith("/order")) { continue; } conflictPatch.add(mapper.writeValueAsString(onePatchOperation)); } }

难点五:如何在出现冲突后进行友好标识,提高解决冲突效率 分析 首先,需要存储存在冲突的变更。从难点二的解决可知,只要从冲突 patch 列表就可以得到。只要备份里增加这个字段即可。
然后,就是怎么根据这个 patch 列表,以及冲突副本完整脑图内容,呈现变更了。
git 标记 diff 的方法,是给增加的内容(+)加上绿色底色,删除的内容(-)加上红色底色,重命名或移动文件则直接通过文件名位置, 以 old -> new 的格式标识。修改内容(replace)从底层上就直接是 删除 + 增加 来表示。
同样的方式放到脑图,增加没问题,删除只要把被删除内容加回来也没问题。没有重命名或移动文件机制,但有修改节点内容及移动节点机制。
由于脑图非纯文本文件,而是以 json 形式记录数据,脑图编辑器呈现数据的形式。diff 内容基本是 path + value 的形式记录,通过 path 不好直观看出改动位置,因此需要直接在冲突副本上通过添加标记的方式进行展示。
按照相对直觉的方式,设定如下标识:
1、增加的节点:加上绿色底色
2、删除的节点:加上红色底色
3、修改的节点(包括移动节点、修改节点自身的文字、优先级、自定义标签等):加上蓝色底色
解决方案 由于实际 json-patch 的操作,并不会认识 “节点” 这个概念,只知道 json 里的 object 及 array 。
所以,需要先判断 patch 的操作对象,是一个节点还是非节点。判断条件为操作的 path 属性。如果是节点,一定会以类似 /childrenObject/xxx 的形式结尾
相关代码:
/** * 根据 jsonPatch 内容,在脑图中标记变更。以节点为单位,增加的加绿色背景,删除的加红色背景,修改的加蓝色背景。 * 特别注意,移动节点(move)因为实际节点 id 未有变化,所以也会被标记为修改 * * @param minderContent * @param jsonPatch * @return */ public static String markJsonPatchOnMinderContent(String jsonPatch, String minderContent) throws IOException, IllegalArgumentException { String green = "#67c23a"; String blue = "#409eff"; String red = "#f56c6c"; ObjectMapper objectMapper = new ObjectMapper(); // 因为 jsonPatch 是针对已经把 children 数组变为对象的 json 格式,所以要先转换下 ObjectNode convertedMinderContentJson = objectMapper.readTree(convertChildrenArrayToObject(minderContent)).deepCopy(); ArrayNode jsonPatchArray = (ArrayNode) objectMapper.readTree(jsonPatch); for (JsonNode onePatch : jsonPatchArray) { JsonNode operation; if (onePatch.isArray() && onePatch.size() == 2) { // 只可能是 replace 或 remove 的。前面多加了 test ,会是一个带有两个子元素的 array 。第二个才是 replace 或 remove operation = onePatch.get(1); if (!("replace".equals(operation.get("op").asText()) || "remove".equals(operation.get("op").asText()))) { throw new IllegalArgumentException(String.format("此单个 patch 格式不正常," + "正常格式在双元素 array 的第二个,应该是 replace 或 remove 操作" + "不符合的 patch 内容: %s", objectMapper.writeValueAsString(onePatch))); } } else if (onePatch.isObject()) { operation = onePatch; } else { // 目前不会生成不符合这两种格式的 patch ,抛异常 throw new IllegalArgumentException(String.format("此单个 patch 格式不正常,正常格式应该是双元素array或单个object" + "请确认 patch 内容是通过此工具类提供的获取 patch 方法生成。不符合的 patch 内容: %s", objectMapper.writeValueAsString(onePatch))); }// 先判定是否为整个节点的内容变更 if (isNodePath(operation.get("path").asText())) { // 节点级别,只支持 add 、 remove 、move 。因为 replace 只改值不改key,不可能在节点级别产生 replace 操作 switch (operation.get("op").asText()) { case "add": addAddNodeMark(convertedMinderContentJson, operation, green); break; case "move": addMoveNodeMark(convertedMinderContentJson, operation, blue); break; case "remove": addRemoveNodeMark(convertedMinderContentJson, operation, red); break; default: throw new IllegalArgumentException(String.format("此单个 patch 格式不正常," + "正常的节点级别 patch ,op 应该是 add、move、remove 其中一个" + "不符合的 patch 内容: %s", objectMapper.writeValueAsString(operation))); } } else { // 非节点级别变更,都将它标记为 修改内容 即可。不应该出现 move 节点属性的动作 switch (operation.get("op").asText()) { case "add": addAddAttrMark(convertedMinderContentJson, operation, blue); break; case "replace": addReplaceAttrMark(convertedMinderContentJson, operation, blue); break; case "remove": addRemoveAttrMark(convertedMinderContentJson, operation, blue); break; default: throw new IllegalArgumentException(String.format("此单个 patch 格式不正常," + "正常的非节点级别 patch ,op 应该是 add、replace、remove 四个其中一个" + "不符合的 patch 内容: %s", objectMapper.writeValueAsString(operation))); } } }return convertChildrenObjectToArray(objectMapper.writeValueAsString(convertedMinderContentJson)); }

总结
由于篇幅所限,其实里面有些小的问题解决并没有列在上面的技术难点里面(比如应用变更时,如果 replace order 操作出错,可以忽略)。整体改动大概花了 4 人天左右,而且中途也写了不少单测代码来保障每次改动都不会影响已有功能(行覆盖率达到 94%,只有少量格式不对抛异常的逻辑没有覆盖)。
此次场景比较复杂,已经尽自己所能,用相对靠谱的分析方法列举出所有可能的场景,并进行对应处理。但是否靠谱还需要靠实践检验,预计节后会上线此功能,届时再看看实际使用的效果。
如果有其它同学也做过类似的功能,有更好的算法或者思路,也欢迎直接评论分享交流下
开源
目前服务端相关的代码改动及配套单测,已提交 PR 给官方。地址:https://github.com/didi/Agile...
增量生成、应用、标记的逻辑全部在 case-server/src/main/java/com/xiaoju/framework/util/MinderJsonPatchUtil.java 这个工具类
配套单测在 case-server/src/test/java/com/xiaoju/framework/util/MinderJsonPatchUtilTest.java
【基于百度脑图的用例增量保存 + diff 展示整体设计】如果有需要的,可以按需自取哈。

    推荐阅读