测试覆盖率 jacoco agent 另类 2 次开发实践

树叶小记 · 2023年03月25日 · 最后由 树叶小记 回复于 2023年03月28日 · 6218 次阅读

执念

jacoco,一个至今还在不断更新的开源覆盖率工具,第一听到它是因为有一次公司需要招一个业务测试 leader,然后公司安排我进行一面。在面试过程中,我像一个学生虚心的聆听着老师的介绍(然而我并没有给他 pass :( 。 )。随后我各个方式都体验了下 jacoco,也基于它在公司从 0 到 1 落地了个覆盖率平台。但是我一直有一个执念,虽然平台出来了也能用,但是还是感觉羁绊太多,想要更(hua)加(li)丝(hu)滑(shao)。

老(传统)流程

老流程非常的原生态,完全按 jacoco 的预设套路来。

图片

它从接入到获取覆盖率的数据大致步骤如下:

  • 在构建应用发布镜像的时候放入 jacocoagent.jar

  • 在发布应用动作发生的开始阶段,通过接口方式(向覆盖率管理端)获取当前发布应用的 jacoco 配置参数

  • 在发布应用的同时,给覆盖率管理端的服务器上下载 class 文件

  • 先拉取 RuntimeData

  • 由覆盖率服务端结合下载的 class 文件解析 RuntimeData

这里其实存在几个问题,应用发布操作已经受到了影响。操作链路过长,任何一个小步骤出现问题,都会导致不稳定(甚至不可用),例如 class 文件没同步,class 版本拿错等等。虽然之前玩的好好的,但是经历了几次小插曲后(新的部署方式加入,老发布系统改造等等),我就萌生出了点其他的想法。有没有可能让协作方都不再这么累,用更加少的步骤,更快捷,更精准,更稳定?

新(另类)流程

肯定是可能的,不然我干嘛说呢?新的流程会把之前的流程优化的更简单。

图片

只有 3 步:

  • 在构建应用发布镜像的时候放入 jacocoagent.jar

  • 有 jacocoagent 插件自己,通过接口方式(向覆盖率管理端)获取当前发布应用的 jacoco 配置参数

  • 由 jacocoagent 直接结合服务器上的 class 文件解析本地的 RuntimeData 内存对象,并上报给服务端。

二者的区别

操作 老流程 新流程
准备字节码文件 管理端需要准备一份 class 文件 不需要
拉取 RuntimeData 从本地 dump 并发向管理端 不需要
把 RuntimeData 进行解析 管理端根据 class 文件和 RunTimeData 进行解析 直接在服务器上使用 RuntimeData 和服务器的 class 文件进行解析
把解析数据发送给管理端 不需要 通过上报解析后的数据给管理端

有没有发现,其实最终的结果是一样的,过程也都是有的,都是最后获取到解析完的数据而已。所有的流程都还是走了的,就是顺序上看似有了些变化。Talk is cheap. 那我们来看下大致实现的切入点有哪些,一共有4处,最后一处改动尤其重要。

第一,我们需要一个基础能力发送数据给管理端。

private static void postObject(ExecutionDataStore store) {

    CoverageBuilder builder = new CoverageBuilder();
    //这里做了一个小小的改动,为了解决因为包类冲突导致覆盖率数据解析失败的问题。本文不做过多介绍
    Analyzer analyzer = new Analyzer(store, builder, excludes, includes);
    try {  
    //  jacoco 使用 class 文件和本地rumtimeData对象进行覆盖率解析的方法
    //  JARPATH 是改变AgentOptions 增加的外部传入参数,特指应用服务的jar包位置
      analyzer.analyzeAll(new File(JARPATH));
    } catch (IOException ignored) {
        ignored.printStackTrace();
    }
    DEFAULTHEADERS.put("Content-type", "application/octet-stream");
    IBundleCoverage iBundleCoverage = builder.getBundle(JARPATH);
    try {
    // 通过http的方式把覆盖率详细信息,通过 byte[] 的方式压缩并发回给管理端。(压缩后10M -> 500K)
        post(URL + PATHPOSTDATA + APPNAME + "/" + IP, compress(iBundleCoverage.toJSONString().getBytes(StandardCharsets.UTF_8)), DEFAULTHEADERS);
    } catch (IOException e) {
        e.printStackTrace();
    }

   public static void post(ExecutionDataStore dataStore) {
        // URL 为管理端的接口url,Ip 为当前应用的ip地址
        if ( URL == null || IP == null) {
            return;
        }
        try {
            Thread thread = new Thread(() -> {
                try {
                    postObject(dataStore);
                } catch (Exception ignored) {
                    ignored.printStackTrace();
                }
            });

            thread.start();
        } catch (Exception ignored) {
            ignored.printStackTrace();
        }
    }

    public static byte[] compress(byte[] input) {
        // 使用jdk的方式进行压缩
        Deflater compressor = new Deflater(Deflater.BEST_COMPRESSION, false);

        compressor.setInput(input);

        compressor.finish();

        try (ByteArrayOutputStream bao = new ByteArrayOutputStream()) {
            byte[] readBuffer = new byte[1024];
            int readCount = 0;

            while (!compressor.finished()) {
                readCount = compressor.deflate(readBuffer);
                if (readCount > 0) {
                    bao.write(readBuffer, 0, readCount);
                }
            }

            compressor.end();
            return bao.toByteArray();
        } catch (Exception ignored) {

        }
        return new byte[0];
    }
}

第二,改造 AgentOptions 类,添加新字段方便控制,比如去哪里找 class 文件,怎么获得应用的配置等

public final class AgentOptions {
...
public static final String APPNAME = "appname";
public static final String CLOUD = "cloud";

public static final String JARPATH = "jar";
...
public static final Collection<String> VALID_OPTIONS = Arrays.asList(
        DESTFILE, APPEND, INCLUDES, EXCLUDES, EXCLCLASSLOADER,
        INCLBOOTSTRAPCLASSES, INCLNOLOCATIONCLASSES, SESSIONID, DUMPONEXIT,
        OUTPUT, ADDRESS, PORT, CLASSDUMPDIR, JMX, APPNAME, CLOUD, JARPATH);
...
// 因为 jacoco 方法封装的很方便扩展,这边就不需要其他步骤就能进行额外参数的提取了。
}

第三,以定时主动上报为例,我们需要修改 PreMain 类,进行定时任务的设置

 private static final ScheduledThreadPoolExecutor timerExec = new ScheduledThreadPoolExecutor(1);

 public static void premain(final String options, final Instrumentation inst)
            throws Exception {

        AgentOptions agentOptions;
        try {
            agentOptions = new AgentOptions(options);
        } catch (Exception e) {
        // 如果解析AgentOptions出现异常的时候,不对应用进行插桩,方便对应用进行控制。
            e.printStackTrace();
            return;
        }
        final Agent agent = Agent.getInstance(agentOptions);
        final IRuntime runtime = createRuntime(inst);
        runtime.startup(agent.getData());
        // 开启定时任务,NETWORKPropsHelper.post 在步骤一中进行了定义;
        int delay =10L;

        timerExec.scheduleAtFixedRate(() -> NETWORKPropsHelper.post(agent.getData().getStore()), delay, delay, TimeUnit.MINUTES);
        inst.addTransformer(new CoverageTransformer(runtime, agentOptions,
                IExceptionLogger.SYSTEM_ERR));
    }

第四,最终覆盖率数据的序列化,因为覆盖率是整个应用工程的,所以需要序列化的内容包括,包,类,方法,行等,非常的多。以下以 2 个核心接口为例

public interface ICoverageNode extends Serializable {
    ...

    default String toJSONString() {
        return "{\"" + CONSTANTS.ELEMENT_TYPE + "\":\"" + getElementType().name() + "\"," +
                "\"" + CONSTANTS.ELEMENT_NAME + "\":\"" + getName() + "\"," +
                "\"" + CONSTANTS.METRICS_INSTRUCTION + "\":" + getInstructionCounter().toJSONString() + "," +
                "\"" + CONSTANTS.METRICS_BRANCH + "\":" + getBranchCounter().toJSONString() + "," +
                "\"" + CONSTANTS.METRICS_LINE + "\":" + getLineCounter().toJSONString() + "," +
                "\"" + CONSTANTS.METRICS_METHOD + "\":" + getMethodCounter().toJSONString() + "," +
                "\"" + CONSTANTS.METRICS_CLASS + "\":" + getClassCounter().toJSONString() + "," +
                "\"" + CONSTANTS.METRICS_COMPLEX + "\":" + getInstructionCounter().toJSONString() +
                "}";
    }

}
public interface ICounter extends Serializable {
   ...
   default String toJSONString() {
        return "{\"" + CONSTANTS.TOTAL + "\":" + getTotalCount() + "," +
                "\"" + CONSTANTS.COVERED + "\":" + getCoveredCount() + "}";
    }
}
public class CONSTANTS {

    // 所有内容转化成json后的key 的label信息

    public static final String CLASSES = "classes";
    public static final String METHOD_DESC = "desc";
    public static final String METHOD_SIGNATURE = "signature";
    public static final String METHOD_LINES = "lines";
    public static final String LINE_NBR = "nbr";
    public static final String LINE_STATUS = "status";
    public static final String METHODS = "methods";
    public static final String PACKAGES = "packages";
    public static final String ELEMENT_TYPE = "elementType";
    public static final String ELEMENT_NAME = "name";
    public static final String METRICS_INSTRUCTION = "instruction";
    public static final String METRICS_BRANCH = "branch";
    public static final String METRICS_LINE = "line";
    public static final String METRICS_METHOD = "method";
    public static final String METRICS_CLASS = "class";
    public static final String METRICS_COMPLEX = "complex";
    public static final String TOTAL = "total";
    public static final String COVERED = "covered";
}

改动涉及到的类:

图片

最后一步,技术含量一般,但是工作量比较大,结果需要由 bundleCoverage 统一执行序列化,进行最后覆盖率数据的 json 字符串的生成。这样生成出来的字符串对象会特别大,大的会超过 10M,所以才有了第一步的压缩 byte 数组操作。

有这了种流程,我们就可以更低成本的接入代码覆盖率,而且在不同的发布方式下,兼容性也比较可控。当然了,也不是完全没有坏处,因为覆盖率的数据解析全部由应用上的 agent 承担,因此会对应用本身产生额外的开销,这就需要进行应用评估后才能选择更好的方式。覆盖率本身不需要每秒都进行解析,每 10~30 分钟一次,每次产生 10M 数据,一般情况下都不会对应用产生太大的负担。

最后

软件的发展,都是一点点变化发展的,其实就是为了解决某个问题引入了新的解决方案,在某一个时刻得到平衡,之后又因为新需求,再有新的方案进行解决,如此循环往复,测试效能工具也是如此,没有银弹,需要跟随需求一起进化。

共收到 4 条回复 时间 点赞

很棒的思路!非常有启发,感谢分享!

👍 厉害。确实能解决不必要的麻烦。顺便请教下大佬!为什么我隔个两三天拉取 exec 时文件会很大有好几个 G

启动方式 java -javaagent:/tmp/jacoco/lib/jacocoagent.jar=includes=*,output=tcpserver,port=6300 这种方式启动

小易何 回复

jacoco 会遍历所有的 class 文件,包括 第三方 jar 包内的 class, 如果不做 exclude,东西会非常多,自然文件也会很大。

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