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

 结束时:

MethodProbesAdapter重写visitMethod方法即可

 以上为获取类方法在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里进行操作:

ExecutionData:

 这样我们就完成了执行数据的合并

4. 效果展示

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

posted @ 2025-06-17 17:45  忙碌了一整天的L师傅  阅读(208)  评论(0)    收藏  举报