cover

Jacocoagent 改造-服务端覆盖率数据上报

背景

代码覆盖率服务已经上线一段时间了,用户也反馈了不少问题,大家反馈比较集中的问题就是: 测试忘记在服务器重启前生成覆盖率报告了,导致某段时间内覆盖率数据丢失。 解决这个问题的思路比较简单,就是改造 javaagent,在 jvm 停止时,上报覆盖率数据到我们的代码覆盖率服务,等待生成报告时,将上报的数据和实时的覆盖率数据做合并即可。

实现方案

具体实现方案涉及到两部分:

  • jacoco 源码改造
  • 代码覆盖率服务(以下简称 cov 服务)改造

jacoco改造

jacoco 改造主要涉及到以下几个类:

  • org.jacoco.agent.rt.internal.Agent
  • org.jacoco.core.runtime.AgentOptions

在 jacocoagent 的 org.jacoco.agent.rt.internal.Agent 类中,官方已经添加了一个 shutdownHook,只需要在此方法中实现我们的上报逻辑即可。为了方便测试,我们还需要对 org.jacoco.core.runtime.AgentOptions 做改造,增加两个参数 debug 和 host。

  • debug:是否启用 debug 模式,如果是 debug 模式,则不会上报覆盖率数据,否则根据 host 配置推送覆盖率数据到代码覆盖率服务器。此参数默认为 false,表示会推送数据,也可以将 debug=true,表示无需推送数据到覆盖率服务。
  • host: 测试平台的域名,用来发送覆盖率数据,可以根据 host 的配置,将数据发送到不同的环境。

改造前,需要在 org.jacoco.agent.rt 的 pom.xml 中加入以下依赖:

<dependency>
    <groupId>com.konghq</groupId>
    <artifactId>unirest-java-core</artifactId>
    <version>4.2.4</version>
</dependency>
<dependency>
    <groupId>commons-logging</groupId>
    <artifactId>commons-logging</artifactId>
    <version>1.2</version>
</dependency>

AgentOptions 实现代码如下:

// 此处省略 getter 和 setter 方法
    /**
	 * 是否启用 debug 模式,如果是 debug 模式,则不会上报覆盖率数据,否则根据 host 配置推送覆盖率数据到 cov 服务器
	 */
	public static final String DEBUG = "debug";
	/**
	 * 测试平台域名,用来发送覆盖率数据
	 */
	public static final String HOST = "host";

    // 添加完参数后,需要将这两个参数加到 VALID_OPTIONS 中,否则会提示 “未知参数”
	private static final Collection<String> VALID_OPTIONS = Arrays.asList(
			DESTFILE, APPEND, INCLUDES, EXCLUDES, EXCLCLASSLOADER,
			INCLBOOTSTRAPCLASSES, INCLNOLOCATIONCLASSES, SESSIONID, DUMPONEXIT,
			OUTPUT, ADDRESS, PORT, CLASSDUMPDIR, JMX, HOST, DEBUG);

Agent 实现代码如下:


public static synchronized Agent getInstance(final AgentOptions options)
            throws Exception {
        if (singleton == null) {
            final Agent agent = new Agent(options, IExceptionLogger.SYSTEM_ERR);
            agent.startup();
            Runtime.getRuntime().addShutdownHook(
                    new Thread("Jacocoagent Shutdown hook thread.") {
                        @Override
                        public void run() {
                            AgentLogger.info("Jacocoagent Shutdown hook running.");
                            // 非 debug 模式,直接上报数据到 cov 服务
                            if (!options.getDebug()) {
                                AgentLogger.info("Not in debug mode, push RuntimeData to OTS server.");
                                // 直接在本地生成 exec 文件
                                File execFile = new File("output.exec");
                                try {
                                    FileOutputStream fos = new FileOutputStream(execFile);
                                    ExecutionDataWriter writer = new ExecutionDataWriter(fos);
                                    RuntimeData runtimeData = singleton.getData();
                                    runtimeData.collect(writer, writer, false);
                                    fos.flush();
                                    AgentLogger.info("Generate exec file success, exec file path is : " + execFile.getAbsolutePath());
                                } catch (Exception e) {
                                    AgentLogger.severe("Failed to generate exec file: " + e.getMessage());
                                }
                                try {
                                    AgentLogger.info("OTS server host is: " + options.getHost());
                                    // 调用 cov 服务接口,上传数据
                                    String reportApiPath = "/coverage/rpc/jacocoagent/report";
                                    HttpResponse<String> response = Unirest.post(options.getHost() + reportApiPath)
                                            .field("ip", InetAddress.getLocalHost().getHostAddress())
                                            .field("file", execFile)
                                            .asString();
                                    if (response.getStatus() == 200) {
                                        AgentLogger.info("Push data to OTS server success, request url is : " + options.getHost() + reportApiPath
                                                + ", request body is :" + " {ip:" + InetAddress.getLocalHost().getHostAddress() + ", file:" + execFile.getAbsolutePath() + "}");
                                    } else {
                                        AgentLogger.severe("Failed to push data to OTS server, request url is : " + options.getHost() + reportApiPath
                                                + ", request body is :" + " {ip:" + InetAddress.getLocalHost().getHostAddress() + ", file:" + execFile.getAbsolutePath() + "}"
                                                + ", response status is " + response.getStatus()
                                                + ", response body is : " + response.getBody());
                                    }
                                } catch (Exception e) {
                                    AgentLogger.severe("Failed to push data to OTS server, on exception: " + e.getMessage());
                                } finally {
                                    // 删除 exec 文件
                                    if (execFile.exists()) {
                                        execFile.delete();
                                    }
                                }
                            } else {
                                AgentLogger.info("In debug mode, skip pushing data to OTS server.");
                            }
                            agent.shutdown();
                        }
                    });
            singleton = agent;
        }
        return singleton;
    }

以下是日志工具类:


package org.jacoco.agent.rt.internal;

import java.io.File;
import java.util.logging.FileHandler;
import java.util.logging.Logger;

/**
 * @author: wick
 * @date: 2024/1/25 17:46
 * @description: agent日志工具类
 */
public class AgentLogger {
	private static final Logger logger;
	private static final String defaultLogFileName = "javaagent.log";

	private static final File LOG_LOCK_FILE = new File("javaagent.log.lck");

	// 静态初始化块配置 Logger 和 FileHandler
	static {
		// 移除日志锁文件
		if (LOG_LOCK_FILE.exists()) {
			LOG_LOCK_FILE.delete();
		}
       logger = Logger.getLogger("AgentLogger");
		try {
			FileHandler fileHandler = new FileHandler(defaultLogFileName, true);
        fileHandler.setFormatter(new SimpleFormatter());
        logger.addHandler(fileHandler);
        logger.setUseParentHandlers(false);
        } catch (Exception e){
          logger.warning("An error occurred initializing th AgentLogger: " + e.getMessage());
        }
     }
  public static void info(String msg){
    logger.info(msg);
  }
  // 其他日志级别如法添加即可
}

cov服务改造

cov 服务改造很简单,添加一个 post 接口用来接受上传的文件即可,接口定义如下:

@RestController
@RequestMapping("/rpc/jacocoagent")
public class JacocoagentReportController{
    @Autowired
    private AgentService agentServie;

    @PostMapping("/report")
    public void report(@RequestParam("ip") String ip, @RequestParam("file") MultipartFile file){
        agentService.report(ip, file);
    }
}

至此,jacocoagent 就实现了在 jvm 停止时,自动上报数据的功能。编译出来的 jacocoagent.jar 位于 org.jacoco.agent/target/classes 目录下。

2