查看原文
其他

高效使用Java构建工具|Maven篇

胡晓宇 阿里云云栖号 2022-07-13

作 者 | 胡晓宇


大家好,我是胡晓宇,目前在云效主要负责Flow流水线编排、任务调度与执行引擎相关的工作。


作为一个有多年Java开发测试工具链开发经验的CRUD专家,使用过所有主流的Java构建工具,对于如何高效使用Java构建工具沉淀了一套方法。众所周知,当前最主流的Java构建工具为Maven/Gradle/Bazel,针对每一个工具,我将分别从日常工作中常见的场景问题切入,例如依赖管理、构建加速、灵活开发、高效迁移等,针对性地介绍如何高效灵活地用好这3个工具。


Java 构建工具的前世今生


在上古时代,Java的构建都在使用make,编写makefile来进行Java构建有非常多别扭与不便的地方。


紧接着Apache Ant诞生了,Ant可以灵活的定义清理编译测试打包等过程,但是由于没有依赖管理的功能,以及需要编写复杂的xml,还是存在着诸多的不便。


随后Apache Maven诞生了,Maven是一个依赖项管理和构建自动化工具,遵循着约定大于配置的规则。虽然也需要编写xml,但是对于复杂工程更加容易管理,有着标准化的工程结构,清晰的依赖管理。此外,由于Maven本质上是一个插件执行框架,也提供了一定的开放性的能力,我们可以通过Maven的插件开发,为构建构成创造一定的灵活性。


但是由于采用约定大于配置的方式,丧失了一定的灵活性,同时由于采用xml管理构建过程与依赖,随着工程的膨胀,配置管理还是会带来不小的复杂度,在这个背景下,集合了Ant与Maven各自优势的Gradle诞生了。


Gradle也是一个集合了依赖管理与构建自动化的工具。首要的他不再使用XML而是基于Groovy的DSL来描述任务串联起整个构建过程,同时也支持插件提供类似于Maven基于约定的构建。除了在构建依赖管理上的诸多优势之外,Gradle在构建速度上也更具优势,提供了强大的缓存与增量构建的能力。


除了以上Java构建工具之外,Google在2015年开源了一款强大,但上手难度较大的分布式构建工具Bazel,具有多语言、跨平台、可靠增量构建的特点,在构建上可以成倍提高构建速度,因为它只重新编译需要重新编译的文件。Bazel也提供了分布式远程构建和远程构建缓存两种方式来帮助提升构建速度。


目前业内使用Ant的人已经比较少,主要都在用Maven、Gradle和Bazel,如何真正基于这三款工具的特点发挥出他们最大的效用,是这个系列文章要帮大家解决的问题。先从Maven说起。


优雅高效地用好 Maven


当我们正在维护一个Maven工程时,关注以下三个问题,可以帮助我们更好的使用Maven。


  • 如何优雅的管理依赖


  • 如何加速我们的构建测试过程


  • 如何扩展我们自己的插件


优雅的依赖管理


在依赖管理中,有以下几个实践原则,可以帮助我们优雅高效的实现不同场景下的依赖管理。


  • 在父模块中使用dependencyManagement,配置依赖


  • 在子模块中使用dependencies,使用依赖


  • 使用profiles,进行多环境管理


以我在日常开发中维护的一个标准的spring-boot多模块Maven工程为例。



工程内各个module之间的依赖关系如下,通常这也是标准的 spring-boot restful api多模块工程的结构。



便捷的依赖升级


通常我们在依赖升级的时候会遇到以下问题:


  • 多个依赖关联升级


  • 多个模块需要一起升级


在父模块的pom.xml中,我们配置了基础的spring-boot依赖,也配置了日志输出需要的logback依赖,可以看出,我们遵循了以下的原则:


(1)在所有子模块的父模块中的pom中配置dependencyManagement,统一管理依赖版本。在子模块中直接配置依赖,不用再纠缠于具体的版本,避免潜在的依赖版本冲突。


(2)把groupId相同的依赖,配置在一起,比如groupId为org.springframework.boot,我们配置在了一起。


(3)把groupId相同,但是需要一组依赖共同提供功能的artifactId,配置在一起,同时将版本号抽取成变量,便于后续一组功能共同的版本升级。比如spring-boot依赖的版本抽取成了spring-boot.version。


<properties>
    ...
    <spring-boot.version>2.3.0.RELEASE</spring-boot.version>
    ...
    <logback.version>1.2.3.12-struct</logback.version>
    ...
    <h2.database.version>1.4.199</h2.database.version>
    ...
  </properties>

  <dependencyManagement>
    ...
    <dependencies>
      <!-- spring-boot -->
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-dependencies</artifactId>
        <version>${spring-boot.version}</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>${spring-boot.version}</version>
      </dependency>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-actuator</artifactId>
        <version>${spring-boot.version}</version>
      </dependency>
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <version>${spring-boot.version}</version>
        <scope>test</scope>
      </dependency>      
      ...
      <!-- log -->
      <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>${logback.version}</version>
      </dependency>
      <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-core</artifactId>
        <version>${logback.version}</version>
      </dependency>
      <!-- test db -->
      <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <version>${h2.database.version}</version>
        <scope>test</scope>
      </dependency>
      ...
    </dependencies>
  </dependencyManagement>


在子模块build-engine-api的pom.xml中,由于在父pom中配置了 

dependencyManagement中依赖的spring-boot相关依赖的版本,因此在子模块的pom中,只需要在dependencies中直接声明依赖,确保了依赖版本的一致性。


 <parent>
    <groupId>com.alibaba.aone</groupId>
    <artifactId>build-engine</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <relativePath>../pom.xml</relativePath>
  </parent>

   ...

  <dependencies>
     <dependency>
       <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
     </dependency>
     ...
  </dependencies>


合理的依赖范围


Maven依赖有依赖范围(scope)的定义,compile/provieded/runtime/test/system/import,原则上,只按照实际情况配置依赖的范围,在必要的阶段,只引入必要的依赖。


90%的Java程序员应该都使用过org.projectlombok:lombok来简化我们的代码,其原理就是在编译过程中将注解转化为Java实现。因此该依赖的scope为provided,也就是编译时需要,但在构建出最终产物时又需要被排除。


  ...
  <dependencies>
     <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
    ...
  </dependencies>
 ...


当你的代码需要使用JDBC连接一个MySQL数据库,通常我们会希望针对标准 JDBC 抽象进行编码,而不是直接错误的使用 MySQL driver实现。这个时候依赖的scope就需要设置为runtime。这意味着我们在编译时无法使用该依赖,该依赖会被包含在最终的产物中,在程序最终执行时可以在classpath下找到它。


  ...
  <dependencies>
     <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    ...
  </dependencies>
 ...


在子模块dao中,我们有对sql进行测试的场景,需要引入内存数据库h2。


因此,我们将h2的scope设置为test,这样我们在测试编译和执行时可以使用,同时避免其出现在最终的产物中。


  ...
  <dependencies>
     <dependency>
       <groupId>com.h2database</groupId>
       <artifactId>h2</artifactId>
       <scope>test</scope>
    </dependency>
    ...
  </dependencies>
 ...


更多关于scope的使用,可以参考官方帮助文档。


多环境支持


举个简单的例子,当我们的服务在公有云部署时,我们使用了一个云上版本为8.0的MySQL,而当我们要进行专有云部署时,用户提供一个自运维的版本为5.7的MySQL。因此,我们在不同的环境中使用不同的 mysql:mysql-connector-java 版本。


类似的,在项目实际的开发过程中,我们经常会面临同一套代码。在多套环境中部署,存在部分依赖不一致的情况。


<project>
  ...
  <profiles>
    <!-- 专有云 -->
    <profile>
      <id>private</id>
      <activation>
        <activeByDefault>true</activeByDefault>
      </activation>
      <properties>
        <mysql.version>5.1.40</mysql.version>
      </properties>
    </profile>
    <!-- 公有云 -->
    <profile>
      <id>public</id>
      <activation>
        <activeByDefault>true</activeByDefault>
      </activation>
      <properties>
        <mysql.version>8.0.12</mysql.version>
      </properties>
    </profile>
    ...
  </profiles>

  ...
  <dependencyManagement>
    <dependencies>
      ...
      <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>${mysql.version}</version>
      </dependency>
      ...
    </dependencies>
  </dependencyManagement>
  ...
</project>


profiles的更多用法,可以参考官方帮助文档。


https://maven.apache.org/guides/introduction/introduction-to-profiles.html


依赖纠错


如果你已经在父pom中使用dependencyManagement来锁定依赖版本,大概率的,你几乎很少会碰到依赖冲突的情况。


但是当你还是意外的看到了NoSuchMethodError,ClassNotFoundException 这两个异常的时候,有以下两个方法可以快速的帮你纠错。


(1)通过依赖分析找到冲突的依赖


# 查看完整的依赖树
mvn dependency:tree
# 查看特定依赖的版本
mvn dependency:tree -Dincludes=commons-lang:commons-lang


(2)通过添加stdout代码找到冲突的类实际是从哪个依赖中查找的


System.out.println(CollectionUtils.class.getProtectionDomain().getCodeSource().getLocation());


通过具体的路径中对应的版本信息,找到对应的版本并校正。


当然这个方法也可以纠出一些依赖被错误的加载到classpath下,非工程本身依赖配置引起的冲突。


测试构建过程加速


作为一个开发者,总会希望我们的工程无论在什么情况下,执行的又快又稳,那么在Maven的使用过程中,需要遵循以下原则。


  • 尽可能复用缓存


  • 尽可能的并行构建或测试


依赖下载加速


通常情况下,根据Maven配置文件 ${user.home}/.m2/settings.xml 中的配置,默认情况下是缓存在${user.home}/.m2/repository/。


<settings>
  ...
  <localRepository>/path/to/local/repo/</localRepository>
  ...
</settings>


通常在构建过程中,依赖的下载往往会成为比较耗时的部分,但是通过一些简单的设置,我们可以有效的减少依赖的下载与更新。


  • 优化updatePolicy设置


updatePolicy指定了尝试更新的频率。Maven 会将本地 POM 的时间戳(存储在存储库的 maven-metadata 文件中)与远程进行比较。选项包括:always(总是)、daily(每天,默认值)、interval:X(其中 X 是以分钟为单位的整数)、never(从不)。


<settings>
  ...
  <profiles>
    <profile>
      ...
      <repositories>
        <repository>
          <id>codehausSnapshots</id>
          <name>Codehaus Snapshots</name>
          <releases>
            <enabled>false</enabled>
            <updatePolicy>always</updatePolicy>
            <checksumPolicy>warn</checksumPolicy>
          </releases>
          <snapshots>
            <enabled>true</enabled>
            <updatePolicy>never</updatePolicy>
            <checksumPolicy>fail</checksumPolicy>
          </snapshots>
          <url>http://snapshots.maven.codehaus.org/maven2</url>
          <layout>default</layout>
        </repository>
      </repositories>
      <pluginRepositories>
        <pluginRepository>
          <id>myPluginRepo</id>
          <name>My Plugins repo</name>
          <releases>
            <enabled>true</enabled>
          </releases>
          <snapshots>
            <enabled>false</enabled>
          </snapshots>
          <url>https://maven-central-eu....com/maven2/</url>
        </pluginRepository>
      </pluginRepositories>
      ...
    </profile>
  </profiles>
  ...
</settings>


  • 使用离线构建


除此之外,如果构建环境已经存在缓存,可以使用Maven的offline模式进行构建,避免依赖或插件的下载更新。


mvn clean install -o


直观的,日志中将不会出现类似如下Downloading相关的信息。



构建过程加速


在默认情况下,Maven构建的过程并不会充分的使用你的硬件的全部能力,他会顺序的构建你的maven工程的每一个模块。这个时候,如果可以使用并行构建,那么将有机会提升构建速度。


# 使用4个线程进行构建
mvn -T 4 install


# 每个cpu核心使用一个线程进行构建
mvn -T 1C install


以上是并行构建的两个命令,可以根据实际的cpu情况来选择对应的命令。但是如果你发现构建时间并没有得到减少,那么你的maven模块间可能存在类似的依赖,模块之间只是一个简单的传递。



那么并行构建对你来说并不适用,如果你的模块间依赖关系存在并行的可能,那么使用上述命令进行构建,才能使并行构建发挥效果。



测试过程加速


当我们尝试加速maven工程测试用例的部分,那么就不得不提到一个插件,maven-surefire-plugin。


当你在执行mvn test的时候,默认情况下就是surefire插件在工作。如果我们想在测试中使用并行的能力,可以作如下配置。


...
  <plugins>
    ....
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.0.0-M3</version>
      <configuration>
        <parallel>classes</parallel>
        <threadCount>8</threadCount>
      </configuration>
    </plugin>
    ...
  </plugins>
...


但是需要注意不恰当的使用并行能力进行测试,反而可能带来副作用。比如当parallel配置为methods,但是由于某些原因测试用例的执行之间存在顺序要求,反而会出现因为用例方法并行执行,导致用例失败,因此也倒逼我们,如果想获得更快的测试速度,case的编写也需要独立且高效。


更多关于surefire插件的使用,可以参考这篇文档。


https://maven.apache.org/surefire/maven-surefire-plugin/examples/fork-options-and-parallel-execution.html


Maven插件开发


maven本质上是一个插件执行框架,所有的执行过程,都是由一个一个插件独立完成的。关于maven的核心插件可以参考这篇文档。


https://maven.apache.org/plugins/index.html


maven默认为我们提供的这些插件比如maven-install-plugin/mvn-surefire-plugin/mvn-deploy-plugin外,还有一些三方提供的插件,单测覆盖率插件mvn-jacoco-plugin,生成api文档的swagger-maven-plugin等等。


在日常工作的过程中,我碰到了这样一个问题:有个存在明显问题的sql被发布到了预发布环境,同时由于预发与生产使用的是同一个db实例,由于sql的性能问题,影响了线上。


除了通过必要的code review准入,来避免类似的问题,更简单的,我们可以自己动手实现一个代码中sql扫描的插件,让代码在CI时直接失败掉,自动化的避免此类问题的发生。于是我们开发了一个maven插件,使用方法和效果如下:


在工程中引入我们开发并部署好的插件com.aliyun.yunxiao:mybatis-sql-scan。


  <plugin>
    <groupId>com.aliyun.yunxiao</groupId>
    <artifactId>mybatis-sql-scan</artifactId>
    <version>1.0-SNAPSHOT</version>
    <configuration>
      <mapperFiles>.*Mapper.java;</mapperFiles>
      <excludeFiles>.*/someMapper.java</excludeFiles>
    </configuration>
    <executions>
      <execution>
        <phase>validate</phase>
        <goals>
          <goal>check</goal>
        </goals>
      </execution>
    </executions>
  </plugin>


执行以下命令,或其他包含validate阶段执行的命令。


mvn validate


我们将会在日志中看到如下插件执行的信息。



在扫描出缺陷时,build失败,并会在日志中出现对应的信息:


在GlobalLockMapper.java这个文件中,我们有一条全表扫描的sql语句可能存在风险,



同时build失败。



接下来我会从如何开发这个异常sql扫描的maven插件入手,帮助大家了解插件开发的过程。


1.创建工程


mvn archetype:generate -DarchetypeGroupId=org.apache.maven.archetypes -DarchetypeArtifactId=maven-archetype-plugin


生成的sample工程如下,



其中MyMojo.java定义了插件的入口实现,


此外在根pom.xml中可以看到,


  • packaging为“maven-plugin”。


  • 依赖配置中,依赖了一些插件开发的基础二方库。


  • 插件节点下,依赖了maven-plugin-plugin协助我们完成插件的构建。


<project ...>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.aliyun.yunxiao</groupId>
  <artifactId>mybatis-sql-scan</artifactId>
  <packaging>maven-plugin</packaging>
  <version>1.0-SNAPSHOT</version>
  <name>mybatis-scan Maven Plugin</name>

  ...
  <dependencies>   
    <!-- plugin API and plugin-tools -->
    <dependency>
      <groupId>org.apache.maven</groupId>
      <artifactId>maven-plugin-api</artifactId>
      <version>3.5.2</version>
    </dependency>
    <dependency>
      <groupId>org.apache.maven.plugin-tools</groupId>
      <artifactId>maven-plugin-annotations</artifactId>
      <version>3.5.2</version>
      <scope>provided</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.maven.shared</groupId>
      <artifactId>maven-shared-utils</artifactId>
      <version>3.2.0</version>
    </dependency>
    ...    
  </dependencies>


  <build>
    <plugins>
      ...
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-plugin-plugin</artifactId>
        <version>3.5.1</version>
        <configuration>
          <goalPrefix>scan</goalPrefix>
        </configuration>
        <executions>
          <execution>
            <id>default-descriptor</id>
            <phase>process-classes</phase>
          </execution>
        </executions>
      </plugin>
      ...
    </plugins>
  </build>

  ...
</project>


2.Mojo实现


在开始实现我们的Mojo之前,我们需要做如下分析:


  • 插件在maven的哪个生命周期执行


  • 插件在执行时需要哪些入口参数


  • 插件执行完成后怎么退出


由于我们要实现的插件是要做mybatis annotation扫描比如 @Update/@Select,判断是否有异常的sql,比如是否存在全表扫描的sql,是否存在全表更新的sql等,对于此种场景下,


  • 由于需要扫描特定的源码,需要知道工程源码的所在目录,以及扫描哪些文件


  • 插件扫描出异常时,只要报错即可,不用产出任何报告


  • 希望在后续执行mvn validate时触发扫描


那么预期中的插件是这样的,


  ...
  <plugin>
    <groupId>com.aliyun.yunxiao</groupId>
    <artifactId>mybatis-sql-scan</artifactId>
    <version>1.0-SNAPSHOT</version>
    <configuration>
      <mapperFiles>.*Mapper.java;</mapperFiles>
      <excludeFiles>.*/someMapper.java</excludeFiles>
    </configuration>
    <executions>
      <execution>
        <phase>validate</phase>
        <goals>
          <goal>check</goal>
        </goals>
      </execution>
    </executions>
  </plugin>
  ...


那么,


  • @Mojo(name = "check") 定义了goal


  • @Parameter


  •  @Parameter(defaultValue = "${project}", readonly = true) 参数绑定了工程的根目录 ,project.getCompileSourceRoots()便可以获取到源代码的根路径


  • 我们定义了mapperFiles,用来负责扫描哪些文件的通配,excludeFiles用来负责排除哪些文件


  • execute()


  • 有了以上的基础,在execute方法中我们便可以实现对应的逻辑,当扫描结出异常的sql时,抛出MojoFailureException异常,插件便会失败终止。


package com.aliyun.yunxiao;

import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;


@Mojo(name = "check")
public class CheckMojo extends AbstractMojo {

    @Parameter(defaultValue = "${project}", readonly = true)
    private MavenProject project;

    @Parameter
    private String mapperFiles;

    @Parameter
    private String excludeFiles;

    @Override
    public void execute() throws MojoExecutionException, MojoFailureException {
        getLog().info("execute risk sql check");

        //  do scan logic,print out scan result
        //  ...
        boolean scanResult = doScanLogic();

        //  scan result contains more than one invalid sql
        if (!scanResult) {
            throw new MojoFailureException("scan failed.");
        }
    }
}


以上,我们便完成了一个插件的基本能力的开发。


3.插件的打包与上传


插件开发完成后,我们可以通过配置distributionManagement,然后执行mvn deploy,完成插件的构建与发布。


<project>
  ...
  <distributionManagement>
    <repository>
      <id>releases</id>
      <url>...</url>
    </repository>
    <snapshotRepository>
      <id>snapshots</id>
      <url>...</url>
    </snapshotRepository>
  </distributionManagement>
  ...
</project>




云栖号的伙伴群开启了,欢迎大家入群聊起来!
大家想看什么内容,我们可以一起聊聊~
👇👇👇

 ✨ 精彩推荐✨  

 技 术 好 文 

交付铁三角的故事之兵戎相见

如何设计一个易扩展、易运维的内容下发服务架构?

前端实现多文件编译器

Spock 单测利器的写法


 企 业 案 例 

🔥 企业上云|数字化转型经验分享

云钉低代码新模式、新能力、新机遇

推文科技:AI 解决方案助力内容出海

硬之城:云钉一体,智能智造产业链升级



↓ 直通阿里云 ☁️  

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存