jacoco-0.8.13版本二开,实现覆盖率跨代码版本合并(细化到方法维度)
1. 期望及现状
示例代码版本A
@RestController @RequestMapping(value = "/jacocotest") public class JacocoTestController { @GetMapping(value = "/test1") public String test1() { int a, b; a = 1; b = 2; return String.valueOf(a + b); } @GetMapping(value = "/test2") public String test2() { int a, b; a = 10; b = 20; return String.valueOf(a + b); } }
假设对 test1、test2都进行了调用,并dump了1.exec文件
第一次变动为代码版本B --> 修改test2 新增test3
@RestController @RequestMapping(value = "/jacocotest") public class JacocoTestController { @GetMapping(value = "/test1") public String test1() { int a, b; a = 1; b = 2; return String.valueOf(a + b); } @GetMapping(value = "/test2") public String test2() { int a, b; a = 10; b = 30; //修改 return String.valueOf(a + b); } //新增 @GetMapping(value = "/test3") public String test3() { int a, b; a = 100; b = 200; return String.valueOf(a + b); } }
服务重新打包部署,假设只对test3进行了调用,并dump了2.exec文件
第二次变动为代码版本C --> 新增test4
@RestController @RequestMapping(value = "/jacocotest") public class JacocoTestController { @GetMapping(value = "/test1") public String test1() { int a, b; a = 1; b = 2; return String.valueOf(a + b); } @GetMapping(value = "/test2") public String test2() { int a, b; a = 10; b = 30; return String.valueOf(a + b); } @GetMapping(value = "/test3") public String test3() { int a, b; a = 100; b = 200; return String.valueOf(a + b); } //新增 @GetMapping(value = "/test4") public String test4() { int a, b; a = 1000; b = 2000; return String.valueOf(a + b); } }
服务重新打包部署,假设没有再进行任何调用,并dump了3.exec文件
1.1 期望
如果可以对1.exec 2.exec 3.exec进行合并,那么我们期望合并后的exec文件得出的覆盖率报告应该具备:
test1 覆盖 版本A调用
test2 未覆盖 版本A虽然进行了调用,但是版本B进行了修改,覆盖率应该重新统计
test3 覆盖 版本B调用
test4 未覆盖 ABC版本均未调用
1.2 现状
直接使用jacoco提供的merge进行exec文件进行合并后,得到的覆盖报告中,四个接口均显示未调用
2. 现状分析
下图为jacoco存储服务执行内容的数据结构设计,其中entries的key为class id(对类的任何修改操作都会改变类的class id),value为该类的执行情况

value数据结构如下,其中 id为class id、name为class name、 probes为插桩点记录的类的每行执行情况

jacoco提供的merge方法如下,等价于对目标ExecutionData做或操作:

该方法第一行的assertCompatibility校验方法如下,具体看参照我加的注释:

上面的校验方法解释了为什么jacoco不支持跨版本的覆盖率合并:1. id变了 2. probes长度也不一定一样
但其实不重要,要实现跨代码版本的覆盖率合并,这个方法是肯定不会用到的。
3. 实现思路
3.1 方法执行状态
首先我们需要按方法对probes做切分,知道probes里每个部分对应这个类的方法,因为每个方法的执行状态是我们进行合并的数据基础把目光聚焦于ClassProbesAdapter

该类的counter就是jacoco进行class文件分析时记录class的行读取情况计数器,所以我们要做的就是在jacoco分析class文件的每个方法的开始和结尾获取这个counter存起来就可以了
开始时:
ClassProbesAdapter.visitMethod 这里的方法笔者进行了二开以实现:1. 只对两个版本间有变动的方法生成覆盖率报告 2. 读取方法起始时的counter

结束时:

以上为获取类方法在probes中的起止位置的方式
3.2 数据结构设计(粗糙实现)
@Data public class MergeInfo { //控制操作的ExecutionDataMap private boolean isAddMode = false; // 差异方法信息<className, Set<methodName,param...>> private Map<String, Set<String>> diffMethodInfos = new HashMap<>(); // 基础类执行数据<className, > private Map<String,ClassExecutionData> baseClassExecutionData = new HashMap<>(); // 追加类执行数据<className, > private Map<String,ClassExecutionData> addClassExecutionData = new HashMap<>(); public Map<String, ClassExecutionData> getNowClassExecutionData(){ if(isAddMode){ return addClassExecutionData; } return baseClassExecutionData; } public void resetAddClassExecutionData(){ addClassExecutionData.clear(); } public void resetDiffMethodInfos(){ diffMethodInfos.clear(); } }
@Data public class ClassExecutionData { private boolean[] probes; //<<methodName,param...>, <[begin, end]>> Map<String, int[]> methodLineInfos = new HashMap<>(); }
MergeInfo.diffMethodInfos存两个exec文件关联的两个代码版本的所有代码差异 使用JGit进行版本分析
MergeInfo.baseClassExecutionData存每次合并的target exec
MergeInfo.addClassExecutionData存每次合并的source exec
ClassExecutionData.methodLineInfos 用来存每个方法在probes中的起止行
3.3 流程
假如需要把2.exec 3.exec进行合并
那么我们将3.exec作为target exec,将2.exec作为source exec
生成了MergeInfo.baseClassExecutionData和MergeInfo.addClassExecutionData
然后我们将MergeInfo.addClassExecutionData里的数据合并到MergeInfo.baseClassExecutionData
核心逻辑:
//手动合并mergeInfo里的数据 Map<String, ClassExecutionData> baseExecution = mergeInfo.get().getBaseClassExecutionData(); Map<String, ClassExecutionData> addExecution = mergeInfo.get().getAddClassExecutionData(); Map<String, Set<String>> diffMethodInfos = mergeInfo.get().getDiffMethodInfos(); baseExecution.forEach((className, baseClassExecutionData) -> { //该class没有变动不需要合并 if(!diffMethodInfos.containsKey(className)){ return; } //旧分支没有该class,该class为新增的class,不需要合并probes if(!addExecution.containsKey(className)){ return; } //到这里说明该class变动过,进一步检测各个方法 ClassExecutionData addClassExecutionData = addExecution.get(className); baseClassExecutionData.getMethodLineInfos().forEach((methodName, baseLineInfo)->{ //旧分支没有该方法执行数据,不需要合并probes if(!addClassExecutionData.getMethodLineInfos().containsKey(methodName)){ return; } //说明该方法更新过,不需要合并,使用新分支的方法覆盖率数据 if(diffMethodInfos.get(className).contains(methodName)){ return; } //说明新旧方法没有更新,需要合并 int[] oldLineInfo = addClassExecutionData.getMethodLineInfos().get(methodName); int baseMethodLength = baseLineInfo[1] - baseLineInfo[0]; int addMethodLength = oldLineInfo[1] - oldLineInfo[0]; Assert.isTrue(baseMethodLength == addMethodLength, "未检测出变动的方法长度不一致,请检查!"); for (int i = 0; i <= baseMethodLength ; i++) { if(addClassExecutionData.getProbes()[oldLineInfo[0]+i]){ baseClassExecutionData.getProbes()[baseLineInfo[0]+i] = true; } } });
合并完后清空MergeInfo.addClassExecutionData,将1.exec再次作为source exec读取到MergeInfo.addClassExecutionData,继续上述流程,最后我们得到了一个合并了所有执行数据的Map<String, ClassExecutionData>
然后我们使用最初作为target exec的文件3.exec的ExecutionDataStore作为基础,将Map<String, ClassExecutionData>合并进ExecutionDataStore
在ExecutionDataStore里进行操作:


这样我们就完成了执行数据的合并
4. 效果展示

报告覆盖率与我们期望符合

浙公网安备 33010602011771号