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

3次阅读

共计 20938 个字符,预计需要花费 53 分钟才能阅读完成。

背景

当初所在公司用上了滴滴出品的 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/…(官网协定定义文档)

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<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/Agile…

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

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

如果有须要的,能够按需自取哈。

正文完
 0