从ASM看jacoco运行原理
创始人
2024-03-17 11:02:26
0

前言

我们在开发中如何保证代码质量,我的回答是做充分的代码测试。Jacoco的出发点是为基于JVM运行的代码提供代码覆盖率统计,期望提供轻量级的、可伸缩的、文档较全的库文件来集成各类构建和开发工具。

ASM介绍

ASM 是一个通用的 Java 字节码操作和分析框架。 它可以用于修改现有类或直接以二进制形式动态生成类。 ASM 提供了一些常见的字节码转换和分析算法,可以从中构建自定义复杂转换和代码分析工具。 ASM 提供与其他 Java 字节码框架类似的功能,但专注于性能。 因为它的设计和实现尽可能小而且快,所以它非常适合在动态系统中使用(但当然也可以以静态方式使用,例如在编译器中)。

ASM增强流程

step1:需要创建一个 ClassReader 对象,将 .class 文件的内容读入到一个字节数组中
step2:需要事件过滤器 ClassVisitor。在调用 ClassVisitor 的某些方法时会产生一个新的 XXXVisitor 对象
step3:然后需要一个 ClassWriter 的对象将操作之后的字节码的字节数组回写
step4:当我们需要修改对应的内容时只要实现自己的 XXXVisitor 并返回就可以了

image-20221203211557147

ASM Bytecode Outline 插件

asm是一款偏底层的字节码增强工具,所以在使用的时候需要对字节码指令有一定的了解。我们可以借助asm bytecode outline插件可以帮我们将java文件编译成字节码文件和使用asm指令生成java代码(IDEA2022版本暂不支持)。

我们写一个简单的java实例:

public class Test {private int num1 = 1;public int add(int a,int b) {return a+b;}
}

使用asm bytecode outline插件翻译当前文件

image-20221203210938667

我们可以看到当前类对应的字节码文件:

image-20221203211119108

同时也可以看到如何使用ASM工具生成当前类文件,这样我们在用ASM动态生成或者修改类文件的时候就可以以此作为参考。

image-20221203211152139

jacoco源码

jacoco的运行原理也很简单,就是在我们的目标服务代码进行插装,记录代码的执行位置,这样我们能很清晰的看到代码的执行位置,也可以生成覆盖度报告。这样说你可能没有什么概览,我们使用反编译查看被插装的代码你就能大概知道jacoco究竟是如何进行覆盖度收集的了。

在这里插入图片描述

下载jacoco的源码一看,有这么多子moudle,一下子是不是有点懵,不知从何看起呢?其实jacoco实现覆盖度收集的方式有很多种:agent、ant、maven、cli等,再回过头来看代码结构是不是很清晰了很多。其中ant和agent是不会对打包好的jar或war代码产生影响,是在运行时去改变字节码,而maven是在编译的时候就去生成字节码,所以使用maven的方式不适合在生产上使用。

image-20221203211427441

core模块

core作为jacoco的核心模块,完成代码插装、收集、合并等操作。

ExecutionDataStore执行数据集

ExecutionDataStore主要是存储对应的收集数据,由于存储在内存中,所以在停机之前如果不进行收集,则测试数据会被丢失。entries是一个Map,id是对应class的唯一标识,ExecutionData是对应类的覆盖数据。

image-20221204141644244

ExecutionData数据结构如下:

public final class ExecutionData {private final long id;private final String name;private final boolean[] probes;public ExecutionData(final long id, final String name,final boolean[] probes) {this.id = id;this.name = name;this.probes = probes;}public void reset() {Arrays.fill(probes, false);}public boolean hasHits() {for (final boolean p : probes) {if (p) {return true;}}return false;}public void merge(final ExecutionData other, final boolean flag) {assertCompatibility(other.getId(), other.getName(),other.getProbes().length);final boolean[] otherData = other.getProbes();for (int i = 0; i < probes.length; i++) {if (otherData[i]) {probes[i] = flag;}}}}
  • id:对应class唯一标识。

  • name:对应class的name。

  • probes:打桩数据,是一个boolean的数组,初始化时为全为false,如果有相应位置的代码被执行,则对应位置的数据变为true。

  • reset():清空测试覆盖数据。

  • hasHits():检查是否有任何探针被击中。

  • merge():将同一个类的两次收集数据合并,合并的逻辑就是如果有对应下标为true,则合并后的数据就为flag,flag可以为true也可以为false。

Instrumenter插装

Instrumenter是负责处理插装核心逻辑,它接受的是源class字节码数据,返回含插装数据的字节数组。

private byte[] instrument(final byte[] source) {final long classId = CRC64.classId(source);final ClassReader reader = InstrSupport.classReaderFor(source);final ClassWriter writer = new ClassWriter(reader, 0) {@Overrideprotected String getCommonSuperClass(final String type1,final String type2) {throw new IllegalStateException();}};final IProbeArrayStrategy strategy = ProbeArrayStrategyFactory.createFor(classId, reader, accessorGenerator);final int version = InstrSupport.getMajorVersion(reader);final ClassVisitor visitor = new ClassProbesAdapter(new ClassInstrumenter(strategy, writer),InstrSupport.needsFrames(version));reader.accept(visitor, ClassReader.EXPAND_FRAMES);return writer.toByteArray();}

我们再回想一下前面使用反编译的类信息,多了哪些信息呢?分别是成员变量jacocoData, jacocoInit方法,以及方法中为jacocoData数组赋值,那我们就看下是在什么时机去完成上面三个操作的。

jacocoData数组赋值

org.jacoco.core.internal.instr.ProbeInserter#insertProbe这个方法就是给数组赋值的,并且值为true。java中方法被调用,对应一次入栈与出栈操作,那么对应的指令也需要进行入栈,所以通过相应位置加入额外的入栈指令,达到字节码增强的目的。

public void insertProbe(final int id) {mv.visitVarInsn(Opcodes.ALOAD, variable);// Stack[0]: [ZInstrSupport.push(mv, id);// Stack[1]: I// Stack[0]: [Zmv.visitInsn(Opcodes.ICONST_1);// Stack[2]: I// Stack[1]: I// Stack[0]: [Zmv.visitInsn(Opcodes.BASTORE);}

接下来我们看下insertProbe方法在哪些地方被调用:MethodInstrumenter。

image-20221204150950244

这里还需要提一下ASM 的Label,是实现条件语句跳转的。换句话说,我们需要知道在方法的哪些位置插装。

public void test(){// ①int a = 1;// ②int b = 2;// ③if(a > 2){// ④}else{// ⑤}
}

我们看下上面的代码,我们并不是需要在每一个位置都进行插装,比如在①插装②、③就没必须进行插装了,因为②、③是一定会执行到的;所以我们需要插装的位置就是①、④、⑤。

jacocoData和jacocoInit

org.jacoco.core.internal.flow.ClassProbesAdapter#visitEnd,在类被加载完毕的时候执行visitEnd,最后调用org.jacoco.core.internal.instr.InterfaceFieldProbeArrayStrategy#addMembers,这里我们已经清楚jacocoData和jacocoInit的生成时机了,生成的逻辑也是使用ASM增强。

image-20221204152042226

agent插装调度

agent的执行入口是premain方法,如何开发和调试agent这里就不多说了,可以自行百度。我们看看jacoco agent方式是如何进行插装的。

org.jacoco.agent.rt.internal.PreMain#premain

image-20221203220900734

由此可以看出,jacoco是在org.jacoco.agent.rt.internal.CoverageTransformer中完成插装任务的。

image-20221204152431598

这里先调用了dump方法是为了将源字节码信息存储下来,方便后面清除插装数据;然后我们在transform方法中看到了调用Instrumenter,这里是不是又很熟悉了,开始调用core模块的插装接口。

maven插装调度

maven的插装入口是InstrumentMojo,这里需要有maven插件开发基础,不然的话可能不太理解运行原理。

@Mojo(name = "instrument", defaultPhase = LifecyclePhase.PROCESS_CLASSES, threadSafe = true)
public class InstrumentMojo extends AbstractJacocoMojo {@Parameterprivate List includes;@Parameterprivate List excludes;@Overridepublic void executeMojo()throws MojoExecutionException, MojoFailureException {final File originalClassesDir = new File(getProject().getBuild().getDirectory(),"generated-classes/jacoco");originalClassesDir.mkdirs();final File classesDir = new File(getProject().getBuild().getOutputDirectory());if (!classesDir.exists()) {getLog().info("Skipping JaCoCo execution due to missing classes directory:"+ classesDir);return;}final List fileNames;try {fileNames = new FileFilter(includes, excludes).getFileNames(classesDir);} catch (final IOException e1) {throw new MojoExecutionException("Unable to get list of files to instrument.", e1);}final Instrumenter instrumenter = new Instrumenter(new OfflineInstrumentationAccessGenerator());for (final String fileName : fileNames) {if (fileName.endsWith(".class")) {final File source = new File(classesDir, fileName);final File backup = new File(originalClassesDir, fileName);InputStream input = null;OutputStream output = null;try {FileUtils.copyFile(source, backup);input = new FileInputStream(backup);output = new FileOutputStream(source);instrumenter.instrument(input, output, source.getPath());} catch (final IOException e2) {throw new MojoExecutionException("Unable to instrument file.", e2);} finally {IOUtil.close(input);IOUtil.close(output);}}}}
}

从上面代码可以看到调用Instrumenter的地方,与探针插装不同的是maven方式拿到插装的字节数据是写入到文件中,其他也就是编译后的产物。

其他指令

除了插状之外,jacoco还提供了dump、merge、restore、report等指令,基于上面的基础,不难分析出它的工作原理。

image-20221204154210026

相关内容

热门资讯

【2025年度消费避坑图鉴】保... 央广网北京12月26日消息(记者白德彰)在本期《年度消费避坑图鉴》中,我们聚焦保险领域。尽管投诉占比...
动态要闻 | 珠海市律师协会涉... 【珠海律师、珠海法律咨询、珠海律师事务所、京师律所、京师珠海律所】 12月24日,珠海市律师协会涉外...
律师最难跟客户说出的一句话:赢... 律师代理案件,最开心的时候莫过于通过律师与客户的共同配合,不但赢得官司,客户还成功拿到钱。 对于有些...
原创 欧... 12月23日,据观察者网报道,俄罗斯副外长谢尔盖表示,虽然俄罗斯跟北约存在冲突风险但他们愿意以法律形...
东京通胀降温,加息难停?日元陷... 日本总务省公布的数据显示,东京12月核心CPI同比上涨2.3%,超预期降温。 周五,日本股市开盘走高...
专业文章丨跨境模具纠纷高效和解... 【珠海律师、珠海法律咨询、珠海律师事务所、京师律所、京师珠海律所】 (本文转载自北京市京师律师事务所...
全总等三部门联合发布2025年... 新华社北京12月25日电(记者樊曦、冯家顺)记者12月25日从全国总工会了解到,全国总工会与最高人民...
对“问题法规”要及时纠正 从事网约车经营要求车辆购置总价不得低于12万元、残疾人机动轮椅车登记需有本市常住户籍……日前提请全国...
专业文章丨相对不起诉:一起交通... 【珠海律师、珠海法律咨询、珠海律师事务所、京师律所、京师珠海律所】 (本文转载自北京市京师郑州律师事...