效能度量 jacoco 多版本报告合并

dray · 2023年08月04日 · 最后由 小狄子 回复于 2023年08月14日 · 5742 次阅读

jacoco 提供了一个 merge 命令可以给我方便的合并代码无变更时的报告,但是一旦代码发生变化,则无法通过 jacoco 进行直接合并,原因在《jacoco 的多次代码提交 merge 分析》中已经说明,那么针对一次功能测试,势必会进行多轮,每一轮针对变更的数据进行覆盖率增量报告,这是没有什么问题的,但是对于一些需求可能需要展示整个测试的覆盖报告,怎么将变更类的报告也进行合并呢?这就是本文讨论的问题。

通过之前的博客,我们理解了一旦类发生变更,探针的顺序势必不同,进而无法进行合并,因为 jacoco 的探针数据只是存储了其顺序和是否被访问,我们无法直接将两个探针进行强制合并。网络上针对代码变更类报告合并的资料相对比较少,很多实现的方案是基于一个最终报告的裁剪,比如我第一的报告有一个 a 方法,他的覆盖行数是 10,第二次他的覆盖行数是 20,简单做了一个累加就决定了他的覆盖行数是 30(这里指指令),这样明显的漏洞是我第二次的覆盖里面包含了第一次的几行,两者应该是取并集,但是 jacoco 的报告里有报告相关的数据,不会有具体某个指令的覆盖情况,所以基于这种方式合并报告误差率非常高。
所以我们只能在生成报告的阶段去干涉覆盖率的合并,那么我们必然要熟悉报告的生成过程
当我们去生成报告的时候,非常重要的一个文件是 jacoco.exec 文件,我们可以通过官方人提供的命令去简单查看一下其内容:

java -jar jacococli.jar execinfo jacoco.exec 

第一行是一个 classid,在上一篇文章中有讲解过,第二个数据就是每个类探针的信息,第三个数据就是类名。我们再梳理下 jacoco 生成报告的过程
在这里插入图片描述
因为 exec 文件没有记录方法的信息,所以生成报告的阶段则是会基于 class 文件重新进行一次插桩,同时会把探针的访问信息也会恢复,这样 jacoco 再使用 ASM 遍历每个类每个方法的指令去收集指令的覆盖率,然后以此再一层层运算到行覆盖率,方法覆盖率,类覆盖率,包覆盖率等。
根据上图所示,我们想对相同全限定名的类进行合并,则需要再对指令覆盖率进行合并。
在此基础上我们首先要对 jacoco 的 report 源码有一定的了解。

jacoco 的 report 源码分析

很多伙伴对 jacoco 的调试功能比较陌生,其实我们使用 jacoco 的单测就很方便进行代码调试,我们以 report cli 为入口

只需要在这里写我们自己的单测用例即可

我们以 reportcli 为入口,如上图所示,我们在 report 的时候,主要有三个方法

  1. 解析 exec 文件并合并探针(只能合并相同 classId 的类的探针)
  2. 根据 class 文件和探针信息获取覆盖数据
  3. 绘制报告,在源码上渲染覆盖信息 第一个方法我们再 merge 源码分析里已经清晰讲解过,这里我们详细讲解下第二个方法(第三个方法用例变更代码打标签功能,有兴趣的小伙伴可以自行了解下)

如上图所示,该方法会遍历我们所有文件里的 class 文件,在此之前创建了一个 CoverageBuilder 对象,这个对象会贯穿我们整个报告计算并最终得到源码覆盖率信息
在上文我们已经提到过,由于探针记录的信息非常有限,我们想要得到指令的覆盖率,只能在 class 基础上再一次进行插桩,这也是为什么 classid 一旦发生改变无法进行合并的原因,在找到每一个 class 文件后进行遍历解析

这里我们要关注一下 createAnalyzingVisitor 方法,他的主要作用是为每个 class 文件创建一个 ClassVistor

核心功能来了,这里我们遍历 class 文件内的每个方法,然后进行了插桩,通过个 InstructionsBuilder 对象接受了指令的覆盖信息,指令覆盖信息是 jacoco 覆盖率最基础最原子的数据,后面的行覆盖率,类覆盖率都是基于指令覆盖率去计算的,所以我们想做不同版本类的覆盖率,需要去合并指令覆盖率,这里有一个核心问题是一旦类发生变更了,但是其中的没有变更的方法指令是否是一样的呢


通过对比两个字节码,我们清晰的可以看出,指令是完全一样的,除了行号,那么我们是否就可以以此作为我们的突破口呢?
ASM 在遍历方法的指令时得到的结果又是怎样的呢?我们找到了两个不同版本的统一类进行分析



由于截图所限我们发现指令的 key 完全是一模一样的,只不过指令的 value 里面的 line 是不一样的,其实看到这里我们就很清晰的知道合并指令,只需要合并 branchs 和 coveredBranches 即可,难点在于我们怎么判断两个方法是否相等,怎么判断两个指令是否相等(指令有很多种类型),由于 builder 对象存储的指令信息是 map 结果,无需的,无疑给我们的判断指令相等带了了更加复杂的挑战,就像上图所示的。如果是 fieldInsnNode 的指令类型,我们则需要判断操作码,onwe,name,desc 等是否相等,如果是 VarInsNode 的指令,则需要判断操作码和 var 是否相等。

合并思路

  1. 引入历史版本的 class 文件

由于需要解析方法指令覆盖率,我们在原有的基础上需要引入历史版本的 class 文件

@Option(name = "--oldClassfiles", usage = "location of old Java class files", metaVar = "<path>")
List<File> oldclassfiles = new ArrayList<File>();
  1. 过滤历史版本的 class 文件 拿到历史版本的 class 文件后,我们则需要对 classs 文件进行过滤,因为一个工程的 class 文件会特别多,这里我们可以采取使用 executionData 的 classId 进行过滤,没有覆盖率的类没有必要进入后续的计算。
  2. 先对历史版本的 class 文件进行插桩和指令覆盖率计算,然后将结果存储到一个 map 中 java //历史版本指令覆盖率,key为方法签名,value为指令覆盖率 private Map<String, List<Map<AbstractInsnNode, Instruction>>> oldClassInstructionsMap = new HashMap<>(); 需要注意的是我们只需要拿到历史版本的指令覆盖率,不需要进行后续逻辑,所以一旦获取到指令后中断
  3. 进行当前版本正常的覆盖率计算,当发现存在 oldClassInstructionsMap 是进行指令合并 指令合并的思路是保证指令的唯一性,这样做方法变动版本和指令合并就会带来更加方便的操作,这里给每个指令加上一个签名


/**
 * 指令标志
 */
private String instructionSign;

在记录指令信息的时候将指令标识也维护进去,这样就能拿到每个指令的唯一标识了

public Instruction merge(final Instruction other) {
    final Instruction result = new Instruction(this.line);
    result.branches = this.branches;
    result.coveredBranches.or(this.coveredBranches);
    result.coveredBranches.or(other.coveredBranches);
    result.instructionSign= this.instructionSign;
    return result;
}

对于指令合并 jacoco 本身提供了一个这样的方法

最终效果如下,我们第一个版本做了一个全量的覆盖,第二个版本删除了 test3,新增了 test4,但是只副高绿 test2 和 test4 方法,我们合并报告后则会把之前 test1 的报告进行合并,最终得到一个全量的覆盖结果,指令覆盖结果也是没有出错的


到此我们的多版本 class 报告合并就完成了,思路其实不麻烦,重点是了解 jacoco 在生成报告的逻辑,然后进行改造就可以了。

共收到 3 条回复 时间 点赞

我感觉怪怪的,假如 test1 是调用了其他的服务,第一轮测试时 test1 正常,但是第二轮测试时 test1 调用了的服务修改了,然后因为第一轮测了 test1,所以第二轮就不测 test1 了,两个报告一合并,那总体的报告会显示 test1 覆盖了,但是其实第二轮根本没测 test1,如果第二轮测了 test1 就可以发现与其他服务交互时发生的问题

dray #2 · 2023年08月14日 Author

代码覆盖率只展示代码是否会覆盖率,不关心你的业务,只要你方法没有发生改变的情况,它的确是被覆盖过,所以会有覆盖率

你的这个诉求可以通过分析调用链变更来解决。如果调用链上的代码在本次发布中发生了变更,可以清空调用链上方法的覆盖记录

4楼 已删除
5楼 已删除
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册