背景

现在所在公司用上了滴滴出品的 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 格式

简单总结下,两者的区别:

{ "op": "replace", "path": "/baz", "value": "boo" }
{ "op": "move", "from": "/biscuits", "path": "/cookies" }
// 这个 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;
    }

难点三:怎么保障无冲突的部分应用后正确

分析

某个角度来说,这个才是最难的。冲突的有人工兜底,没冲突就真的纯靠系统识别了,等到人工发现可能已经过了好多个版本,不好追溯和恢复。

需要先尽可能穷举所有可能的改动场景,并一一分析是否有问题。

首先,每次改动,从原子操作角度,可能产生的情况有:

  1. 增加新节点(包括从零编辑和通过复制粘贴得到的,因 id 值会不一样,从 json object 角度都认为是新增节点)
  2. 修改已有节点内容(文字、标签、优先级等属性)、
  3. 删除已有节点
  4. 移动已有节点(节点的 id 不变,只是位置变了)

四种场景。对应 json-patch 里面的 op :add、replace、remove、move(特别留意,这里也有暗坑,实际实现库有可能用 remove + add 来取代 move 操作,这样 add 就带上了内容的绝对值且无法比对是否和数据库一致)

考虑到多人协作,有可能 base 版本实际非数据库中实际最新版,因此每个原子操作进行分析时,增加 path 或 value 的 base 值,和数据库当前最新版一致/不一致的场景

总结起来,存在两个问题:

问题一: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

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


↙↙↙阅读原文可查看相关链接,并与作者交流