一年前刚开始调研这块的时候发过一个帖子《覆盖率打包与 CI/CD 如何更无侵入的结合》。为什么会想要做完全无入侵呢?好像平时大家在聊诸如此类问题的时候,更多是在谈增量覆盖率,精准测试这些问题,而很少提及覆盖率的准备工作,即配置、构建、采集这些点。
首先面临的现状是公司内部几千个后端的服务,没有专职的后端测试,语言 c++,java,go,python,node……构建框架有 ant、maven、gradle、make、cmake、bazel……
如果要按照常规的模式去推动测试覆盖率的进展,仅仅是开会拉通,让开发按照不同的语言、不同的框架去做覆盖率的配置自己的应用,使得应用在构建编译时能打出覆盖率包这一点来说就不是那么现实。
所以简单的明确一下目标:需要一套可以结合 CI/CD 流水线,做到完全无入侵,适配不同语言,不同构建框架的覆盖率构建、采集、分析系统。
PS:本文只会零散的记录一些有趣的点,没有架构之类的东西
首先聊聊完全无入侵,即开发什么都不用配置,我们可以在流水线的中间节点,让用户无感的情况下,实现覆盖率构建、发布、采集的全流程。其实想想这个理念,其实就是开发中常用的 aop 思想,面向切面编程。而区别在于,可能开发中需要切一个方法,而这里我们需要去切一个阶段,或者切一个插件。
怎么切一个阶段呢?以 C++ 为例,这是原本的编译命令
gcc hello.c -o hello
那么其实我们如果可以在命令中加上一段-fprofile-arcs -ftest-coverage
,那么就可以打出覆盖率包了
gcc -fprofile-arcs -ftest-coverage hello.c -o hello
再比如 Java maven 中,我们只需要在原本的pom.xml
文件的 build 阶段中加入这么一段(实际上 dependency 也要加,这里省略),那么打出的构建包就是经过覆盖率插桩的。
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</version>
<executions>
<execution>
<id>default-instrument</id>
<goals>
<goal>instrument</goal>
</goals>
</execution>
</executions>
</plugin>
所以回想一下 aop,用户本身的命令 or pom 文件就是切点,我们通过切面编程对命令 or 文件进行改动,那就可以实现完全无侵入的覆盖率构建了。
那么要在什么地方去切用户的指令呢?第一想法是能够结合 CI/CD 流水线,在原本构建发布的基础上,稍作插件的替换即可快捷实现全流程的运行。在之前那篇文章中,有大佬评论说
如果是流水线的插件的话,那原有流水线的编译和这个增加覆盖率的编译,可能会引起重复。
很有道理,首先在覆盖率之前,流水线已经运行很久了,大家都已经在流水线编译插件上配置好了各自的编译命令,那么现有的流水线编译插件——覆盖率流水线编译插件的配置移植工作将是一个非常大的工作量和难题。同时如果未来流水线编译插件有了更新,覆盖率流水线编译插件能否及时同步流水线编译插件的更新?
但是覆盖率这件事虽然和编译系统紧密相关,但是从架构角度思考,编译系统本身作为一个 SLA 要求相对较高的,放在一起强耦合显然不是那么合适。同时从实际使用出发,覆盖率打包对于一般用户来说,除过原本的编译命令之外并不需要特别多的额外配置,即使有也可以通过环境变量的方式传进去,甚至都不需要额外做一个界面。
所以在这种情况下,我们能否通过源码依赖的方式,aop 修改流水线编译插件的部分入参,实现编译指令的 AOP。这样当流水线编译插件更新的时候,甚至我们都不需要更新代码逻辑,只需要拉代码重新打包发布即可。
所以接下来分享两个特殊的 AOP 玩法
众所周知,流水线编译插件会将用户的配置进行组装,然后调用构建系统的接口去实现源码 - 包的构建过程。
所以在调用构建系统的请求这里我们一般可以拿到用户最终的构建指令,如果我们可以在这里实现一次 aop,那么他将是绝杀!
首先我们用 maven 创建一个新的组件
将构建组件的 maven 通过 dependency 的方式引入,根据流水线本身的插件玩法,将构建组件的功能组合继承到新组件中。
pf4j
中需要@Extension去声明一个类作为插件的执行主路径@Slf4j
@Extension
public class CodeBuildAction implements IAction {
@Override
public ActionResult execute(ActionParam actionParam) {
return ActionResult.success(result);
}
}
@Extension
public class CodeCoverageBuildAction extends CodeBuildAthenaAction {
@Override
public ActionResult execute(ActionParam actionParam) {
return super.execute(actionParam);
}
}
开始 AOP,这里我们引入 Aspectj 类库。与 spring 里面大家熟知的 jdk 动态代理,cglib 不同的是,由于流水线可能不是 spring 编写的,亦或者流水线开发者一开始没有考虑动态代理的问题,那么 jdk proxy 或者 cglib 在这里就排不上用场了。但是我们可以通过 aspectj 的静态代理来实现,静态代理原理也有很多种,例如,编译期的语法树修改,编译期的字节码修改,类加载起的字节码修改等等。
由于我们是类库引入流水线编译插件,所以这里可以用Post-Compile Weaving
这种方式进行 aop,即编译完成后对字节码进行增强。具体的代码实现其实和常规的 aop 写法类似
@Aspect
@Slf4j
public class BuildTaskAop {
//通过阅读源码发现这里是发送构建请求的入口,那么对它进行切面,将接口参数提前改掉
@Pointcut("execution(* com.***.CodeBuildHandler.request(..))")
public void point() {
}
@Before("point()")
public void addEnv(JoinPoint point) {
Object[] args = point.getArgs();
if (args.length != 1 || !(args[0] instanceof CodeBuildCreateReq)) {
log.error("构建任务切面异常");
}
CodeBuildCreateReq request = (CodeBuildCreateReq) args[0];
Map<String, String> map = Optional.ofNullable(request.getEnvironments()).orElseGet(() -> {
request.setEnvironments(new HashMap<>());
return request.getEnvironments();
});
//*******各种各样的逻辑可以写起来了
}
}
先来看这样一段构建指令的样例
#这是一个大仓目录
cd xxx
#使用mvn指令进行grpc 代码生成
mvn protobuf:compile
cd ..
mvn clean package -pl xxx -am
按照我们之前的思路,
简单一看,上面四步走,仅仅第一步就让人觉得不可能。
再举一个例子,有的同学可能会这么写命令
mvn protobuf:compile && mvn clean package -pl xxx -am
难道我们要自研一个 shell parser 把命令都分析出来?像 mvn gcc 这些命令都包含了大量的内部参数、格式,想要穷举式的分析 或者简单的正则表达式匹配都是绝无可能的。
我觉得这算是一个大招,先留一个悬念后面接着更新 :)
……
在生成 c++ 覆盖率报告时发现了这么一种情况,系统会报错
*.gcda:stamp mismatch with notes file
查了一下大致的缘由,是因为 gcc 编译第一次生产的 gcno 报告与构建产物生成的 gcda 报告的 stamp 号不一致。
这是由于覆盖率系统的设计,我们首先坚信一个原则,即同一个 commitId 打包出来的构建产物 99% 以上的情况应该是一致的,基于这个假设,我们并没有存储每一次打包构建的源码、gcno 报告,而且根据 commitId 进行存储。
即同一个 commitID 多次打包,产生了多个线上版本,有不同版本的 gcda 报告,但是源码文件和 gcno 文件只存储了一次。所以就导致了这样的情况。
但是虽然 gcda 与 gcno 的 stamp 号不一致,但是由于我们是通过 commitID 进行一致性校验,所以其实并不影响 gcc 最后生成报告的准确性。
先上一段 gcc 源码,看看为什么会报这个错
version = gcov_read_unsigned ();
if (version != GCOV_VERSION)
{
char v[4], e[4];
GCOV_UNSIGNED2STRING (v, version);
GCOV_UNSIGNED2STRING (e, GCOV_VERSION);
fnotice (stderr, "%s:version '%.4s', prefer version '%.4s'\n",
da_file_name, v, e);
}
tag = gcov_read_unsigned ();
if (tag != bbg_stamp)
{
fnotice (stderr, "%s:stamp mismatch with notes file\n", da_file_name);
goto cleanup;
}
当 gcc 检测到两个文件的 stamp 不一致时,就会直接goto cleanup;
结束编译流程,同时也可以发现stamp
位于文件的第 8-12 字节
所以我们找两个不一致的文件
hexdump -C -s8 -n4 xx.gcda
hexdump -C -s8 -n4 xx.gcno
尝试将 gcno 文件中的字节直接暴力修改掉
printf "\xx\xx\xx\xx" | dd of=xx.gcno bs=1 seek=8 count=4 conv=notrunc
再次生成覆盖率报告,成功~
所以目前摆在我们面前的有两条路:
最后考虑到重新编译的 gcc 版本在安全、稳定、部署和分布式拓展方面都有很大的问题,最后选择了第二条路。
这里贴一个简单的 python 脚本
def changeStamp(gcda: Path, gcno: Path):
with gcda.open('rb+') as gcdaf:
with gcno.open('rb+') as gcnof:
gcdaf.seek(8)
stamp = gcdaf.read(4)
gcnof.seek(8)
gcnof.write(stamp)