写给android同学的代码覆盖率讲解「终于解决」

写给android同学的代码覆盖率讲解「终于解决」背景 很多团队都是通过测试这一流程来作为代码高质量上线的最后一道关卡,所以保证测试这一流程不出问题是非常重要的。 因此为了提高代码质量,通常有以下几种方案: 通过单测,来cover部分代码逻辑的边界

背景

很多团队都是通过测试这一流程来作为代码高质量上线的最后一道关卡,所以保证测试这一流程不出问题是非常重要的。

因此为了提高代码质量,通常有以下几种方案:

  • 通过单测,来cover部分代码逻辑的边界
  • 通过代码覆盖率,让测试团队的黑盒测试尽可能的覆盖完大部分分支
  • 通过自动化测试,把部分人工验证的场景交给机器验证

当然,就算把上边的几种方案都做了,也不能保证线上不出问题,不过较大程度上的降低线上出现问题的场景,收益还是比较大的。

本文会针对代码覆盖率这一场景进行分析。在android的代码覆盖率使用中,使用比较多的还是jacoco。在android的整个工具链中,也已经内置了jacoco了。我们可以不需要引入其他库也能够使用jacoco。

jacoco使用

在Android中使用jacoco进行代码覆盖率比较简单,按照下面几个步骤即可开启并展示结果。

开启jacoco插件

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.5"
}

jacoco在androidStudio已经内置了。 在androidStudio中可以直接开启jacoco插件。

可以通过jacoco的Extension设置指定的的jacoco版本。

开启打包插桩开关

    buildTypes {
        release {
            minifyEnabled false
            testCoverageEnabled = true 
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
        debug {
            testCoverageEnabled = true
        }
    }

需要在BuildTypes中,针对不同的打包类型,通过testCoverageEnabled开启代码插桩。

保存代码覆盖结果

通过编译期间的代码插桩,运行时会实时记录代码运行情况。 需要我们在我们自定义的时机保留代码覆盖结果。 支持同一个文件追加写入。

object JacocoUtil {
    val ecFile = File(Environment.getExternalStorageDirectory(),  "/coverage.ec")
    fun generateEcFile() {

        val agent = Class.forName("org.jacoco.agent.rt.RT")
            .getMethod("getAgent")
            .invoke(null)
        writeBytes2File(ecFile.absolutePath, agent.javaClass.getMethod("getExecutionData",
            Boolean::class.javaPrimitiveType).invoke(agent, false) as ByteArray)
    }
 }

通过反射获取jacoco的Agent实例,再通过反射获取出来代码覆盖结果的字节流,写入到文件中。

public byte[] getExecutionData(final boolean reset) {
		final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
		try {
			final ExecutionDataWriter writer = new ExecutionDataWriter(buffer);
			data.collect(writer, writer, reset);
		} catch (final IOException e) {}
		return buffer.toByteArray();
	}

从手机中dump指定覆盖率文件,放在指定文件夹

adb pull sdcard/coverage.ec xxx/MyTest/app/build/ecf

通过保留文件,解析代码覆盖率报告

在gradle文件中,增加处理任务。

task jacocoTestReport(type: JacocoReport) {
    group = "JacocoReport"
    description = "Generate Jacoco coverage reports after running tests."
    reports {
        html.enabled = true
    }
    classDirectories.from = files(files(coverageClassDirs).files.collect {
        fileTree(dir: "$rootDir" + it)
    })
    sourceDirectories.from = files(coverageSourceDirs)
    executionData.from = files("$buildDir/ecf/coverage.ec")

    doFirst {
        coverageClassDirs.each { filePath ->
            println("$rootDir" + filePath)
            new File("$rootDir" + filePath).eachFileRecurse { file ->
                if (file.name.contains('$$')) {
                    file.renameTo(file.path.replace('$$', '$'))
                }
            }
        }
    }
}

查看报告

在执行完生成报告Task之后,可以看到在build目录下会多生成一个reports文件夹,生成的报告就在里面。

写给android同学的代码覆盖率讲解「终于解决」

查看对应的覆盖结果如下:

各个类覆盖详情:

列表展示

单个类的覆盖详情:

写给android同学的代码覆盖率讲解「终于解决」

jacoco原理

看完了jacoco的简单使用,我们再来看看jacoco的实现

构建方式

jacoco整体是使用maven的方式去构建的。 maven构建的方式是java中通用的构建方式。

maven构建相对于android中gradle构建区别还是比较大的。最主要的不同点是在构建配置

  • 配置上:

    • Gradle基于groovy语言和DSL语法提供了简明、灵活、可读性强的配置方式

    • maven使用xml文件格式进行配置,较为繁琐

  • 可扩展性:

    • gradle扩展任何语言的构建,maven不行。
  • 构建性能:

    • gradle支持增量构建

    • gradle支持构建缓存

    • gradle支持守护进程

      所以gradle的构建性能要优于maven。

插件开发

在阅读代码插桩入口时,发现类上边都会打上一个特定的注解Mojo

@Mojo(name = "instrument", defaultPhase = LifecyclePhase.PROCESS_CLASSES, threadSafe = true)
public class InstrumentMojo extends AbstractJacocoMojo {}

Maven plain Old Java Object,mojo是基于maven的插件开发的注解。每一个mojo对象都是一个执行目标。

类似于gradle中的gradle-plugin。一个mojo就对应着一个java类,在整体jar包编译时,会执行配置的插件。

有兴趣的可以查看maven的官方文档: 传送门

代码插入方式

jacoco记录代码覆盖率完全依赖于对原始代码的插桩,需要在原始代码中插入探针,通过运行时记录探针执行来计算代码覆盖来。

插入代码有两套实现

  • 动态方式:在Jvm加载class过程中,动态的去修改class
  • 离线模式:在编译class的阶段,对原始class进行修改,生成类已经带有全量的插入代码

动态方式之javaAgent

利用JVM提供的 InstrumentAPI 来更改加载的到JVM的现有字节码。

JavaAgent也有两种方式修改字节码

  • 静态修改:在加载jar包之前修改字节码。静态加载会调用到premain方法。
java -javaagent:agent.jar -jar xxx.jar
比如jacoco就使用的静态修改的方式。可以查看其PreMain的premain方法
	public static void premain(final String options, final Instrumentation inst)
			throws Exception {

		final AgentOptions agentOptions = new AgentOptions(options);

		final Agent agent = Agent.getInstance(agentOptions);
		final IRuntime runtime = createRuntime(inst);
		runtime.startup(agent.getData());
		inst.addTransformer(new CoverageTransformer(runtime, agentOptions,
				IExceptionLogger.SYSTEM_ERR));
	}
  • 动态修改: 将javaAgent加载到已经运行的JVM中的过程称为动态加载。需要使用Java Attach API。

    public class XXX {
        public static void agentmain(String agentArgs, Instrumentation inst) {
           	// can use inst to add args
        }
    }
    

离线模式

Jacoco的离线模式,是在编译阶段,通过ASM修改原始字节码。

本次会针对jacoco的离线模式进行分析,jacoco在android的使用场景上无法使用动态插入方式。

因为Android运行加载的本质上是Dex文件,已经不是jar包了,指令上和jar包不一样,javaAgent无法识别dex文件结构,所以在Android上只能使用离线模式。

原理

jacoco是如何实现的代码覆盖率的统计的呢?

简单来说关键逻辑可以分为三块:

  • 插桩逻辑: 通过ASM做静态代码插桩,提前给期望覆盖的类都添加上代码探针
  • 覆盖率统计:运行时,把对应的类执行过的探针记录存储,存储在内存中,接入方在自己期望的时机进行探针记录的本地存储
  • 报告计算:从本地导出探针记录,进行记录合并,将记录转为期望的结果显示,比如html等。

插桩逻辑

先看一个简单的例子

class TestForJacocoData {
    fun testMethod(result: Boolean) {
        if (result) {
            System.out.println("分支1")
            return
        }
        System.out.println("分支2")
    }
}

经过jacoco插桩之后的代码逻辑为

public final class TestForJacocoData {
    private static transient /* synthetic */ boolean[] $jacocoData;

    private static /* synthetic */ boolean[] $jacocoInit() {
        boolean[] zArr = $jacocoData;
        if (zArr != null) {
            return zArr;
        }
        boolean[] probes = Offline.getProbes(-1643518976017980468L, "com/xx/zz/TestForJacocoData", 18);
        $jacocoData = probes;
        return probes;
    }

    public TestForJacocoData() {
        $jacocoInit()[4] = true;
    }

    public final void testMethod(boolean z) {
        boolean[] $jacocoInit = $jacocoInit();
        if (z) {
            $jacocoInit[0] = true;
            System.out.println("分支1");
            $jacocoInit[1] = true;
            return;
        }
        $jacocoInit[2] = true;
        System.out.println("分支2");
        $jacocoInit[3] = true;
    }
}

通过这个样例代码在jacoco插桩前后的对比,可以发现jacoco的代码插桩会做下面几个操作:

  • 每一个类都会插入一个名为$jacocoData的成员,jacocoData的类型为boolean数组

    这个成员用来记录当次进程启动之后,该类的代码分支执行情况。当对应的分支执行之后,就会给对应的数组元素赋值为true。

  • 每一个类都会插入$jacocoInit()的初始化方法


    j a c o c o I n i t ( ) 的作用是在任意方法执行时,从本地已经保存的记录文件中获取当前类的分支执行情况,并给当前类的成员 jacocoInit()的作用是在任意方法执行时,从本地已经保存的记录文件中获取当前类的分支执行情况,并给当前类的成员
    jacocoData赋初始值

  • 对于每一个方法,针对各类型的指令,会插入代码探针。所谓的探针就是$jacocoInit[i] = true;语句,一旦执行到,就把当前位置的探针index设置为true,表示已经执行过了。

探针插入的关键逻辑如下所示:

	public void insertProbe(final int id) {
		mv.visitVarInsn(Opcodes.ALOAD, variable);
		InstrSupport.push(mv, id);
		mv.visitInsn(Opcodes.ICONST_1);
		mv.visitInsn(Opcodes.BASTORE);
	}

  // InstrSupport.push
	public static void push(final MethodVisitor mv, final int value) {
		if (value >= -1 && value <= 5) {
			mv.visitInsn(Opcodes.ICONST_0 + value);
		} else if (value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE) {
			mv.visitIntInsn(Opcodes.BIPUSH, value);
		} else if (value >= Short.MIN_VALUE && value <= Short.MAX_VALUE) {
			mv.visitIntInsn(Opcodes.SIPUSH, value);
		} else {
			mv.visitLdcInsn(Integer.valueOf(value));
		}
	}

整体的探针插入的代码逻辑比较简单,主要关注下面两点:

  • 在ASM中如何对数组元素的赋值,需要给操作数栈依次放入数组对象引用, 需要放入index位置以及具体要放入的值,通过BASTORE指令,把栈顶的boolean数组存入数组指定的索引位置。

  • 对于不同大小的Int值处理的指令不一样。

    • int值-15,使用ICONST_(05)

      jvm的解释:

      Push the int constant <i> (-1, 0, 1, 2, 3, 4 or 5) onto the operand stack 
      

      -1到5,直接将常量push到操作数据栈中。

    • int值在-128~127,使用BIPUSH

      The immediate byte is sign-extended to an int value. That value is pushed onto the operand stack。 
      

      在字节码层面上会使用一个byte字节去实现。一个byte有8bit,第一个bit表示符号,后面7位表示具体大小,所以区间范围是-2^7~(2^7 -1)

    • int值在-32768~32767,使用SIPUSH

      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表示具体的数值。所以区间范围是

      -2^15~(2^15 -1)

    • 其他Int区间,使用的Ldc指令

插入规则

jacoco的插入规则是比较重要,如何能够尽可能的覆盖全每一个分支?可以看下具体的插桩代码。

关键的代码插入逻辑在MethodProbesAdapter中。

public final class MethodProbesAdapter extends MethodVisitor {
	@Override
	public void visitLabel(final Label label) {
		if (LabelInfo.needsProbe(label)) {
			probesVisitor.visitProbe(idGenerator.nextId());
		}
	}
  @Override
	public void visitInsn(final int opcode) {
		switch (opcode) {
		case Opcodes.IRETURN:
		case Opcodes.LRETURN:
		case Opcodes.FRETURN:
		case Opcodes.DRETURN:
		case Opcodes.ARETURN:
		case Opcodes.RETURN:
		case Opcodes.ATHROW:
			probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
			break;
		default:
			probesVisitor.visitInsn(opcode);
			break;
		}
	}
  @Override
	public void visitJumpInsn(final int opcode, final Label label) {
		if (LabelInfo.isMultiTarget(label)) {
			probesVisitor.visitJumpInsnWithProbe(opcode, label,
					idGenerator.nextId(), frame(jumpPopCount(opcode)));
		}
    ....
	}
}
	@Override
	public void visitLookupSwitchInsn(final Label dflt, final int[] keys, final Label[] labels) {
		if (markLabels(dflt, labels)) {
			probesVisitor.visitLookupSwitchInsnWithProbes(dflt, keys, labels,
					frame(1));
		}
    ...
	}

	@Override
	public void visitTableSwitchInsn(final int min, final int max, final Label dflt, final Label... labels) {
		if (markLabels(dflt, labels)) {
			probesVisitor.visitTableSwitchInsnWithProbes(min, max, dflt, labels,	frame(1));
		}
    ...
	}

在这个MethodProbesAdapter中有以下几个时机会插入探针。

  • visitLabel: 在字节码访问Label时调用
  • visitInsn: 在访问各个指令时会调用
  • visitJumpInsn:在跳转指令时调用
  • visitLookupSwitchInsn&visitTableSwitchInsn:在switch-case语句中会调用

详细分析下这几个时机

visitLabel

在visitLabel方法中,会调用visitProbe方法,进行探针插入。在字节码层面上,Label的含义可以先看看ASM文档中的介绍:

A 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访问都会插入探针,只有满足下面几个场景才会做探针的插入操作。

	public static boolean needsProbe(final Label label) {
		final LabelInfo info = get(label);
		return info != null && info.successor
				&& (info.multiTarget || info.methodInvocationLine);
	}
successor

表示指令的连续性,当前Label是相对于上一条指令是否是连续的,如果上一条指令是goto、jump指令,那么当前Label对于上条指令就不是连续的。

在ASM阶段中,通过每一条指令访问时修改successor的值来记录是否是连续的。

multiTarget

表示当前是否这个label是否有多个跳转来源,在一个方法调用中,可能存在多处指令会跳转到当前这个Label。

对于multiTarget的设置可以看下面的代码:

	public static void setTarget(final Label label) {
		final LabelInfo info = create(label);
		if (info.target || info.successor) {
			info.multiTarget = true;
		} else {
			info.target = true;
		}
	}
  • 如果这个label首次访问,那么target设置为true。

  • 如果这个label再次访问时,即target为true,此时设置multiTarget为true。

如果当前这个探针的跳转是单来源,在显示结果上,这个Label会直接跟着前面的探针是否执行展示,如果多个地方都可能跳转到当前Label,就意味着其他两个分支到这个分支之间中间会有断层,不是连续的,没有办法通过之前的探针是否执行表明当前Label是否能够在结果显示上

方法调用起始行数:methodInvocationLine
	@Override
	public void visitInvokeDynamicInsn(final String name, final String desc, final Handle bsm, final Object... bsmArgs) {
		successor = true;
		first = false;
		markMethodInvocationLine();
	}

	private void markMethodInvocationLine() {
		if (lineStart != null) {
			LabelInfo.setMethodInvocationLine(lineStart);
		}
	}

表示当前是调用Label表示调用一个方法。在方法调用场景下,会在方法调用前插入探针。

visitInsn

  @Override
	public void visitInsn(final int opcode) {
		switch (opcode) {
		case Opcodes.IRETURN:
		case Opcodes.LRETURN:
		case Opcodes.FRETURN:
		case Opcodes.DRETURN:
		case Opcodes.ARETURN:
		case Opcodes.RETURN:
		case Opcodes.ATHROW:
			probesVisitor.visitInsnWithProbe(opcode, idGenerator.nextId());
			break;
		default:
			probesVisitor.visitInsn(opcode);
			break;
		}
	}

====》
  
  @Override
	public void visitInsnWithProbe(final int opcode, final int probeId) {
		probeInserter.insertProbe(probeId);
		mv.visitInsn(opcode);
	}

在识别到return语句时,会在return语句前插入探针。在return语句前加入探针,当这个探针执行了,就表示当前这个分支执行结束了。

visitJumpInsn

主要表示跳转指令。

Visits a jump instruction. A jump instruction is an instruction that may jump to another instruction.
Params:
opcode – 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.
label – 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。

  @Override
	public void visitJumpInsn(final int opcode, final Label label) {
		if (LabelInfo.isMultiTarget(label)) {
			probesVisitor.visitJumpInsnWithProbe(opcode, label,
					idGenerator.nextId(), frame(jumpPopCount(opcode)));
		}
    ....
	}
}

并不是所有的跳转指令都会插入探针,也会判断跳转的目标label是否有个来源。

	@Override
	public void visitJumpInsnWithProbe(final int opcode, final Label label, final int probeId, final IFrame frame) {
		if (opcode == Opcodes.GOTO) {
			probeInserter.insertProbe(probeId);
			mv.visitJumpInsn(Opcodes.GOTO, label);
		} else {
			final Label intermediate = new Label();
			mv.visitJumpInsn(getInverted(opcode), intermediate);
			probeInserter.insertProbe(probeId);
			mv.visitJumpInsn(Opcodes.GOTO, label);
			mv.visitLabel(intermediate);
			frame.accept(mv);
		}
	}
  • 对于goto指令,探针需要添加到跳转指令之前

  • 对于其他跳转指令,比如IF,会做一层转换,把IFEQ转换为IFNE,同时添加GOTO语句

    private int getInverted(final int opcode) {
    		switch (opcode) {
    		case Opcodes.IFEQ:
    			return Opcodes.IFNE;
    		case Opcodes.IFNE:
    			return Opcodes.IFEQ;
    		case Opcodes.IFLT:
    			return Opcodes.IFGE;
    		case Opcodes.IFGE:
    			return Opcodes.IFLT;
    		case Opcodes.IFGT:
    			return Opcodes.IFLE;
    		case Opcodes.IFLE:
    			return Opcodes.IFGT;
    		case Opcodes.IF_ICMPEQ:
    			return Opcodes.IF_ICMPNE;
    		case Opcodes.IF_ICMPNE:
    			return Opcodes.IF_ICMPEQ;
    		case Opcodes.IF_ICMPLT:
    			return Opcodes.IF_ICMPGE;
    		case Opcodes.IF_ICMPGE:
    			return Opcodes.IF_ICMPLT;
    		case Opcodes.IF_ICMPGT:
    			return Opcodes.IF_ICMPLE;
    		case Opcodes.IF_ICMPLE:
    			return Opcodes.IF_ICMPGT;
    		case Opcodes.IF_ACMPEQ:
    			return Opcodes.IF_ACMPNE;
    		case Opcodes.IF_ACMPNE:
    			return Opcodes.IF_ACMPEQ;
    		case Opcodes.IFNULL:
    			return Opcodes.IFNONNULL;
    		case Opcodes.IFNONNULL:
    			return Opcodes.IFNULL;
    		}
    		throw new IllegalArgumentException();
    	}
    

    比如下面的例子:

    class TestForJacocoData {
        fun testMethod(result: Int) {
            if (result == 1) {
                defineA()
            }
        }
        fun defineA() { val a = 1 }
    }
    

    编译后:

    public final void testMethod(int result) {
            boolean[] $jacocoInit = $jacocoInit();
            if (result != 1) {
                $jacocoInit[1] = true;
            } else {
                $jacocoInit[2] = true;
                defineA();
                $jacocoInit[3] = true;
            }
            $jacocoInit[4] = true;
        }
    

    比如在一些较为复杂的if语句中,会把复杂的判断的语句拆分成单一条件,并进行反转,这样能够保证能够覆盖全所有的分支,并且在反转操作后,可以更好的配合GOTO语句插入探针。

switch-case分支

switch-case对应下面两个字节码:

  • tableSwitch

    查找效率为O(1),通过偏移量就可以找到对应的case。

    比如下面的例子:

    class TestForJacocoData {
        fun testSwitch() {
            val value = 1;
            when(value) {
                0 -> System.out.println(0)
                2 ->  System.out.println(2)
                5 ->  System.out.println(5)
                else -> System.out.println(6)
            }
        }
    }
    

    编译后的字节码为

    public final void testSwitch();
        descriptor: ()V
        flags: ACC_PUBLIC, ACC_FINAL
        Code:
          stack=2, locals=2, args_size=1
             0: iconst_1
             1: istore_1
             2: iload_1
             3: tableswitch   { // 0 to 5
                           0: 40
                           1: 70
                           2: 50
                           3: 70
                           4: 70
                           5: 60
                     default: 70
                }
    }
    

    可以看到这里使用的tableswitch指令,并且原本我们的case语句只有0、2、5,系统自动给我们补齐成了0,1,2,3,4,5,让其成为了顺序的table,可以直接通过游标直接访问。时间复杂度最终才能O(1),

  • lookupSwitch

    查找效率为O(lgn),通过二分查找寻找对应的value值。

    比如下面的例子:

    class TestForJacocoData {
        fun testSwitch() {
            val value = 2;
            when(value) {
                0 -> System.out.println(0)
                1000 -> System.out.println(2)
                else -> {
                    System.out.println(6)
                }
            }
        }
    }
    

    对应的字节码为:

    public final void testSwitch();
        descriptor: ()V
        flags: ACC_PUBLIC, ACC_FINAL
        Code:
          stack=2, locals=2, args_size=1
             0: iconst_2
             1: istore_1
             2: iload_1
             3: lookupswitch  { // 2
                           0: 28
                        1000: 38
                     default: 48
                }
            28: getstatic     #12                 // Field java/lang/System.out:Ljava/io/PrintStream;
    

​ 可以看到这里使用的lookupswitch指令。字节码层面会根据case语句value值的稀疏,选择tableswitch指令还是lookupswitch指令。

对应的在ASM层看到的调用指令是下面两个:

	@Override
	public void visitLookupSwitchInsn(final Label dflt, final int[] keys, final Label[] labels) {
		if (markLabels(dflt, labels)) {
			probesVisitor.visitLookupSwitchInsnWithProbes(dflt, keys, labels,
					frame(1));
		}
    ...
	}

	@Override
	public void visitTableSwitchInsn(final int min, final int max, final Label dflt, final Label... labels) {
		if (markLabels(dflt, labels)) {
			probesVisitor.visitTableSwitchInsnWithProbes(min, max, dflt, labels,	frame(1));
		}
    ...
	}

其中,

  • dflt :默认的处理label
  • keys: 当前case的value集合
  • labels: 对应的keys的处理label

通过visitLookupSwitchInsnWithProbes和visitTableSwitchInsnWithProbes会给对应的case语句中插入探针。

综上的介绍,我们可以大概整理下jacoco的代码插入逻辑

  • return语句前、throw语句前会进行探针的插入。

  • 如果label对于上一条指令来说是连续的,并且有多个来源,那么会进行探针插入

  • 如果label对于上一条指令来说是连续的,并且label是一个方法调用,那么会进行探针插入

  • 如果是一个switch语句,对于各个case跳转也会进行探针插入。

运行时处理

在编译期间做了代码插入之后,运行时是如何生效的? 以及数据展示是如何来实现的呢?

对于每一个方法调用,在对
j a c o c o D a t a 数组元素赋值前,都会先尝试初始化 jacocoData数组元素赋值前,都会先尝试初始化
jacocoData

  private static  boolean[] $jacocoInit() {
        boolean[] zArr = $jacocoData;
        if (zArr != null) {
            return zArr;
        }
        boolean[] probes = Offline.getProbes(-1643518976017980468L, "com/kuaikan/zz/TestForJacocoData", 18);
        $jacocoData = probes;
        return probes;
    }

会先尝试从Offline中获取离线探针数据,有三个参数:

  • 第一个参数是class的唯一Id,在插桩时,会根据字节码流创建生成。

    private byte[] instrument(final byte[] source) {
    		final long classId = CRC64.classId(source);
    }
    
  • 第二个参数是class全路径名称

  • 第三个参数是当前class共计有多少个探针。

这三个参数在编译后就已经固定了,不会发生改变,所以在插桩后插入的都是具体的值。

然后呢根据传入的参数,创建出来一个ExecutionData对象并返回

	public ExecutionData get(final Long id, final String name, final int probecount) {
		ExecutionData entry = entries.get(id);
		if (entry == null) {
			entry = new ExecutionData(id.longValue(), name, probecount);
			entries.put(id, entry);
			names.add(name);
		} else {
			entry.assertCompatibility(id.longValue(), name, probecount);
		}
		return entry;
	}

可以看到ExecutionData做了内存存储,并没有做本地的文件存储。

所以,当我们想要用jacoco来实现多人协作的覆盖率合并时,就需要自己实现当前覆盖率结果的文件存储。

如下代码所示:

fun generateEcFile() {
	FileUtils.createFolderIfNotExists(path)
  FileUtils.createFileIfNotExists(ecFile)
  val agent = Class.forName("org.jacoco.agent.rt.RT")
      .getMethod("getAgent")
      .invoke(null)
  IOUtils.writeBytes2File(ecFile, agent.javaClass.getMethod("getExecutionData", 
      Boolean::class.javaPrimitiveType).invoke(agent, false) as ByteArray)}

通过反射获取出RT实例,拿出当前所有的已执行的结果,并存储到文件中。

覆盖结果

在不看代码的情况下,我们可以先推理下,按照前面插桩和收集的数据,如何展现出实际覆盖率的结果。

  • 首先需要把所有上传的数据进行合并

    DumpTask + MergeTask

    • DumpTask: jacoco内置dumpTask,主要的作用是从远端下载收集到的测试覆盖数据。

    • MergeTask:因为每次运行的测试覆盖数据都是单独的文件数据,所以需要有一个专门的Task,把众多的文件的测试覆盖数据合并成单个文件。

      合成的逻辑比较简单:

      private void load(final ExecFileLoader loader) {
      		final Iterator<?> resourceIterator = files.iterator();
      		while (resourceIterator.hasNext()) {
      			final Resource resource = (Resource) resourceIterator.next();
      				resourceStream = resource.getInputStream();
      				loader.load(resourceStream);
          }
      	}
      
      	public void load(final InputStream stream) throws IOException {
      		final ExecutionDataReader reader = new ExecutionDataReader(
      				new BufferedInputStream(stream));
      		reader.setExecutionDataVisitor(executionData);
      		reader.setSessionInfoVisitor(sessionInfos);
      		reader.read();
      	}
      
      ====》
      	public void visitClassExecution(final ExecutionData data) {
      		put(data);
      	}
      
        ====》 
      	public void put(final ExecutionData data) throws IllegalStateException {
      		final Long id = Long.valueOf(data.getId());
      		final ExecutionData entry = entries.get(id);
      		if (entry == null) {
      			entries.put(id, data);
      			names.add(data.getName());
      		} else {
      			entry.merge(data);
      		}
      	}
      

      即遍历每一个文件,根据文件内容,调用load,最终merge到同一个hashMap中。

  • 需要把方法覆盖率和对应的className和源代码文件做关联

    这个流程是比较重要并且复杂的流程。 因为运行时收集到的ExecutionData数据还比较少,仅有唯一ID、名称、和覆盖率结果数组,无法直接应用于结果展示。

    public final class ExecutionData {
    	private final long id;
    	private final String name;
    	private final boolean[] probes;
    	}
    

    因此,需要有分析的Task,将这个结果和源文件进行关联。分析单个方法主要使用InstructionsBuilder。

    	InstructionsBuilder(final boolean[] probes) {
    		this.probes = probes;
    		this.currentLine = ISourceNode.UNKNOWN_LINE;
    		this.currentInsn = null;
    		this.instructions = new HashMap<AbstractInsnNode, Instruction>();
    		this.currentLabel = new ArrayList<Label>(2);
    		this.jumps = new ArrayList<Jump>();
    	}
    

    根据MethodVisitor的访问顺序,重建探针对应的覆盖的行号、指令等。

    @Override
    	public void visitLabel(final Label label) {
    		builder.addLabel(label);
    	}
    	@Override
    	public void visitLineNumber(final int line, final Label start) {
    		builder.setCurrentLine(line);
    	}
    	@Override
    	public void visitInsn(final int opcode) {
    		builder.addInstruction(currentNode);
    	}
    	@Override
    	public void visitIntInsn(final int opcode, final int operand) {
    		builder.addInstruction(currentNode);
    	}
    
    	void addProbe(final int probeId, final int branch) {
    		final boolean executed = probes != null && probes[probeId];
    		currentInsn.addBranch(executed, branch);
    	}
    

    最关键的还是在插桩时标记需要插入探针的地方,都访问一次addProbe(final int probeId, final int branch)方法,这样可以重新从探针数组中获取当前指令是否覆盖到。

  • 根据展示的样式,转化为html等其他格式。

将merge的结果,展示成对应的文件样式,比如html等。

关键逻辑是是否覆盖的展示逻辑,如下所示:

	HTMLElement highlight(final HTMLElement pre, final ILine line, final int lineNr) throws IOException {
		final String style;
		switch (line.getStatus()) {
		case ICounter.NOT_COVERED:
			style = Styles.NOT_COVERED;
			break;
		case ICounter.FULLY_COVERED:
			style = Styles.FULLY_COVERED;
			break;
		case ICounter.PARTLY_COVERED:
			style = Styles.PARTLY_COVERED;
			break;
		default:
			ret
		}

这里的line就是我们前面通过InstructionsBuilder分析出来的结果,根据不同的结果,展示不同的色值。

增量

在现有的jacoco的能力基础上快速实现增量,目前来说,有比较多的方式

  • 在插桩过程中做增量,在非增量文件中,不进行插桩
  • 在结果merge的过程中,进行增量逻辑处理,过滤扫描出来的增量代码段。
  • 在生成的结果文件中,过滤出来增量结果并展示

第一种方案较优,仅对需要增量的代码进行插桩,可以降低整体的编译耗时。

第二种、第三种方案较简单,仅需要针对结果集层面做处理,不需要care较为复杂的插桩逻辑。

增量代码获取

比较通用的方案是通过git diff可以计算两个分支间的增量代码。不过这种方案有缺陷

  • 在某些场景下,diff过大的情况下,查询不出来结果。 在一些改动较大的业务下,会导致整个增量方案失效。

  • 基于git diff实现的,需要自己实现一套解析器,针对git diff的结果,解析出来增量数据集,较为复杂。

因此,我们基于目前jacoco的代码插桩,在全量时,直接利用jacoco对于每个类、各个方法的插桩记录做了记录,然后和分支关联,并且上传作为备份。在每一次代码编译时,拉下来对应分支上传备份文件,计算增量。然后在插桩过程中,过滤掉对应非增量的class和method来实现增量。

参考文章:

javaAgent指南:www.baeldung.com/java-instru…

javaAgent指南:xz.aliyun.com/t/9450

maven、gradle对比:www.flydean.com/gradle-vs-m…

jacoco染色技术实践:www.shuzhiduo.com/A/GBJrKqrE5…

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
转载请注明出处: https://daima100.com/13142.html

(0)

相关推荐

  • 阿里面试题和答案java_阿里王坚

    阿里面试题和答案java_阿里王坚我的2017是忙碌的一年,从年初备战实习春招,年三十都在死磕JDK源码,三月份经历了阿里五次面试,四月顺利收到实习offer。然后五月怀着忐忑的心情开始了蚂蚁金服的实习。八月,又经历了两轮面试,总算拿到转正offer。到此为止,我总算可以尽情地享受最后的校园时光了。 希望我的经…

    2023-07-25
    130
  • integer127与integer128的区别_数据库tinyint类型

    integer127与integer128的区别_数据库tinyint类型想要了解取值范围首先需要知道的是 bit 和 Byte 的概念 bit :位 二进制数系统中,位通常简写为 "b",也称为比特,每个二进制数字 0 或 1 就是一个位(bit)。位

    2023-04-22
    157
  • ocp认证题库_ccf认证考试报名

    ocp认证题库_ccf认证考试报名Choose two Examine the data in the CUST NAME column of the CUSTOMERS table: CUST_NAME ————-…

    2022-12-19
    160
  • redis事件模型_如何设计移动客户端

    redis事件模型_如何设计移动客户端(第6章 事件与客户端) 前言 参考资料:《Redis设计与实现 第二版》; 第二部分为单机数据库的实现,主要由以下模块组成:数据库、持久化、事件、客户端与服务器; 本篇将介绍 Redis 中的事件与

    2023-04-30
    133
  • Python命令大全

    Python命令大全Python是一种高级、解释型的编程语言。它有着简单易学、代码量少、执行速度快等优点,在人工智能、数据分析、Web开发等领域都有着广泛的应用。

    2024-07-28
    30
  • MongoDB学习笔记:命令行工具

    MongoDB学习笔记:命令行工具本文更新于2022-01-22,使用MongoDB 4.4.5。 bsondump BSON查看工具。 bsondump BSONFILENAME mongo JavaScript shell。 mo

    2023-05-06
    150
  • 如何强制实行 setTimeout 与 setInterval 的最佳实践「终于解决」

    如何强制实行 setTimeout 与 setInterval 的最佳实践「终于解决」在 JavaScript 异步编程中,作者经常会写出各种各样的 bug,其中最常见的错误,都是由于忘记清除 setTimeout 或者 setInterval 创建的定时器引起的。 组件挂载后,每隔一秒会将 sec 的值加 1,看起来一切正常,但是,当组件被卸载的时候,setI…

    2023-08-09
    126
  • Redis学习笔记(二) 链表「建议收藏」

    Redis学习笔记(二) 链表「建议收藏」链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。 redis中链表应用广泛,如list中就使用了链表。 每一个链表节点使用listNode结构标识(

    2023-02-26
    158

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注