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 中加入以下依赖:

1
2
3
4
5
6
7
8
9
10
<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 实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 此处省略 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 实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

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;
}

以下是日志工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

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 接口用来接受上传的文件即可,接口定义如下:

1
2
3
4
5
6
7
8
9
10
11
@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 目录下。