高效使用 Java 构建工具,Maven 篇|云效工程师指北
大家好,我是胡晓宇,目前在云效主要负责 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。
在子模块 build-engine-api 的 pom.xml 中,由于在父 pom 中配置了 dependencyManagement 中依赖的 spring-boot 相关依赖的版本,因此在子模块的 pom 中,只需要在 dependencies 中直接声明依赖,确保了依赖版本的一致性。
合理的依赖范围
Maven 依赖有依赖范围(scope)的定义,compile/provieded/runtime/test/system/import,原则上,只按照实际情况配置依赖的范围,在必要的阶段,只引入必要的依赖。
90%的 Java 程序员应该都使用过 org.projectlombok:lombok 来简化我们的代码,其原理就是在编译过程中将注解转化为 Java 实现。因此该依赖的 scope 为 provided,也就是编译时需要,但在构建出最终产物时又需要被排除。
当你的代码需要使用 jdbc 连接一个 mysql 数据库,通常我们会希望针对标准 JDBC 抽象进行编码,而不是直接错误的使用 MySQL driver 实现。这个时候依赖的 scope 就需要设置为 runtime。这意味着我们在编译时无法使用该依赖,该依赖会被包含在最终的产物中,在程序最终执行时可以在 classpath 下找到它。
在子模块 dao 中,我们有对 sql 进行测试的场景,需要引入内存数据库 h2。
因此,我们将 h2 的 scope 设置为 test,这样我们在测试编译和执行时可以使用,同时避免其出现在最终的产物中。
更多关于 scope 的使用,可以参考官方帮助文档。
多环境支持
举个简单的例子,当我们的服务在公有云部署时,我们使用了一个云上版本为 8.0 的 MySQL,而当我们要进行专有云部署时,用户提供一个自运维的版本为 5.7 的 MySQL。因此,我们在不同的环境中使用不同的 mysql:mysql-connector-java 版本。
类似的,在项目实际的开发过程中,我们经常会面临同一套代码。在多套环境中部署,存在部分依赖不一致的情况。
关于 profiles 的更多用法,可以参考官方帮助文档
依赖纠错
如果你已经在父 pom 中使用 dependencyManagement 来锁定依赖版本,大概率的,你几乎很少会碰到依赖冲突的情况。
但是当你还是意外的看到了 NoSuchMethodError,ClassNotFoundException 这两个异常的时候,有以下两个方法可以快速的帮你纠错。
(1)通过依赖分析找到冲突的依赖
(2)通过添加 stdout 代码找到冲突的类实际是从哪个依赖中查找的
通过具体的路径中对应的版本信息,找到对应的版本并校正。
当然这个方法也可以纠出一些依赖被错误的加载到 classpath 下,非工程本身依赖配置引起的冲突。
测试构建过程加速
作为一个开发者,总会希望我们的工程无论在什么情况下,执行的又快又稳,那么在 Maven 的使用过程中,需要遵循以下原则。
● 尽可能复用缓存
● 尽可能的并行构建或测试
依赖下载加速
通常情况下,根据 Maven 配置文件 ${user.home}/.m2/settings.xml 中的配置,默认情况下是缓存在 ${user.home}/.m2/repository/。
通常在构建过程中,依赖的下载往往会成为比较耗时的部分,但是通过一些简单的设置,我们可以有效的减少依赖的下载与更新。
● 优化 updatePolicy 设置
updatePolicy 指定了尝试更新的频率。Maven 会将本地 POM 的时间戳(存储在存储库的 maven-metadata 文件中)与远程进行比较。选项包括:always(总是)、daily(每天,默认值)、interval:X(其中 X 是以分钟为单位的整数)、never(从不)。
● 使用离线构建
除此之外,如果构建环境已经存在缓存,可以使用 Maven 的 offline 模式进行构建,避免依赖或插件的下载更新。
直观的,日志中将不会出现类似如下 Downloading 相关的信息。
构建过程加速
在默认情况下,Maven 构建的过程并不会充分的使用你的硬件的全部能力,他会顺序的构建你的 maven 工程的每一个模块。这个时候,如果可以使用并行构建,那么将有机会提升构建速度。
以上是并行构建的两个命令,可以根据实际的 cpu 情况来选择对应的命令。但是如果你发现构建时间并没有得到减少,那么你的 maven 模块间可能存在类似的依赖,模块之间只是一个简单的传递。
那么并行构建对你来说并不适用,如果你的模块间依赖关系存在并行的可能,那么使用上述命令进行构建,才能使并行构建发挥效果。
测试过程加速
当我们尝试加速 maven 工程测试用例的部分,那么就不得不提到一个插件,maven-surefire-plugin。
当你在执行 mvn test 的时候,默认情况下就是 surefire 插件在工作。如果我们想在测试中使用并行的能力,可以作如下配置。
但是需要注意不恰当的使用并行能力进行测试,反而可能带来副作用。比如当 parallel 配置为 methods,但是由于某些原因测试用例的执行之间存在顺序要求,反而会出现因为用例方法并行执行,导致用例失败,因此也倒逼我们,如果想获得更快的测试速度,case 的编写也需要独立且高效。
更多关于 surefire 插件的使用,可以参考这篇文档。
Maven 插件开发
maven 本质上是一个插件执行框架,所有的执行过程,都是由一个一个插件独立完成的。关于 maven 的核心插件可以参考这篇文档。
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。
执行以下命令,或其他包含 validate 阶段执行的命令。
我们将会在日志中看到如下插件执行的信息
在扫描出缺陷时,build 失败,并会在日志中出现对应的信息:
在 GlobalLockMapper.java 这个文件中,我们有一条全表扫描的 sql 语句可能存在风险,
同时 build 失败。
接下来我会从如何开发这个异常 sql 扫描的 maven 插件入手,帮助大家了解插件开发的过程。
1、创建工程
生成的 sample 工程如下,
其中 MyMojo.java 定义了插件的入口实现,
此外在根 pom.xml 中可以看到,
● packaging 为“maven-plugin”。
● 依赖配置中,依赖了一些插件开发的基础二方库。
● 插件节点下,依赖了 maven-plugin-plugin 协助我们完成插件的构建。
2、Mojo 实现
在开始实现我们的 Mojo 之前,我们需要做如下分析:
● 插件在 maven 的哪个生命周期执行
● 插件在执行时需要哪些入口参数
● 插件执行完成后怎么退出
由于我们要实现的插件是要做 mybatis annotation 扫描比如 @Update/@Select,判断是否有异常的 sql,比如是否存在全表扫描的 sql,是否存在全表更新的 sql 等,对于此种场景下,
● 由于需要扫描特定的源码,需要知道工程源码的所在目录,以及扫描哪些文件
● 插件扫描出异常时,只要报错即可,不用产出任何报告
● 希望在后续执行 mvn validate 时触发扫描
那么预期中的插件是这样的,
那么,
● @Mojo(name = "check") 定义了 goal
● @Parameter○ @Parameter(defaultValue = "${project}", readonly = true) 参数绑定了工程的根目录 ,project.getCompileSourceRoots()便可以获取到源代码的根路径○ 我们定义了 mapperFiles,用来负责扫描哪些文件的通配,excludeFiles 用来负责排除哪些文件
● execute()○ 有了以上的基础,在 execute 方法中我们便可以实现对应的逻辑,当扫描结出异常的 sql 时,抛出 MojoFailureException 异常,插件便会失败终止。
以上,我们便完成了一个插件的基本能力的开发。
3、插件的打包与上传
插件开发完成后,我们可以通过配置 distributionManagement,然后执行 mvn deploy,完成插件的构建与发布。
希望通过我的介绍,能够帮助大家更好的使用 maven,下一篇我们讲 Gradle,欢迎持续关注我们。
点击下方链接,即可免费体验云效流水线 Flow。
https://www.aliyun.com/product/yunxiao/flow?channel=yy_practice
版权声明: 本文为 InfoQ 作者【阿里云云效】的原创文章。
原文链接:【http://xie.infoq.cn/article/a48718e53e44c311408e802a9】。文章转载请联系作者。
评论