速读原著-Gradle 在大型 Java 项目上的应用
- 2020 年 2 月 14 日
- 笔记
Gradle 在大型 Java 项目上的应用
在 Java 构建工具的世界里,先有了 Ant,然后有了 Maven。Maven 的 CoC[1]、依赖管理以及项目构建规则重用性等特点,让 Maven 几乎成为 Java 构建工具的事实标准。然而,冗余的依赖管理配置、复杂并且难以扩展的构建生命周期,都成为使用 Maven 的困扰。
Gradle 作为新的构建工具,获得了 2010 Springy 大奖,并入围了 2011 的 Jax 最佳 Java 技术发明奖。它是基于 Groovy 语言的构建工具,既保持了 Maven 的优点,又通过使用 Groovy 定义的DSL[2],克服了Maven 中使用XML繁冗以及不灵活等缺点。在Eugene Dvorkin 撰写的文章《最让人激动的 5 个Java 项目》中,他是这样介绍Gradle 的:
“工程自动化是软件项目成功的必要条件,而且它应该实现起来简单、易用、好玩。构建没有千 篇一律的方法,所以 Gradle 没有死板的强加方法于我们,尽管你会认为查找和描述方法很重要, 然而 Gradle 对于如何描述有着非常好的支持。我不认为工具能够拯救我们,但是 Gradle 能给你所需要的自由,你可以利用 Gradle 构建易描述的、可维护的、简洁的、高性能项目”。
在最近半年里,我在使用Gradle 作为构建脚本的大型Java 项目上工作,更深切体会到Gradle 在项目构建过程中是如此的简单、易用。
1.多Module 的项目
Hibernate 项目负责人 Steve Ebersole 在Hibernate 将构建脚本从 Maven 换成Gradle 时,专门写了一篇文章“Gradle: why?”,文中提到 Maven 的一个缺点就是:Maven 不支持多 module 的构建。在 Micro-Service[3]架构风格流行的今天,在一个项目里面包含多个 Module 已成为一种趋势。Gradle 天然支持多 module,并且提供了很多手段来简化构建脚本。在 Gradle 中,一个模块就是它的一个子项目(subproject),所以,我使用父项目来描述顶级项目,使用子项目来描述顶级项目下面的模块。
1.1配置子项目
在多模块的项目中,Gradle 遵循惯例优于配置(Convention Over Configuration)原则。
在父项目的根目录下寻找 settings.gradle 文件,在该文件中设置想要包括到项目构建中的子项目。在构建的初始化阶段(Initialization),Gradle 会根据 settings.gradle 文件来判断有哪些子项目被include 到了构建中,并为每一个子项目初始化一个 Project 对象, 在构建脚本中通过project(‘:sub-project-name’)来引用子项目对应的 Project 对象。
通常,多模块项目的目录结构要求将子模块放在父项目的根目录下,但是如果有特殊的目录结构,可以在settings.gradle 文件中配置。 我所在的项目包括:
- 一个描述核心业务的 core 模块
- 一个遗留的 Enterprise Java Bean(enterprise-beans)模块
- 两个提供不同服务的 Web 项目(cis-war 和 admin-war)
- 一个通过 schema 生成 jaxb 对象的 jaxb 项目以及一个用来用来打 ear 包的 ear 项目
- 一个用于存放项目配置文件相关的 config 子目录。它不是子模块,所以 config 不应该被加到项目的构建中去。
它们都放置在根项目目录下。我们通过如下的 settings.gradle 来设置项目中的子项目:
include 'core', 'enterprise-beans', 'cis-war', 'admin-war', 'jaxb', 'ear'
我们将需要加入到项目构建中的子项目配置在 settings.gradle 文件中,而没有加入不需要的config 子目录。
1.2共享配置
在大型 Java 项目中,子项目之间必然具有相同的配置项。我们在编写代码时,要追求代码重用和代码整洁;而在编写 Gradle 脚本时,同样需要保持代码重用和代码整洁。Gradle 提供了不同的方式使不同的项目能够共享配置。
- allprojects:allprojects 是父 Project 的一个属性,该属性会返回该 Project 对象以及其所有子项目。在父项目的build.gradle脚本里,可以通过给allprojects传一个包含配置信息的闭包, 来配置所有项目(包括父项目)的共同设置。通常可以在这里配置 IDE 的插件,group 和version 等信息,比如:
allprojects { apply plugin: 'idea' }
这样就会给所有的项目(包括当前项目以及其子项目)应用上 idea 插件。
- subprojects:subprojects 和 allprojects 一样,也是父 Project 的一个属性,该属性会返回所有子项目。在父项目的 build.gradle 脚本里,给 subprojects 传一个包含配置信息的闭包,可以配置所有子项目共有的设置,比如共同的插件、repositories、依赖版本以及依赖配置:
subprojects { apply plugin: 'java' repositories { mavenCentral() } ext { guavaVersion = ’14.0 .1’ junitVersion = ‘4.10’ } dependencies { compile( “com.google.guava: guava: $ { guavaVersion }” ) testCompile( “junit: junit: $ { junitVersion }” ) } }
这就会给所有子项目设置上 java 的插件、使用 mavenCentral 作为 所有子项目的 repository 以及对 Guava[4]和 JUnit 的项目依赖。此外,这里还在 ext 里配置依赖包的版本,方便以后升级依赖的版本。
- configure:在项目中,并不是所有的子项目都会具有相同的配置,但是会有部分子项目具有相同的配置,比如在我所在的项目里除了 cis-war 和 admin-war 是 web 项目之外,其他子项目都不是。所以需要给这两个子项目添加 war 插件。Gradle 的 configure 可以传入子项目数组,并为这些子项目设置相关配置。在我的项目中使用如下的配置:
configure(subprojects.findAll {it.name.contains('war')}) { apply plugin: 'war' }
configure 需要传入一个 Project 对象的数组,通过查找所有项目名包含 war 的子项目,并为其设置war 插件。
1.3独享配置
在项目中,除了设置共同配置之外, 每个子项目还会有其独有的配置。比如每个子项目具有不同的依赖以及每个子项目特殊的 task 等。Gradle 提供了两种方式来分别为每个子项目设置独有的配置。
- 在父项目的 build.gradle 文件中通过 project(‘:sub-project-name’)来设置对应的子项目的配置。比如在子项目 core 需要 Hibernate 的依赖,可以在父项目的 build.gradle 文件中添加如下的配置:
project(‘: core’) { ext { hibernateVersion = ‘4.2 .1.Final’ } dependencies { compile“ org.hibernate: hibernate - core: $ { hibernateVersion }” } }
注意:这里子项目名字前面有一个冒号(:)。 通过这种方式,指定对应的子项目,并对其进行配置。
- 我们还可以在每个子项目的目录里建立自己的构建脚本。在上例中,可以在子项目 core 目录下为其建立一个 build.gradle 文件,并在该构建脚本中配置 core 子项目所需的所有配置。例如,在该 build.gradle 文件中添加如下配置:
ext { hibernateVersion = ‘4.2 .1.Final’ } dependencies { compile“ org.hibernate: hibernate - core: $ { hibernateVersion }” }
根据我对 Gradle 的使用经验,对于子项目少,配置简单的小型项目,推荐使用第一种方式配置, 这样就可以把所有的配置信息放在同一个build.gradle 文件里。例如我同事郑晔的开源项目moco。它只有两个子项目,因而就使用了第一种方式配置,在项目根目录下的 build.gradle 文件中设置项目相关的配置信息。但是,若是对于子项目多,并且配置复杂的大型项目,使用第二种方式 对项目进行配置会更好。因为,第二种配置方式将各个项目的配置分别放到单独的 build.gradle 文件中去,可以方便设置和管理每个子项目的配置信息。
1.4其他共享
在 Gradle 中,除了上面提到的配置信息共享,还可以共享方法以及 Task。可以在根目录的build.gradle 文件中添加所有子项目都需要的方法,在子项目的 build.gradle 文件中调用在父项目build.gradle 脚本里定义的方法。例如我定义了这样一个方法,它可以从命令行中获取属性,若没有提供该属性,则使用默认值:
def defaultProperty(propertyName, defaultValue) { return hasProperty(propertyName) ? project[propertyName] : defaultValue }
注意,这段脚本完全就是一段Groovy 代码,具有非常好的可读性。
由于在父项目中定义了 defaultProperty 方法,因而在子项目的 build.gradle 文件中,也可以调用该方法。
2.环境的配置
为了方便地将应用部署到开发、测试以及产品等不同环境上, Gradle 提供了几种不同的方式为不同的环境打包,使得不同的环境可以使用不同的配置文件。此外,它还提供了简单的方法, 使得我们能够便捷地初始化数据库。
2.1Properties 配置
要为不同的环境提供不一样的配置信息,Maven 选择使用 profile,而 Gradle 则提供了两种方法为构建脚本提供Properties 配置:
- 第一种方式是使用传统的 properties 文件, 然后在使用 Gradle 时,通过传入不同的参数加载不同的 properties 文件。例如, 我们可以在项目中提供 development.properties 、test.properties 和 production.properties。在项目运行时,使用-Pprofile=development 来指定加载开发环境的配置。构建脚本中加载 properties 文件的代码如下:
在运行脚本的时候,传入的-Pprofile=development 可以指定使用哪个运行环境的配置文件。代码中使用了project[‘profile’]从命令行里读取-P 传入的参数,Gradle 会去父项目根目录下的config 文件夹中需找对应的properties 文件。
- 另外一种方式就是使用Groovy 的语法,定义可读性更高的配置文件。比如可以在项目中定义 config.groovy 的配置文件,内容如下:
environments { development { jdbc { url = 'development' user = 'xxxx' } } test { jdbc { url = 'test' user = 'xxxx' password = 'xxxx' } } production { jdbc { url = 'production' user = 'xxxx' password = 'xxxx' } } }
这里定义了三个环境下的不同数据库配置,在构建脚本中使用如下的代码来加载:
这里在 ConfigSlurper 的构造函数里传入从命令行里取到的-P 的参数。调用 loadGroovy 方法就可以加载项目根目录下的 config.groovy 文件,并作为一个 Map 返回,这样就可以通过 jdbc.url 来获取 url 的值。 从可读性以及代码整洁(配置文件也需要代码整洁)而言,我推荐使用第二种方式来配置,因为这种方法具有清晰的结构。如上面的例子,就可以把数据库相关的信息都放在 jdbc 这个大的节点下,而不用像 properties 文件这样的扁平结构。但是对于一些已经使用 properties 文件来为不同环境提供配置信息的遗留项目里,使用 properties 文件也没有问题。
2.2替换
通过不同的方式加载不同环境的配置后,就需要把它们替换到有占位符的配置文件中去。在配置文件中使用@key@来标注要被替换的位置,比如在config 文件夹中有jdbc.properties 文件,其内容如下:
在 Gradle 构建过程中,有一个 processResources 的 Task,可以修改该 Task 的配置,让其在构建过程中替换资源文件中的占位符:
上面这种做法用来处理子项目 src/main/resources
文件夹下的资源文件,所以需要将这段代码放在子项目的独立配置文件里。
在一些复杂的项目中,经常会把配置文件放置到一个目录进行统一管理。比如在我所在的项目, 就专门提供了一个 config 子目录,里面存放了所有的配置信息。在处理这些资源文件时, Gradle 默认提供的 processResources 就不够用了,我们需要在 Gradle 脚本中定义一个 Task 去替换这些包含占位符的配置文件,然后让 package 或者 deploy 的 Task 依赖这个 Task。该 Task 的 代码如下:
task replace(type: Sync) { def configHome = "${project.rootDir}/config" from(configHome) { include '**/*.properties' include '**/*.xml' filter org.apache.tools.ant.filters.ReplaceTokens, tokens: loadGroovyConfig() } into "${buildDir}/resources/main" }
这里定义了一个 Sync 类型的 Task,会将父项目的根目录下的 config 文件夹的所有 properties 和xml 文件使用从loadGroovyConfig()方法中加载出来的配置替换,并将替换之后的文件放到build 文件夹下的resource/main 目录中。再让打包的Task 依赖这个Task,就会把替换之后的配置文件打到包中。
2.3更复杂的情况
上面介绍了在项目中如何使用Gradle 处理properties 和xml 文件中具有相同配置,但其中的一些值并不相同的情况 。然而,在有些项目中不同的环境配置之间变化的不仅是值,很有可能整个配置文件都不相同;那么,使用上面替换的处理方式就无法满足要求了。
在我所在的项目中,我们需要依赖一个外部的Web Service。在开发环境上,我们使用了Stub 来模拟和Web Service 之间的交互,为开发环境提供测试数据,这些数据都放置在一个Spring 的配置文件中;而在测试和产品环境上,又要使用对应的测试和产品环境的 Web Service。这时,开发、测试与产品环境的配置完全不同。对于这种复杂的情况,Gradle 可以在构建过程中为不同的环境指定不同的资源文件夹,在不同的资源文件夹中包含不同的配置文件。
例如,在我们项目的 config 目录下包含了 application 文件夹,定义了不同环境所需的不同配置文件,其目录结构如下图所示:
在构建脚本中,根据从命令行读入的-P 参数,使用不同的资源文件夹,其代码如下:
sourceSets { main { resources { srcDir "config/application/spring/${profile}", "config/application/properties/${profile}" } } }
这样在打包的过程中,就可以使用-P 传入的参数的资源文件夹下面的properties 和xml 文件作为项目的配置文件。
2.4 初始化数据库
在项目开发过程中,为了方便为不同环境构建相同的数据库及数据,我们通常需创建数据库的表以及插入一些初始化数据。Gradle 目前没有提供相关的 Task 或者 Plugin,但是我们可以自己创建Task 去运行 SQL 来初始化各个环境上的数据库。
前面也提到Gradle 是Groovy 定义的 DSL,所以我们可以在 Gradle 中使用Groovy 的代码来执行SQL 脚本文件。在 Gradle 脚本中,使用 Groovy 加载数据库的 Driver 之后,就可以使用 Groovy 提供的 Sql 类去执行SQL 来初始化数据库了。代码如下:
这段代码会初始化执行 SQL 的 groovy.sql.Sql 对象,然后按照分号(;)分割 SQL 脚本文件里的每一条 SQL 并执行。对于一些必须运行成功的 SQL 文件,可以在 catch 块里通过抛出异常来中止数据库的初始化。需要注意的是需要将数据库的 Driver 加载到 ClassPath 里才可以正确地执行。
因为在 Gradle 中包含了 Ant,所以我们除了使用 Groovy 提供的 API 来执行 SQL 之外,还可以使用 Ant 的 sql 任务来执行 SQL 脚本文件。但若非特殊情况,我并不推荐使用 Ant 任务,这部分内容与本文无关,这里不再细述。
3.代码质量
代码质量是软件开发质量的一部分,除了人工代码评审之外,在把代码提交到代码库之前,还应该使用自动检查工具来自动检查代码,来保证项目的代码质量。下面介绍一下 Gradle 提供的支持代码检查的插件。
3.1 CheckStyle
CheckStyle 是 SourceForge 下的一个项目,提供了一个帮助 JAVA 开发人员遵守某些编码规范的工具。它能够自动化代码规范检查过程,从而使得开发人员从这项重要却枯燥的任务中解脱出来。Gradle 官方提供了CheckStyle 的插件,在 Gradle 的构建脚本中只需要应用该插件:
apply plugin: 'checkstyle'
默认情况下,该插件会找/config/checkstyle/checkstyle.xml 作为 CheckStyle 的配置文件,可以在 checkstyle 插件的配置阶段(Configuration) 设置CheckStyle 的配置文件:
checkstyle{ configFile = file('config/checkstyle/checkstyle-main.xml') }
还可以通过checkstyle 设置 CheckStyle 插件的其他配置。
3.2 FindBugs
FindBugs 是一个静态分析工具,它检查类或者 JAR 文件,将字节码与一组缺陷模式进行对比以发现可能的问题。Gradle 使用如下的代码为项目的构建脚本添加 FindBugs 的插件:
apply plugin: 'findbugs'
同样也可以在 FindBugs 的配置阶段(Configuration)设置其相关的属性,比如 Report 的输出目录、检查哪些sourceSet 等。
3.3JDepend
在开发 Java 项目时经常会遇到关于包混乱的问题,JDepend 工具可以帮助你在开发过程中随时跟踪每个包的依赖性(引用/被引用),从而设计高维护性的架构,不论是在打包发布还是版本升级都会更加轻松。在构建脚本中加入如下代码即可:
apply plugin: 'jdepend'
3.4 PMD
PMD 是一种开源分析 Java 代码错误的工具。与其他分析工具不同的是,PMD 通过静态分析获知代码错误,即在不运行Java 程序的情况下报告错误。PMD 附带了许多可以直接使用的规则, 利用这些规则可以找出 Java 源程序的许多问题。此外,用户还可以自己定义规则,检查 Java 代码是否符合某些特定的编码规范。在构建脚本中加入如下代码:
apply plugin: 'pmd'
3.5 小结
上面提到的几种代码检查插件 apply 到构建脚本之后,可以运行:
gradle check
来执行代码质量检查。更详细的信息请查阅 Gradle 的官方文档。运行结束后会在对应的项目目录下的 build 文件夹下生成 report。
对于 Gradle 没有提供的代码检查工具,我们可以有两种选择:第一就是自己实现一个 Gradle 插件,第二就是调用 Ant 任务,让 Ant 作为一个媒介去调用在 Ant 中已经有的代码检查工具,比 如测试覆盖率的 Cobertura。我们的项目使用了 Ant 来调用 Cobertura,但是为了使用方便,我们将它封装为一个Gradle 插件,这样就可以在不同的项目里重用。
4.依赖
几乎每个 Java 项目都会用到开源框架。同时,对于具有多个子模块的项目来说,项目之间也会 有所依赖。所以,管理项目中对开源框架和其他模块的依赖是每个项目必须面对的问题。同时, Gradle 也使用Repository 来管理依赖。
4.1 Jar 包依赖管理
Maven 提出了使用 Repository 来管理 Jar 包,Ant 也提供了使用 Ivy 来管理 jar 包。Gradle 提供了对所有这些Respository 的支持,可以从Gradle 的官方文档上了解更详细的信息。
Gradle 沿用 Maven 的依赖管理方法,通过 groupId、name 和 version 到配置的 Repository 里寻找指定的 Jar 包。同样,它也提供了和 Maven 一样的构建生命周期, compile、runtime、testCompile 和 testRuntime 分别对应项目不同阶段的依赖。通过如下方式为构建脚本指定依赖:
这里分别指定group、name 以及 version,但是Gradle 提供了一种更简单的方式来指定依赖:
这样比 Maven 使用 XML 来管理依赖简单多了,但是还可以更简单一点。实际上这里的 compile 和testCompile 是Groovy 为Gradle 提供的方法,可以为其传入多个参数,所以当compile 有多个Jar 包依赖的时候,可以同时指定到 compile 里去,代码如下:
另外,当在 Respository 无法找到 Jar 包时(如数据库的 driver),就可以将这些 Jar 包放在项目的一个子目录中,然后让项目管理依赖。例如,我们可以在项目的根目录下创建一个 lib 文件夹, 用以存放这些Jar 包。使用如下代码可以将其添加到项目依赖中:
4.2 子项目之间的依赖
对于多模块的项目,项目中的某些模块需要依赖于其他模块,前面提到在初始化阶段,Gradle 为每个模块都创建了一个 Project 对象,并且可以通过模块的名字引用到该对象。在配置模块之间的依赖时,使用这种方式可以告诉 Gradle 当前模块依赖了哪些子模块。例如,在我们的项目中,cis-war 会依赖 core 子项目,就可以在cis-war 的构建脚本中加上如下代码:
通过 project(’:core’)来引用core 子项目,在构建cis-war 时,Gradle 会把 core 加到 ClassPath 中。
4.3 构建脚本的依赖
除了项目需要依赖之外,构建脚本本身也可以有自己的依赖。当使用一个非 Gradle 官方提供的插件时,就需要在构建脚本里指定其依赖,当然还需要指定该插件的Repository。在Gradle 中, 使用 buildscript 块为构建脚本配置依赖。
比如在项目中使用cucumber-JVM 作为项目 BDD 工具,而Gradle 官方没有提供它的插件,好在开源社区有人提供cucumber 的插件。在构建脚本中添加如下代码:
buildscript { repositories { mavenCentral() } classpath "gradle-cucumber-plugin:gradle-cucumber-plugin:0.2" } } apply plugin: com.excella.gradle.cucumber.CucumberPlugin
5.其他
5.1 apply 其他 Gradle 文件
当一个项目很复杂的时候,Gradle 脚本也会很复杂,除了将子项目的配置移到对应项目的构建脚本之外, 还可以可以按照不同的功能将复杂的构建脚本拆分成小的构建脚本, 然后在build.gradle 里使用apply from,将这些小的构建脚本引入到整体的构建脚本中去。比如在一个项目中既使用了 Jetty,又使用了 Cargo 插件启动 JBoss,就可以把他们分别提到 jetty.gradle 和jboss.gradle,然后在 build.gradle 里使用如下的代码将他们引入进来:
apply from: "jetty.gradle" apply from: "jboss.gradle"
5.2 project 的目录
在脚本文件中,需要访问项目中的各级目录结构。Gradle 为 Project 对象定义了一些属性指向项目的根目录,方便在脚本中引用。
- rootDir:在子项目的脚本文件中可以通过该属性访问到根项目路径。
- rootProject:在子项目中,可以通过该属性获取父项目的Project 对象。
5.3 使用Wrapper 指定 Gradle 的版本
为了统一项目中 Gradle 的版本,可以在构建脚本中通过定义一个 wrapper 的 Task,并在该 Task 中指定 Gradle 的版本以及存放Gradle 的位置。
运行 gradle wrapper, 就会在根项目目录下创建一个wrapper 的文件夹,会包含 wrapper 的Jar 包和properties 文件。之后就可以使用gradlew 来运行task。第一次使用gradlew 执行 task 的时候, 会在项目根目录下的gradle/dists 下下载你指定的Gradle 版本 。这样在项目构建的时候,就会使用该目录下的Gradle,保证整个团队使用了相同的Gradle 版本。
5.4 使用 gradle.properties 文件
Gradle 构建脚本会自动找同级目录下的 gradle.properties 文件,在这个文件中可以定义一些property,以供构建脚本使用。例如,我们要使用的 Repository 需要提供用户名和密码,就可以将其配置在 gradle.properties 中。这样,每个团队成员都可以修改该配置文件,却不用上传到代码库中对团队其他成员造成影响。可以使用如下的代码定义:
username=user password=password
在构建脚本中使用"${username} "就可以访问该文件中定义的相关值。
由于篇幅有限,本文只是我在一个大型 Java 项目上使用 Gradle 的部分经验,并未涵盖所有Gradle 相关的知识,包括如何编写 Gradle 插件以及 Gradle 对其他语言的构建,读者可以通过阅读 Gradle 的官方文档(比起其他开源软件,Gradle 的另一特点就是文档详细)来了解。另外, Gradle 是基于Groovy 的构建工具,在使用 Gradle 的时候也需要了解和使用 Groovy。所以,在学习Gradle 插件的过程中,也能学会 Groovy 相关的用法,可谓一举两得。