基于 Jacoco 的 java 代码覆盖率收集服务设计

写 bug 的大耳朵图图
·

背景

下半年开始搞精准测试了,先搞一波代码覆盖率,因为公司绝大多数项目都是基于 Java 开发的,所以就先搞 Java 的了,主流的代码覆盖率工具是 Jacoco(其实我也只知道这一个),所以就直接基于 springboot 搞一个吧。

建设思路

  • 目标:为整个中心不同部门/项目组提供统一的Java 代码覆盖率收集能力
  • 前期准备:
  1. 收集试点项目的技术栈,包括:开发框架,部署架构。
  2. 了解不同项目组对代码覆盖率的使用场景,比如 TL 关心开发提交的代码是否夹带私货,是否存在 dead code。测试人员或者产品经理关心是否测试全面,想通过代码的变更点推导出业务上的影响面。开发人员关心自己本个迭代提交的代码是否都测试完全等。
  • 技术选型:
  1. 后端开发技术栈:Springboot,JacocoCli,Jgit,MavenCli,GradleToolingApi,MySQL,MyBatis
  2. 前端开发技术栈:vue2

应用架构设计(业务视角)

业务架构图不方便贴,就说下基本流程:

  1. 配置凭据,即Git 账号和密码,此处使用类似 Jenkins 凭据的方式来管理。
  2. 配置服务信息,如服务名称,git 仓库地址,环境类型,dump 端口(jacocoagent启动端口),ip 列表(同一个服务在不同环境有不同 ip,而且可能是多实例部署),选择凭据。
  3. 配置覆盖率采集任务,选择环境自动带出此环境下面的服务列表,填写任务名称,迭代信息,在选择服务的时候要选择分支,如果是增量覆盖率,则需要选择采集分支和基准分支,如果是全量覆盖率,则选择采集分支即可。
  4. 执行采集任务,生成执行记录和覆盖率报告。

应用架构设计(技术视角)

代码覆盖率只是整个测试平台的一个服务,测试平台使用 SBA 架构(服务导向的架构),整个平台架构图如下所示: OneTest代码覆盖率架构设计.jpg

代码覆盖率量子中只包含一个代码覆盖率容器,配合插件层的 jacocoagent 插件来协同工作。

安全方案

代码覆盖率收集服务,主要业务是:根据用户配置的代码库地址和GIT账密来收集 Java 应用服务在某个时间段内的代码覆盖情况。其中会涉及到用户在平台上填写以下数据:

  • 代码库地址(以下简称 代码库)
  • GIT账号和密码(以下简称 凭据)

同时生成的覆盖率报告会包含部分/所有的代码信息,因此需要对覆盖率报告也做数据权限的管控。 因为测试平台使用多租户模式,所以不同的租户下面数据是完全隔离的,不存在跨租户访问数据的情况,只需要解决水平越权和垂直越权的安全问题即可。

因为安全方案涉及到公司内部数据,所以只给出以下思路:

  1. 菜单权限/api权限:可以基于 Spring Security实现,或者其他安全框架实现
  2. 数据权限:对接口中的入参 by 租户进行校验,不允许访问未授权的测试库数据
  3. 数据安全:对 GIT 密码进行加密保存,可以选择 DES 或者 RSA 非对称加密

业务流程

OneTest代码覆盖率收集流程设计.jpg

代码覆盖率收集过程

Step-1:代码插桩

代码覆盖率收集过程基于 jacoco 的插桩来实现,jacoco 支持 2 中插桩方式:

On-the-fly 模式(即时模式):

插桩时机:在应用程序运行时,通过 Java 代理(Java Agent)将 JaCoCo 注入到 JVM 中。 数据收集方式:实时地收集代码覆盖率数据,即时生成运行时的覆盖率报告。

优点:

  • 可以实时地监测应用程序的执行和覆盖率情况。
  • 不需要对代码进行重新编译,使得在现有项目中使用更为方便。

缺点:

  • 在运行时对字节码进行修改,可能会对应用程序的性能产生一定影响。
  • 数据收集和报告生成过程会占用一些 CPU 和内存资源。

Offline 模式(离线模式):

插桩时机:在构建过程中对项目的字节码进行修改,通过构建工具(如 Maven 或 Gradle)进行插桩。 数据收集方式:在应用程序运行时,JaCoCo 会收集覆盖率数据并将其保存到执行文件中(通常是一个二进制格式的 .exec 文件)。

优点:

  • 不会对应用程序的运行性能造成直接影响。
  • 可以在构建过程中自动插桩,方便集成到自动化构建和持续集成流程中。

缺点:

  • 需要对项目进行重新编译,可能会增加构建过程的时间和开销。
  • 需要对执行文件进行处理,生成可读性更好的覆盖率报告。

Step-2:生成 exec 文件

exec 文件是 jacoco 默认的覆盖率数据文件类型,使用 on-the-fly 模式时,可以通过 Socket连接的方式,从远程服务器(即部署了 jacocoagent 的服务器) 上下载 exec 文件。JaCoCo 生成的 exec 文件是二进制文件,其中包含了代码覆盖率数据。它的结构如下:

Header(文件头部):包含了 exec 文件的元数据信息,如版本号和会话标识符。 Sessions(会话信息):存储了测试会话的信息,每个会话都有一个唯一的会话标识符。 Probes(探针信息):存储了所有被覆盖的代码块的信息。每个代码块都有一个唯一的标识符,并且将内联代码的情况进行了处理。 Execution Data(执行数据):实际的代码覆盖率数据。它记录了每个代码块是否被执行过。 具体来说,Header 部分包括以下信息:

Magic Number:一个特殊的标识符,用于识别文件类型。 Version:exec 文件的版本号。 Session Count:会话数量。 Session Infos:会话信息的起始位置。 Probe Count:探针数量。 Probe Infos:探针信息的起始位置。 Sessions 部分包括了每个会话的信息,例如会话标识符和会话名称。

Probes 部分包括了每个被覆盖的代码块的信息,例如代码块的标识符和分支相关的信息。

Execution Data 部分包括了实际的代码覆盖率数据,记录了每个代码块是否被执行过。

Step-3:代码差异比对(增量覆盖率才有此步骤)

code-diff 阶段用于比对代码差异,进而分析出代码变更点。代码变更包括以下几种情况:

新增,修改,删除不会被统计在内,因为文件已经被删除,不会产生覆盖率数据。如果是全量覆盖率报告生成,则会跳过此阶段。code-diff 功能使用 jgit 库和 javaparser 库的 API 实现。code-diff 流程如下:

下载采集分支代码,即新分支,如 feature,develop 等。 下载基准分支代码,即需要与之比较的分支,一般是 master 或者 release 等稳定分支。 使用 jgit 获取变更过得文件,即 java 源文件。 使用 javaparser 获取变更的类和方法,并记录方法信息(方法名,包名,方法签名等)。 生成 code-diff文件,用于后面生成增量报告。

Step-4:代码编译

代码编译过程用于将 java 源代码编译成 class 文件,用于生成覆盖率报告,在生成 JaCoCo 报告时,需要使用 class 文件和源码文件主要是为了对覆盖率数据进行解析和展示。

Class 文件:JaCoCo 通过分析 class 文件来获取代码结构和字节码信息。它包含了类、方法和字段的定义、修饰符以及字节码指令等信息。通过分析 class 文件,JaCoCo 可以确定每个代码块(如行、分支等)的起始和结束位置。

源码文件:源码文件是编写程序的原始文件,其中包含了开发人员编写的代码。在生成覆盖率报告时,JaCoCo 将覆盖率数据与源码文件进行关联,并进行代码染色,以显示被执行和未执行的代码行。这样,开发人员可以清楚地看到哪些行被覆盖,哪些行未被覆盖。本服务支持 Gradle 项目和 maven 项目编译,编译工具版本如下:

  • JDK:jdk11,jdk8
  • Gradle:gradle 7, gradle 6, gradle 5
  • Maven:maven 3.6.1

Step-5:报告生成

支持增量报告和全量报告生成。增量报告用于比较不同版本之间代码差异和覆盖率情况,全量报告直接展示新版本代码覆盖率情况。两种报告使用场景如下:

增量报告:

  • 新迭代差异代码覆盖情况,一般用于开发自测,检查变更的代码是否被执行

全量报告:

  • SIT 测试,通过代码覆盖情况间接表示功能覆盖情况
  • 全量回归测试,比如机房迁移,项目重构等,会统计此分支下所有代码的执行情况

技术方案

代码同步

直接使用 Jgit 即可 Maven 坐标如下:

<dependency>
    <groupId>org.eclipse.jgit</groupId>
    <artifactId>org.eclipse.jgit</artifactId>
    <version>6.5.0.202303070854-r</version>
</dependency>

示例代码如下:

public String clone(String repoUrl, String branch, String localPath, String username, String password) {
        log.info("开始克隆代码,代码库地址: {}", repoUrl);
        long startTime = System.currentTimeMillis();
        CredentialsProvider credentialsProvider = new UsernamePasswordCredentialsProvider(username, EncryptUtil.decryptByDes(password, desKey));
        String savePath = CoverageConstant.GIT_CLONE_TEMP_PATH + localPath;
        log.info("git clone repoUrl: {}, branch: {}, savePath: {}", repoUrl, branch, savePath);
        try (Git ignored = Git.cloneRepository()
                .setURI(repoUrl)
                .setBranch(branch)
                .setCredentialsProvider(credentialsProvider)
                .setDirectory(new File(savePath))
                .setDepth(1)
                .setCloneAllBranches(false)
                .call()) {
            log.info("git clone success");
        } catch (Exception e) {
            log.error("Git clone 异常,异常详情: {}", ExceptionUtil.getErrorMessage(e));
            throw new ServiceException("Git clone 异常");
        }
        log.info("代码克隆完成,耗时: {} s, 代码库地址: {}", (System.currentTimeMillis() - startTime) / 1000, repoUrl);
        return savePath;
    }

代码编译

Maven 项目编译

Maven 项目使用 Maven embedder 进行编译 Maven 坐标如下

<dependency>
    <groupId>org.apache.maven</groupId>
    <artifactId>maven-embedder</artifactId>
    <version>3.6.3</version>
    <exclusions>
        <exclusion>
            <groupId>javax.annotation</groupId>
            <artifactId>jsr250-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.apache.maven</groupId>
    <artifactId>maven-compat</artifactId>
    <version>3.6.3</version>
</dependency>
<dependency>
    <groupId>org.apache.maven.resolver</groupId>
    <artifactId>maven-resolver-connector-basic</artifactId>
    <version>1.6.3</version>
</dependency>
<dependency>
    <groupId>org.apache.maven.resolver</groupId>
    <artifactId>maven-resolver-transport-http</artifactId>
    <version>1.6.3</version>
</dependency>

示例代码如下:

@Component
@Slf4j
public class MavenBuildManagerImpl implements MavenBuildManager {
    @Override
    public void compiler(String codePath, List<String> commands) {
        log.info("开始编译Maven项目,代码路径: {}, 编译参数: {}", codePath, commands);
        long startTime = System.currentTimeMillis();
        File codeFile = new File(codePath);
        if (!codeFile.exists()) {
            throw new ServiceException("代码路径不存在");
        }
        MavenCli cli = new MavenCli();
        System.getProperties().setProperty(MavenCli.MULTIMODULE_PROJECT_DIRECTORY, MavenCli.USER_MAVEN_CONFIGURATION_HOME.getAbsolutePath());
        // 重定向标准错误输出流
        ByteArrayOutputStream errorStream = new ByteArrayOutputStream();
        PrintStream originalErrStream = System.err;
        System.setErr(new PrintStream(errorStream));
        int statusCode = cli.doMain(commands.toArray(new String[0]), codePath, System.out, System.err);
        // 恢复标准错误输出流
        System.setErr(originalErrStream);
        if (statusCode != 0) {
            log.error("代码: {} 编译失败, 异常详情: {}", codePath, errorStream);
            throw new ServiceException("编译失败");
        }
        log.info("结束编译Maven项目,编译耗时: {} s", (System.currentTimeMillis() - startTime) / 1000);
    }

    /**
     * 多模块代码扫描
     * @param codePath 代码路径
     * @return 模块列表
     */
    @Override
    public List<String> modules(String codePath) {
        List<String> modules;
        File pomFile = new File(codePath, "pom.xml");
        try {
            MavenXpp3Reader reader = new MavenXpp3Reader();
            Model model = reader.read(new FileReader(pomFile));
            modules = model.getModules();
        } catch (Exception e) {
            log.error("代码: {} 模块扫描失败, 异常详情: {}", codePath, ExceptionUtil.getErrorMessage(e));
            throw new ServiceException("模块扫描失败");
        }
        return modules;
    }
}

public class MavenCommand {

    /**
     * maven命令
     */
    public static final String MVN = "mvn";

    /**
     * 清理构建产物
     */
    public static final String CLEAN = "clean";

    /**
     * 编译 class 文件
     */
    public static final String COMPILE = "compile";

    /**
     * 打包
     */
    public static final String PACKAGE = "package";

    /**
     * 跳过测试
     */
    public static final String SKIP_TEST = "-Dmaven.test.skip=true";

    /**
     * 批量模式
     */
    public static final String BATCH_MODE = "--batch-mode";
    /**
     * 多核编译
     */
    public static final String PARALLEL = "-T 1C";
    /**
     * 多线程编译
     */
    public static final String FORK = "-Dmaven.compile.fork=true";

    /**
     * 通用编译命令
     */
    public static final List<String> COMMAND = new ArrayList<>(){{
        add(CLEAN);
        add(COMPILE);
        add(SKIP_TEST);
        add(BATCH_MODE);
        add(PARALLEL);
    }};
}

Gradle 项目编译

Gradle 项目使用 gradle tooling api进行编译 Maven 坐标如下:

<dependency>
    <groupId>org.netbeans.external</groupId>
    <artifactId>gradle-tooling-api</artifactId>
    <version>RELEASE170</version>
</dependency>

示例代码如下:

@Component
@Slf4j
public class GradleBuildManagerImpl implements GradleBuildManager {
    @Override
    public void compiler(String gradlePath, String codePath, List<String> commands) {
        log.info("开始编译Gradle项目,编译工具路径: {},代码路径: {}, 编译参数: {}", gradlePath, codePath, commands);
        long startTime = System.currentTimeMillis();
        // 重定向标准错误输出流
        ByteArrayOutputStream errorStream = new ByteArrayOutputStream();
        PrintStream originalErrStream = System.err;
        // 重定向标准输出流
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        PrintStream originalOutStream = System.out;
        try (ProjectConnection connection = GradleConnector.newConnector()
                .forProjectDirectory(new File(codePath))
                .useGradleUserHomeDir(CoverageConstant.DEFAULT_GRADLE_USER_HOME)
                .useInstallation(new File(gradlePath))
                .connect()) {
            BuildLauncher build = connection.newBuild();
            System.setErr(new PrintStream(errorStream));
            System.setOut(new PrintStream(outputStream));
            build.forTasks(commands.toArray(new String[0]))
                    .setStandardOutput(System.out)
                    .setStandardError(System.err)
                    // 跳过单测,多线程编译
                    .withArguments(GradleCommand.EXCLUDE, GradleCommand.TEST)
                    // 不使用彩色日志,否则会导致日志中的颜色代码被打印出来,导致日志不易阅读
                    .setColorOutput(false)
                    // 限制 gradle 内存,防止编译过程中内存溢出
                    .setJvmArguments("-Xmx512m");
            build.run();
            log.info("编译日志:\n {}", outputStream);
            System.setErr(originalErrStream);
            System.setOut(originalOutStream);
        } catch (Exception e) {
            log.error("代码: {} 编译失败, 异常详情: {}", codePath, ExceptionUtil.getErrorMessage(e));
            log.error("编译异常日志:\n {}", errorStream);
            throw new ServiceException("编译失败");
        }
        log.info("结束编译Gradle项目,编译耗时: {} s", (System.currentTimeMillis() - startTime) / 1000);
    }

    @Override
    public List<String> modules(String gradlePath, String codePath) {
        log.info("开始扫描Gradle项目模块,编译工具路径: {},代码路径: {}", gradlePath, codePath);
        try (ProjectConnection connection = GradleConnector.newConnector()
                .forProjectDirectory(new File(codePath))
                .useInstallation(new File(gradlePath))
                .connect()) {
            GradleProject model = connection.getModel(GradleProject.class);
            return model.getChildren().stream().map(GradleProject::getName).collect(Collectors.toList());
        } catch (Exception e) {
            log.error("代码: {} 模块扫描失败, 异常详情: {}", codePath, ExceptionUtil.getErrorMessage(e));
            throw new ServiceException("模块扫描失败");
        }
    }
}

public class GradleCommand {

    /**
     * gradle命令
     */
    public static final String GRADLE = "gradle";

    /**
     * 清理构建产物
     */
    public static final String CLEAN = "clean";

    /**
     * 打包
     */
    public static final String BUILD = "build";

    /**
     * 排除某个任务
     */
    public static final String EXCLUDE = "-x";

    /**
     * 测试
     */
    public static final String TEST = "test";

    /**
     * 多线程编译
     */
    public static final String PARALLEL = "-Dorg.gradle.parallel=true";

    /**
     * 多进程编译
     */
    public static final String FORK = "-Dorg.gradle.fork=true";

    /**
     * 编译class文件
     */
    public static final String CLASSES = "classes";

    /**
     * 通用编译命令
     */
    public static final List<String> COMMAND = new ArrayList<>(){{
        add(CLEAN);
        add(CLASSES);
    }};
}

差异代码比对

直接参考这个就好:https://gitee.com/Dray/code-diff/blob/master/src/main/java/com/dr/code/diff/service/impl/CodeDiffServiceImpl.java 原理就是直接通过jgit 分析出变更的文件,然后通过 javaparser 来分析代码

覆盖率报告生成

生成报告参考这个:https://gitee.com/Dray/code-diff/blob/master/src/main/java/com/dr/code/diff/jacoco/report/ReportAction.java 但是里面涉及到差异报告生成,就需要使用这个仓库提供的 jar 了

写在最后的话

准备提桶了,没啥心情写了,就先写这么多吧,希望诸位都能找到合适的工作 PS:代码编译比较耗费资源,记得改成 mq,触发任务采集的时候通过 mq 来做任务排队

参考文档

  1. https://www.jacoco.org/jacoco/trunk/doc/
  2. https://gitee.com/Dray/jacoco
  3. https://gitee.com/Dray/code-diff
  4. https://blog.csdn.net/Huang1178387848/article/details/114399056
  5. https://maven.apache.org/ref/3-LATEST/maven-embedder/index.html
  6. https://blog.csdn.net/ByteDanceTech/article/details/123059368
1
社区准则 博客 联系 社区 状态
主题