执念
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];
}
}
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 方法封装的很方便扩展,这边就不需要其他步骤就能进行额外参数的提取了。
}
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));
}
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 数据,一般情况下都不会对应用产生太大的负担。
最后
软件的发展,都是一点点变化发展的,其实就是为了解决某个问题引入了新的解决方案,在某一个时刻得到平衡,之后又因为新需求,再有新的方案进行解决,如此循环往复,测试效能工具也是如此,没有银弹,需要跟随需求一起进化。