背景
许多团队通过测试这个过程作为高质量在线代码的最后一个关卡,因此确保测试过程没有问题是非常重要的。
因此,为了提高代码质量,通常有以下方案:
- 通过单测,来cover部分代码逻辑的边界
- 通过代码覆盖率,测试团队的黑盒测试可以覆盖大部分分支
- 通过自动化测试,将部分人工验证场景交给机器验证
当然,即使上述方案已经完成,也不能保证在线没有问题,但在很大程度上减少在线问题的场景,收入相对较大。
本文会针对代码覆盖率这一场景进行分析。在android在使用代码覆盖率时,使用更多jacoco。在android内置了整个工具链jacoco我们可以在不引入其他库的情况下使用它jacoco。
jacoco使用
在Android中使用jacoco代码覆盖率相对简单,可以按照以下步骤打开并显示结果。
开启jacoco插件
1plugins { 2 id 'com.android.application' 3 id 'kotlin-android' 4 id 'jacoco' 5} 6 7jacoco { 8 toolVersion = "0.8.5" 9}
jacoco在androidStudio已内置androidStudio可直接打开jacoco插件。
可以通过jacoco的Extension指定的设置jacoco版本。
打开打包插桩开关
1 buildTypes { 2 release { 3 minifyEnabled false 4 testCoverageEnabled = true 5 proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 6 } 7 debug { 8 testCoverageEnabled = true 9 } 10 }
需要在BuildTypes对于不同的包装类型,通过 testCoverageEnabled 打开代码插桩。
保存代码覆盖结果
编译过程中的代码插桩将在运行过程中实时记录代码运行情况。我们需要在我们定制的时间保留代码覆盖结果。支持相同的文件。
1object JacocoUtil { 2 val ecFile = File(Environment.getExternalStorageDirectory(), "/coverage.ec") 3 fun generateEcFile() { 4 5 val agent = Class.forName("org.jacoco.agent.rt.RT") 6 .getMethod("getAgent") 7 .invoke(null) 8 writeBytes2File(ecFile.absolutePath, agent.javaClass.getMethod("getExecutionData", 9 Boolean::class.javaPrimitiveType).invoke(agent, false) as ByteArray) 10 } 11 }
通过反射获得jacoco的Agent实例,再通过反射获得出来代码覆盖结果的字节流,写入到文件中。
1public byte[] getExecutionData(final boolean reset) { 2 final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); 3 try { 4 final ExecutionDataWriter writer = new ExecutionDataWriter(buffer); 5 data.collect(writer, writer, reset); 6 } catch (final IOException e) {} 7 return buffer.toByteArray(); 8 }
从手机中dump指定覆盖率文件放在指定文件夹中 1adb pull sdcard/coverage.ec xxx/MyTest/app/build/ecf 分析代码覆盖率报告 在gradle在文件中,增加处理任务。
1task jacocoTestReport(type: JacocoReport) { 2 group = "JacocoReport" 3 description = "Generate Jacoco coverage reports after running tests." 4 reports { 5 html.enabled = true 6 } 7 classDirectories.from = files(files(coverageClassDirs).files.collect { 8 fileTree(dir: "$rootDir" it) 9 }) 10 sourceDirectories.from = files(coverageSourceDirs) 11 executionData.from = files("$buildDir/ecf/coverage.ec") 12 13 doFirst { 14 coverageClassDirs.each { filePath -> 15 println("$rootDir" filePath) 16 new File("$rootDir" filePath).eachFileRecurse { file -> 17 if (file.name.contains('$$')) { 18 file.renameTo(file.path.replace('$$', '$')) 19 } 20 } 21 } 22 } 23}
查看报告
在执行中生成报告Task之后,可以看到build多生成一个目录reports文件夹,生成的报告就在里面。
检查相应的覆盖结果如下:
各类覆盖细节:
列表展示 单类覆盖细节:
jacoco原理
看完了jacoco让我们来看看简单的使用。jacoco的实现
jacoco整体是使用maven构建的方式。maven构造的方法是java一般的施工方法。
maven构建相对于android中gradle建筑差异还是比较大的。主要区别在于建筑配置
Gradle基于groovy语言和DSL语法提供了简单、灵活、可读的配置模式
maven使用xml配置文件格式比较繁琐
gradle扩展任何语言的构建,maven不行。
gradle支持增量建设
gradle支持缓存建设
gradle支持守护过程
所以gradle的构建性能要优于maven。
插件开发
阅读代码插桩入口时,会在类上方打一个特定的注释Mojo
1@Mojo(name = "instrument", defaultPhase = LifecyclePhase.PROCESS_CLASSES, threadSafe = true) 2public class InstrumentMojo extends AbstractJacocoMojo {}
Maven plain Old Java Object ,mojo是基于maven注释插件开发。mojo对象是执行目标。
类似于gradle中的gradle-plugin。一个mojo对应一个java类,在整体jar配置的插件将在包编译时执行。
代码插入
jacoco记录代码覆盖率完全取决于原始代码的插桩,需要在原始代码中插入探针,通过记录探针执行来计算代码覆盖。
插入代码有两套实现
动态方式:在Jvm加载class过程中,动态的去修改class
离线模式:在编译class的阶段,对原始class进行修改,生成类已经带有全量的插入代码
动态方式之javaAgent
利用JVM提供的 InstrumentAPI 来更改加载的到JVM的现有字节码。
JavaAgent也有两种方式修改字节码
静态修改:在加载jar包之前修改字节码。静态加载会调用到premain方法。
java -javaagent:agent.jar -jar xxx.jar
比如jacoco就使用的静态修改的方式。可以查看其PreMain的premain方法
1public static void premain(final String options, final Instrumentation inst)
2 throws Exception {
final AgentOptions agentOptions = new AgentOptions(options);
3
4final Agent agent = Agent.getInstance(agentOptions);
5final IRuntime runtime = createRuntime(inst);
6runtime.startup(agent.getData());
7inst.addTransformer(new CoverageTransformer(runtime, agentOptions,
8 IExceptionLogger.SYSTEM_ERR));
9
}
动态修改: 将javaAgent加载到已经运行的JVM中的过程称为动态加载。需要使用Java Attach API。
1public class XXX {
2public static void agentmain(String agentArgs, Instrumentation inst) {
3 // can use inst to add args
4}
}
离线模式
Jacoco的离线模式,是在编译阶段,通过ASM修改原始字节码。
本次会针对jacoco的离线模式进行分析,jacoco在android的使用场景上无法使用动态插入方式。
因为Android运行加载的本质上是Dex文件,已经不是jar包了,指令上和jar包不一样,javaAgent无法识别dex文件结构,所以在Android上只能使用离线模式。
原理
jacoco是如何实现的代码覆盖率的统计的呢?
简单来说关键逻辑可以分为三块:
插桩逻辑:通过ASM做静态代码插桩,提前给期望覆盖的类都添加上代码探针
覆盖率统计:运行时,把对应的类执行过的探针记录存储,存储在内存中,接入方在自己期望的时机进行探针记录的本地存储
报告计算:从本地导出探针记录,进行记录合并,将记录转为期望的结果显示,比如html等。
插桩逻辑
先看一个简单的例子
1class TestForJacocoData {
2 fun testMethod(result: Boolean) {
3 if (result) {
4 System.out.println("分支1")
5 return
6 }
7 System.out.println("分支2")
8 }
9}
经过jacoco插桩之后的代码逻辑为
1public final class TestForJacocoData {
2 private static transient /* synthetic */ boolean[] $jacocoData;
3
4 private static /* synthetic */ boolean[] $jacocoInit() {
5 boolean[] zArr = $jacocoData;
6 if (zArr != null) {
7 return zArr;
8 }
9 boolean[] probes = Offline.getProbes(-1643518976017980468L, "com/xx/zz/TestForJacocoData", 18);
10 $jacocoData = probes;
11 return probes;
12 }
13
14 public TestForJacocoData() {
15 $jacocoInit()[4] = true;
16 }
17
18 public final void testMethod(boolean z) {
19 boolean[] $jacocoInit = $jacocoInit();
20 if (z) {
21 $jacocoInit[0] = true;
22 System.out.println("分支1");
23 $jacocoInit[1] = true;
24 return;
25 }
26 $jacocoInit[2] = true;
27 System.out.println("分支2");
28 $jacocoInit[3] = true;
29 }
30}
通过这个样例代码在jacoco插桩前后的对比,可以发现jacoco的代码插桩会做下面几个操作:
这个成员用来记录当次进程启动之后,该类的代码分支执行情况。当对应的分支执行之后,就会给对应的数组元素赋值为true。
jacocoInit()的作用是在任意方法执行时,从本地已经保存的记录文件中获取当前类的分支执行情况,并给当前类的成员jacocoInit()的作用是在任意方法执行时,从本地已经保存的记录文件中获取当前类的分支执行情况,并给当前类的成员jacocoInit()的作用是在任意方法执行时,从本地已经保存的记录文件中获取当前类的分支执行情况,并给当前类的成员jacocoData赋初始值
对于每一个方法,针对各类型的指令,会插入代码探针。所谓的探针就是$jacocoInit[i] = true;语句,一旦执行到,就把当前位置的探针index设置为true,表示已经执行过了。
探针插入的关键逻辑如下所示:
1 public void insertProbe(final int id) {
2 mv.visitVarInsn(Opcodes.ALOAD, variable);
3 InstrSupport.push(mv, id);
4 mv.visitInsn(Opcodes.ICONST_1);
5 mv.visitInsn(Opcodes.BASTORE);
6 }
7
8 // InstrSupport.push
9 public static void push(final MethodVisitor mv, final int value) {
10 if (value >= -1 && value <= 5) {
11 mv.visitInsn(Opcodes.ICONST_0 + value);
12 } else if (value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE) {
13 mv.visitIntInsn(Opcodes.BIPUSH, value);
14 } else if (value >= Short.MIN_VALUE && value <= Short.MAX_VALUE) {
15 mv.visitIntInsn(Opcodes.SIPUSH, value);
16 } else {
17 mv.visitLdcInsn(Integer.valueOf(value));
18 }
19 }
整体的探针插入的代码逻辑比较简单,主要关注下面两点:
在ASM中如何对数组元素的赋值,需要给操作数栈依次放入数组对象引用, 需要放入index位置以及具体要放入的值,通过BASTORE指令,把栈顶的boolean数组存入数组指定的索引位置。
对于不同大小的Int值处理的指令不一样。
int值-15,使用ICONST_(05)
1 Push the int constant <i> (-1, 0, 1, 2, 3, 4 or 5) onto the operand stack
-1到5,直接将常量push到操作数据栈中。
1* int值在-128~127,使用BIPUSH
2
3 The immediate byte is sign-extended to an int value. That value is pushed onto the operand stack。
在字节码层面上会使用一个byte字节去实现。一个byte有8bit,第一个bit表示符号,后面7位表示具体大小,所以区间范围是-27~(27 -1)
1* int值在-32768~32767,使用SIPUSH
2
3 The immediate unsigned byte1 and byte2 values are assembled into an intermediate short where the value of the short is (byte1 << 8) | byte2. The intermediate value is then sign-extended to an int, and the resulting value is pushed onto the operand stack.
在字节码层面上会使用两个byte字节去实现。第一个bit表示符号,后面15bit表示具体的数值。所以区间范围是
-215~(215 -1)
1* 其他Int区间,使用的Ldc指令
jacoco的插入规则是比较重要,如何能够尽可能的覆盖全每一个分支?可以看下具体的插桩代码。
关键的代码插入逻辑在MethodProbesAdapter中。
1public final class MethodProbesAdapter extends MethodVisitor {
2 @Override
3 public void visitLabel(final Label label) {
4 if (LabelInfo.needsProbe(label)) {
5 probesVisitor.visitProbe(idGenerator.nextId());
6 }
7 }
8 @Override
9 public void visitInsn(final int opcode) {
10 switch (opcode) {
11 case Opcodes.IRETURN:
12 case Opcodes.LRETURN:
13 case Opcodes.FRETURN:
14 case Opcodes.DRETURN:
15 case Opcodes.ARETURN:
16 case Opcodes.RETURN:
17 case Opcodes.ATHROW:
18 probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
19 break;
20 default:
21 probesVisitor.visitInsn(opcode);
22 break;
23 }
24 }
25 @Override
26 public void visitJumpInsn(final int opcode, final Label label) {
27 if (LabelInfo.isMultiTarget(label)) {
28 probesVisitor.visitJumpInsnWithProbe(opcode, label,
29 idGenerator.nextId(), frame(jumpPopCount(opcode)));
30 }
31 ....
32 }
33}
34 @Override
35 public void visitLookupSwitchInsn(final Label dflt, final int[] keys,
36 final Label[] labels) {
37 if (markLabels(dflt, labels)) {
38 probesVisitor.visitLookupSwitchInsnWithProbes(dflt, keys, labels,
39 frame(1));
40 }
41 ...
42 }
43
44 @Override
45 public void visitTableSwitchInsn(final int min, final int max,
46 final Label dflt, final Label... labels) {
47 if (markLabels(dflt, labels)) {
48 probesVisitor.visitTableSwitchInsnWithProbes(min, max, dflt, labels, frame(1));
49 }
50 ...
51 }
在这个MethodProbesAdapter中有以下几个时机会插入探针。
visitLabel:在字节码访问Label时调用
visitInsn:在访问各个指令时会调用
visitJumpInsn:在跳转指令时调用
visitLookupSwitchInsn&visitTableSwitchInsn:在switch-case语句中会调用
详细分析下这几个时机
visitLabel
在visitLabel方法中,会调用visitProbe方法,进行探针插入。在字节码层面上,Label的含义可以先看看ASM文档中的介绍:
1A position in the bytecode of a method. Labels are used for jump, goto, and switch instructions, and for try catch blocks. A label designates the instruction that is just after. Note however that there can be other elements between a label and the instruction it designates (such as other labels, stack map frames, line numbers, etc.).
Label表示字节码在方法中的位置
Label通常使用在跳转、goto、switch指令和try-catch块
Label可以指定下一条需要执行的指令。不过Label和其跳转的指令中间可能存在其他指令
在字节码层面上,指令默认是顺序执行的,假如没有label的支持,就无法实现跳转。
一个Label至少包含一条字节码指令。也就是说一个Label定义之后,后面的指令就是这个Label对象所对应的指令。
在VisitLabel中,并不是所有的Label访问都会插入探针,只有满足下面几个场景才会做探针的插入操作。
1 public static boolean needsProbe(final Label label) {
2 final LabelInfo info = get(label);
3 return info != null && info.successor
4 && (info.multiTarget || info.methodInvocationLine);
5 }
表示指令的连续性,当前Label是相对于上一条指令是否是连续的,如果上一条指令是goto、jump指令,那么当前Label对于上条指令就不是连续的。
在ASM阶段中,通过每一条指令访问时修改successor的值来记录是否是连续的。
表示当前是否这个label是否有多个跳转来源,在一个方法调用中,可能存在多处指令会跳转到当前这个Label。
对于multiTarget的设置可以看下面的代码:
1 public static void setTarget(final Label label) {
2 final LabelInfo info = create(label);
3 if (info.target || info.successor) {
4 info.multiTarget = true;
5 } else {
6 info.target = true;
7 }
8 }
如果这个label首次访问,那么target设置为true。
如果这个label再次访问时,即target为true,此时设置multiTarget为true。
如果当前这个探针的跳转是单来源,在显示结果上,这个Label会直接跟着前面的探针是否执行展示,如果多个地方都可能跳转到当前Label,就意味着其他两个分支到这个分支之间中间会有断层,不是连续的,没有办法通过之前的探针是否执行表明当前Label是否能够在结果显示上
1 @Override
2 public void visitInvokeDynamicInsn(final String name, final String desc,
3 final Handle bsm, final Object... bsmArgs) {
4 successor = true;
5 first = false;
6 markMethodInvocationLine();
7 }
8
9 private void markMethodInvocationLine() {
10 if (lineStart != null) {
11 LabelInfo.setMethodInvocationLine(lineStart);
12 }
13 }
表示当前是调用Label表示调用一个方法。在方法调用场景下,会在方法调用前插入探针。
1 @Override
2 public void visitInsn(final int opcode) {
3 switch (opcode) {
4 case Opcodes.IRETURN:
5 case Opcodes.LRETURN:
6 case Opcodes.FRETURN:
7 case Opcodes.DRETURN:
8 case Opcodes.ARETURN:
9 case Opcodes.RETURN:
10 case Opcodes.ATHROW:
11 probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
12 break;
13 default:
14 probesVisitor.visitInsn(opcode);
15 break;
16 }
17 }
18
19====》
20
21 @Override
22 public void visitInsnWithProbe(final int opcode, final int probeId) {
23 probeInserter.insertProbe(probeId);
24 mv.visitInsn(opcode);
25 }
在识别到return语句时,会在return语句前插入探针。在return语句前加入探针,当这个探针执行了,就表示当前这个分支执行结束了。
主要表示跳转指令。
1Visits a jump instruction. A jump instruction is an instruction that may jump to another instruction.
2Params:
3opcode – the opcode of the type instruction to be visited. This opcode is either IFEQ, IFNE, IFLT, IFGE, IFGT, IFLE, IF_ICMPEQ, IF_ICMPNE, IF_ICMPLT, IF_ICMPGE, IF_ICMPGT, IF_ICMPLE, IF_ACMPEQ, IF_ACMPNE, GOTO, JSR, IFNULL or IFNONNULL.
4label – the operand of the instruction to be visited. This operand is a label that designates the instruction to which the jump instruction may jump.
主要有列的几个指令:IFEQ, IFNE, IFLT, IFGE, IFGT, IFLE, IF_ICMPEQ, IF_ICMPNE, IF_ICMPLT, IF_ICMPGE, IF_ICMPGT, IF_ICMPLE, IF_ACMPEQ, IF_ACMPNE, GOTO, JSR, IFNULL or IFNONNULL。
1 @Override
2 public void visitJumpInsn(final int opcode, final Label label) {
3 if (LabelInfo.isMultiTarget(label)) {
4 probesVisitor.visitJumpInsnWithProbe(opcode, label,
5 idGenerator.nextId(), frame(jumpPopCount(opcode)));
6 }
7 ....
8 }
9}
并不是所有的跳转指令都会插入探针,也会判断跳转的目标label是否有个来源。
1 @Override
2 public void visitJumpInsnWithProbe(final int opcode, final Label label,
3 final int probeId, final IFrame frame) {
4 if (opcode == Opcodes.GOTO) {
5 probeInserter.insertProbe(probeId);
6 mv.visitJumpInsn(Opcodes.GOTO, label);
7 } else {
8 final Label intermediate = new Label();
9 mv.visitJumpInsn(getInverted(opcode), intermediate);
10 probeInserter.insertProbe(probeId);
11 mv.visitJumpInsn(Opcodes.GOTO, label);
12 mv.visitLabel(intermediate);
13 frame.accept(mv);
14 }
15 }
对于goto指令,探针需要添加到跳转指令之前
对于其他跳转指令,比如IF,会做一层转换,把IFEQ转换为IFNE,同时添加GOTO语句
1private int getInverted(final int opcode) {
2 switch (opcode) {
3 case Opcodes.IFEQ:
4 return Opcodes.IFNE;
5 case Opcodes.IFNE:
6 return Opcodes.IFEQ;
7 case Opcodes.IFLT:
8 return Opcodes.IFGE;
9 case Opcodes.IFGE:
10 return Opcodes.IFLT;
11 case Opcodes.IFGT:
12 return Opcodes.IFLE;
13 case Opcodes.IFLE:
14 return Opcodes.IFGT;
15 case Opcodes.IF_ICMPEQ:
16 return Opcodes.IF_ICMPNE;
17 case Opcodes.IF_ICMPNE:
18 return Opcodes.IF_ICMPEQ;
19 case Opcodes.IF_ICMPLT:
20 return Opcodes.IF_ICMPGE;
21 case Opcodes.IF_ICMPGE:
22 return Opcodes.IF_ICMPLT;
23 case Opcodes.IF_ICMPGT:
24 return Opcodes.IF_ICMPLE;
25 case Opcodes.IF_ICMPLE:
26 return Opcodes.IF_ICMPGT;
27 case Opcodes.IF_ACMPEQ:
28 return Opcodes.IF_ACMPNE;
29 case Opcodes.IF_ACMPNE:
30 return Opcodes.IF_ACMPEQ;
31 case Opcodes.IFNULL:
32 return Opcodes.IFNONNULL;
33 case Opcodes.IFNONNULL:
34 return Opcodes.IFNULL;
35 }
36 throw new IllegalArgumentException();
37}
比如下面的例子:
1 class TestForJacocoData {
2 fun testMethod(result: Int) {
3 if (result == 1) {
4 defineA()
5 }
6 }
7 fun defineA() { val a = 1 }
8}
编译后:
1 public final void testMethod(int result) {
2 boolean[] $jacocoInit = $jacocoInit();
3 if (result != 1) {
4 $jacocoInit[1] = true;
5 } else {
6 $jacocoInit[2] = true;
7 defineA();
8 $jacocoInit[3] = true;
9 }
10 $jacocoInit[4] = true;
11 }
比如在一些较为复杂的 if 语句中,会把复杂的判断的语句拆分成单一条件,并进行反转,这样能够保证能够覆盖全所有的分支,并且在反转操作后,可以更好的配合GOTO语句插入探针。
switch-case分支
switch-case对应下面两个字节码:
tableSwitch
查找效率为O(1),通过偏移量就可以找到对应的case。
比如下面的例子:
1 class TestForJacocoData {
2 fun testSwitch() {
3 val value = 1;
4 when(value) {
5 0 -> System.out.println(0)
6 2 -> System.out.println(2)
7 5 -> System.out.println(5)
8 else -> System.out.println(6)
9 }
10 }
11}
编译后的字节码为
1 public final void testSwitch();
2 descriptor: ()V
3 flags: ACC_PUBLIC, ACC_FINAL
4 Code:
5 stack=2, locals=2, args_size=1
6 0: iconst_1
7 1: istore_1
8 2: iload_1
9 3: tableswitch { // 0 to 5
10 0: 40
11 1: 70
12 2: 50
13 3: 70
14 4: 70
15 5: 60
16 default: 70
17 }
18}
可以看到这里使用的tableswitch指令,并且原本我们的case语句只有0、2、5,系统自动给我们补齐成了0,1,2,3,4,5,让其成为了顺序的table,可以直接通过游标直接访问。时间复杂度最终才能O(1),
lookupSwitch
查找效率为O(lgn),通过二分查找寻找对应的value值。
比如下面的例子:
1 class TestForJacocoData {
2 fun testSwitch() {
3 val value = 2;
4 when(value) {
5 0 -> System.out.println(0)
6 1000 -> System.out.println(2)
7 else -> {
8 System.out.println(6)
9 }
10 }
11 }
12}
对应的字节码为:
1 public final void testSwitch();
2 descriptor: ()V
3 flags: ACC_PUBLIC, ACC_FINAL
4 Code:
5 stack=2, locals=2, args_size=1
6 0: iconst_2
7 1: istore_1
8 2: iload_1
9 3: lookupswitch { // 2
10 0: 28
11 1000: 38
12 default: 48
13 }
14 28: getstatic #12 // Field java/lang/System.out:Ljava/io/PrintStream;
可以看到这里使用的lookupswitch指令。字节码层面会根据case语句value值的稀疏,选择tableswitch指令还是lookupswitch指令。
对应的在ASM层看到的调用指令是下面两个:
1 @Override
2 public void visitLookupSwitchInsn(final Label dflt, final int[] keys,
3 final Label[] labels) {
4 if (markLabels(dflt, labels)) {
5 probesVisitor.visitLookupSwitchInsnWithProbes(dflt, keys, labels,
6 frame(1));
7 }
8 ...
9 }
10
11 @Override
12 public void visitTableSwitchInsn(final int min, final int max,
13 final Label dflt, final Label... labels) {
14 if (markLabels(dflt, labels)) {
15 probesVisitor.visitTableSwitchInsnWithProbes(min, max, dflt, labels, frame(1));
16 }
17 ...
18 }
其中,
dflt :默认的处理label
keys: 当前case的value集合
labels:对应的keys的处理label
通过visitLookupSwitchInsnWithProbes和visitTableSwitchInsnWithProbes会给对应的case语句中插入探针。
综上的介绍,我们可以大概整理下jacoco的代码插入逻辑
return语句前、throw语句前会进行探针的插入。
如果label对于上一条指令来说是连续的,并且有多个来源,那么会进行探针插入
如果label对于上一条指令来说是连续的,并且label是一个方法调用,那么会进行探针插入
如果是一个switch语句,对于各个case跳转也会进行探针插入。
运行时处理
在编译期间做了代码插入之后,运行时是如何生效的?以及数据展示是如何来实现的呢?
对于每一个方法调用,在对jacocoData数组元素赋值前,都会先尝试初始化jacocoData数组元素赋值前,都会先尝试初始化jacocoData数组元素赋值前,都会先尝试初始化jacocoData
1 private static boolean[] $jacocoInit() {
2 boolean[] zArr = $jacocoData;
3 if (zArr != null) {
4 return zArr;
5 }
6 boolean[] probes = Offline.getProbes(-1643518976017980468L, "com/kuaikan/zz/TestForJacocoData", 18);
7 $jacocoData = probes;
8 return probes;
9 }
会先尝试从 Offline 中获取离线探针数据,有三个参数:
第一个参数是class的唯一Id,在插桩时,会根据字节码流创建生成。
1private byte[] instrument(final byte[] source) {
2 final long classId = CRC64.classId(source);
}
第二个参数是class全路径名称
第三个参数是当前class共计有多少个探针。
这三个参数在编译后就已经固定了,不会发生改变,所以在插桩后插入的都是具体的值。
然后呢根据传入的参数,创建出来一个ExecutionData对象并返回
1 public ExecutionData get(final Long id, final String name,
2 final int probecount) {
3 ExecutionData entry = entries.get(id);
4 if (entry == null) {
5 entry = new ExecutionData(id.longValue(), name, probecount);
6 entries.put(id, entry);
7 names.add(name);
8 } else {
9 entry.assertCompatibility(id.longValue(), name, probecount);
10 }
11 return entry;
12 }
可以看到ExecutionData做了内存存储,并没有做本地的文件存储。
所以,当我们想要用jacoco来实现多人协作的覆盖率合并时,就需要自己实现当前覆盖率结果的文件存储。
如下代码所示:
1fun generateEcFile() {
2 FileUtils.createFolderIfNotExists(path)
3 FileUtils.createFileIfNotExists(ecFile)
4 val agent = Class.forName("org.jacoco.agent.rt.RT")
5 .getMethod("getAgent")
6 .invoke(null)
7 IOUtils.writeBytes2File(ecFile, agent.javaClass.getMethod("getExecutionData",
8 Boolean::class.javaPrimitiveType).invoke(agent, false) as ByteArray)}
通过反射获取出RT实例,拿出当前所有的已执行的结果,并存储到文件中。
覆盖结果
在不看代码的情况下,我们可以先推理下,按照前面插桩和收集的数据,如何展现出实际覆盖率的结果。
首先需要把所有上传的数据进行合并
DumpTask + MergeTask
1* DumpTask:jacoco内置dumpTask,主要的作用是从远端下载收集到的测试覆盖数据。 2 3* MergeTask:因为每次运行的测试覆盖数据都是单独的文件数据,所以需要有一个专门的Task,把众多的文件的测试覆盖数据合并成单个文件。 合成的逻辑比较简单:
1 private void load(final ExecFileLoader loader) {
2 final Iterator<?> resourceIterator = files.iterator();
3 while (resourceIterator.hasNext()) {
4 final Resource resource = (Resource) resourceIterator.next();
5 resourceStream = resource.getInputStream();
6 loader.load(resourceStream);
7 }
8 }
9
10 public void load(final InputStream stream) throws IOException {
11 final ExecutionDataReader reader = new ExecutionDataReader(
12 new BufferedInputStream(stream));
13 reader.setExecutionDataVisitor(executionData);
14 reader.setSessionInfoVisitor(sessionInfos);
15 reader.read();
16 }
17
18 ====》
19 public void visitClassExecution(final ExecutionData data) {
20 put(data);
21 }
22
23 ====》
24 public void put(final ExecutionData data) throws IllegalStateException {
25 final Long id = Long.valueOf(data.getId());
26 final ExecutionData entry = entries.get(id);
27 if (entry == null) {
28 entries.put(id, data);
29 names.add(data.getName());
30 } else {
31 entry.merge(data);
32 }
33 }
即遍历每一个文件,根据文件内容,调用load,最终merge到同一个hashMap中。
需要把方法覆盖率和对应的className和源代码文件做关联
这个流程是比较重要并且复杂的流程。因为运行时收集到的ExecutionData数据还比较少,仅有唯一ID、名称、和覆盖率结果数组,无法直接应用于结果展示。
1 public final class ExecutionData {
2 private final long id;
3 private final String name;
4 private final boolean[] probes;
5 }
因此,需要有分析的Task,将这个结果和源文件进行关联。分析单个方法主要使用InstructionsBuilder。
1 InstructionsBuilder(final boolean[] probes) {
2 this.probes = probes;
3 this.currentLine = ISourceNode.UNKNOWN_LINE;
4 this.currentInsn = null;
5 this.instructions = new HashMap<AbstractInsnNode, Instruction>();
6 this.currentLabel = new ArrayList<Label>(2);
7 this.jumps = new ArrayList<Jump>();
8 }
根据MethodVisitor的访问顺序,重建探针对应的覆盖的行号、指令等。
1 @Override
2 public void visitLabel(final Label label) {
3 builder.addLabel(label);
4 }
5 @Override
6 public void visitLineNumber(final int line, final Label start) {
7 builder.setCurrentLine(line);
8 }
9 @Override
10 public void visitInsn(final int opcode) {
11 builder.addInstruction(currentNode);
12 }
13 @Override
14 public void visitIntInsn(final int opcode, final int operand) {
15 builder.addInstruction(currentNode);
16 }
17
18 void addProbe(final int probeId, final int branch) {
19 final boolean executed = probes != null && probes[probeId];
20 currentInsn.addBranch(executed, branch);
21 }
最关键的还是在插桩时标记需要插入探针的地方,都访问一次addProbe(final int probeId, final int branch)方法,这样可以重新从探针数组中获取当前指令是否覆盖到。
根据展示的样式,转化为html等其他格式。
将merge的结果,展示成对应的文件样式,比如html等。
关键逻辑是是否覆盖的展示逻辑,如下所示:
1 HTMLElement highlight(final HTMLElement pre, final ILine line,
2 final int lineNr) throws IOException {
3 final String style;
4 switch (line.getStatus()) {
5 case ICounter.NOT_COVERED:
6 style = Styles.NOT_COVERED;
7 break;
8 case ICounter.FULLY_COVERED:
9 style = Styles.FULLY_COVERED;
10 break;
11 case ICounter.PARTLY_COVERED:
12 style = Styles.PARTLY_COVERED;
13 break;
14 default:
15 ret
16 }
这里的line就是我们前面通过InstructionsBuilder分析出来的结果,根据不同的结果,展示不同的色值。
在现有的jacoco的能力基础上快速实现增量,目前来说,有比较多的方式
在插桩过程中做增量,在非增量文件中,不进行插桩
在结果merge的过程中,进行增量逻辑处理,过滤扫描出来的增量代码段。
在生成的结果文件中,过滤出来增量结果并展示
第一种方案较优,仅对需要增量的代码进行插桩,可以降低整体的编译耗时。
第二种、第三种方案较简单,仅需要针对结果集层面做处理,不需要care较为复杂的插桩逻辑。
增量代码获取
比较通用的方案是通过git diff可以计算两个分支间的增量代码。不过这种方案有缺陷
在某些场景下,diff过大的情况下,查询不出来结果。在一些改动较大的业务下,会导致整个增量方案失效。
基于git diff实现的,需要自己实现一套解析器,针对git diff的结果,解析出来增量数据集,较为复杂。
因此,我们基于目前jacoco的代码插桩,在全量时,直接利用jacoco对于每个类、各个方法的插桩记录做了记录,然后和分支关联,并且上传作为备份。在每一次代码编译时,拉下来对应分支上传备份文件,计算增量。然后在插桩过程中,过滤掉对应非增量的class和method来实现增量。