TesterHome测试之家

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

2022-01-27  本文已影响0人  TesterHome

背景

现在所在公司用上了滴滴出品的 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 ,再应用到最新的用例集中。

方向二:简化整体模型原则。直接去掉测试任务概念,回归之前用过的直接用例集保存测试结果,然后在此基础上,再应用增量保存功能。

考虑到目前大家已经有测试任务的使用习惯,且提出任务可以改用例需求的组,也比较认可测试任务这个工具。因此决定,采用方向一。

整体方案设计

整体方案看起来比较简单,改动点主要有:

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/doc/html/rfc6902(官方协议定义文档)

json-merge-patch 格式说明:https://datatracker.ietf.org/doc/html/rfc7386(官方协议定义文档)

两者对比:https://erosb.github.io/post/json-patch-vs-merge-patch/

相关 java 实现库:

https://github.com/flipkart-incubator/zjsonpatch ——仅支持 json-patch 格式

https://nicedoc.io/java-json-tools/json-patch ——支持 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<DiffFlags> 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<ApplyPatchFlagEnum> 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<String> conflictPatch = new ArrayList<>();

        List<String> 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 = 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<childrenArray.size(); i++) {

            JSONObject child = childrenArray.getJSONObject(i);

            String childId = child.getJSONObject("data").getString("id");

            if (withOrder) {

                // 加一个 order 字段,用于转回 array 时保证内部顺序一致。

                child.put("order", i);

            }

            childrenObject.put(childId, child);

            // 对 child 进行递归,把它的 children 再变成 object

            JSONArray childrenArrayInChild = child.getJSONArray("children");

            child.put("childrenObject", convertArrayToObject(childrenArrayInChild, withOrder));

            // 删掉已经不需要的 children 字段

            child.remove("children");

        }

        return childrenObject;

    }

问题二: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: <fromValue>}, {op: replace, path: .., value: <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<String> allFromPath = new ArrayList<>();

List<String> allToPath = new ArrayList<>();

List<JsonNode> 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<String> keyMoved = new ArrayList<>();

    // object 中每个子元素,重新放回到 array 中

    for (int i=0; i<childrenObject.keySet().size(); i++) {

        for (String key : childrenObject.keySet()) {

            JSONObject child = childrenObject.getJSONObject(key);

            if (withOrder) {

                // 需要根据 order 判定原来的顺序,按顺序加进去,避免顺序错误

                if (Integer.valueOf(i).equals(child.getInteger("order"))) {

                    childrenArray.add(child);

                    keyMoved.add(key);

                } else {

                    continue;

                }

            } else {

                // 不用管 order ,直接一个一个 key 加进去就是了

                childrenArray.add(child);

                keyMoved.add(key);

            }

            // 对添加的 child 进行递归,把它的 childrenObject 再变回 array

            JSONObject childrenObjectInChild = child.getJSONObject("childrenObject");

            child.put("children", convertObjectToArray(childrenObjectInChild, withOrder));

            if (withOrder) {

                // 去掉排序用的临时字段

                child.remove("order");

            }

            child.remove("childrenObject");

        }

    }

    // ** 重点:有可能通过 move 过来的 order 值很大,最后要把剩余的 childrenObject 元素继续放到 array 里面

    for (String key : childrenObject.keySet()) {

        if (!keyMoved.contains(key)) {

            childrenArray.add(childrenObject.getJSONObject(key));

        }

    }

    return childrenArray;

}

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/AgileTC/pull/93

增量生成、应用、标记的逻辑全部在 case-server/src/main/java/com/xiaoju/framework/util/MinderJsonPatchUtil.java 这个工具类

配套单测在 case-server/src/test/java/com/xiaoju/framework/util/MinderJsonPatchUtilTest.java

如果有需要的,可以按需自取哈。


由陈恒捷首发于TesterHome社区

上一篇下一篇

猜你喜欢

热点阅读