使用gradle管理多模块Java项目

由于项目需要,我最近学习了一点关于 Gradle 的入门知识,本篇以一个含有多个子项目的 Java SpringBoot 工程为例,总结分享一下Gradle 的基本原理和正确使用方式

写这篇文章,主要因为网上查阅到的 Gradle 相关博客和中文文档几乎都已经过时了,Gradle 官方文档很靠谱但是是英文的,我读了一些文档,结合自己的理解尽量写的深入浅出一些,适合 Java 开发看。但是要系统学习 Gradle 还得看官方文档

##Gradle 从陌生到入门七问

Gradle 是什么?

Gradle 是一个以 Groovy/Kotlin 作为 DSL 的灵活的可扩展的,高性能构建工具。广泛应用于多种语言和框架的项目构建,比如 Android 项目、Java 类库和后端项目、多编程语言的项目等等

Gradle 与 Maven 最本质的区别?

Maven 的目的是用 XML 描述项目的项目管理工具,而 Gradle 是用 DSL 描述 Build Task 的自动化构建工具。虽然功能有重合的部分,但根本上它俩的思想和机制是完全不一样的。

为什么 Java 项目推荐用 Gradle 替换 Maven?

  • Gradle 比 Maven
  • Gradle 默认使用的 DSL 是定制的 Groovy,比 XML简洁
  • Gradle 的插件机制比 Maven 更方便灵活
  • Gradle 的生态比 Maven 更加广泛,支持的编程语言和技术平台非常多样。

Gradle Wrapper 是什么?

由于 Gradle 的版本迭代很快,为了能更方便管理 Gradle 自身的版本,并且让不同项目之间的 Gradle 版本隔离开不会互相影响,Gradle 项目最好使用 Wrapper 模式。

使用 Wrapper 模式时,项目根目录有一个 CMD/Shell脚本(gradlew)用来启动 Gradle Wrapper,根据gradle-wrapper.properties的定义,wrapper 从 CDN 下载对应的 Gradle 版本并执行构建,免去了开发人员自己去下载和配置 Gradle 环境变量的负担。

如果在当前项目中升级 Gradle 的版本,执行下面这行命令即可:

1
 gradlew wrapper --gradle-version 7.0

使用 Wrapper 之后,本地也不需要事先安装和配置 gradle 命令,直接用项目文件中的 gradlew 命令即可。

Gradle 的基本概念有哪些?

  • 三级数据模型:Build,Project,Task。一个 Build 可以包括 1 到 N 个 Project,一个 Project 包括 1-N 个 Task
  • Task 之间的关系是有向无环图(DAG),构建过程就是按照 DAG 依次执行其中的 Task,Gradle 可以看成指挥官,是Task 的调度器
  • Plugin 是底下真正干活的,不同的 Plugin实现不同功能的 Task,添加 Plugin 就会在 Build 中预定义 Task 和 Configuration。

下图即是一个常规的 Java 项目 Build 过程经历的 Task,像 jar/classes 等等这些 Task 的实现就是Java Plugin提供的。 image.png

Gradle 的命令怎么用?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 执行名为build的Task
gradle build
# 执行build Task时,跳过 test Task
gradle build -x test
# 执行时开启build cache和并行模式,减少构建时间
gradle build -x test --parallel --build-cache
# 执行完把构建数据上传到Gradle云服务,在浏览器中直接分析,如下图所示
# --scan需要手动同意,首次激活链接后就可以看到构建报告了,功能非常强大
gradle build -x test --parallel --build-cache --scan
# 注: wrapper模式下,执行的是gradlew而非gradle

在线的 Scan Report,可以看到每个 Task 的耗时、依赖树、单元测试失败的栈轨迹等等,花钱买 Gradle Enterprise 可解锁更强的功能哦。

image.png

Gradle 的原理和核心流程是什么?

这个问题我们用示例来说明,我们创建一个简单的 SpringBoot Web 工程,包含 3 个子项目:

  • application-api:是导出给其他服务用于 RPC 调用的模块;
  • application-core:是这个 Web 工程的核心实现模块,包括 controller/service/model 等等 MVC 结构的常用包;
  • application-boot:是 SpringBoot 的启动模块,包括启动类和 Spring 的 JavaConfig 配置类等等。

目录结构如下(build.gradle 里面具体内容在最后一节贴出详细代码来分析):

├─build.gradle # 描述根项目的依赖和构建的核心文件 ├─settings.gradle # 描述根目录和子项目的关键信息 ├─gradlew # Wrapper 的 shell script ├─gradle.bat # Wrapper 的 cmd script ├─application-api │ ├─build.gradle # 描述子项目的依赖和构建等信息 │ └─src │ └─main │ └─java │ └─com.example.x ├─application-boot │ ├─build.gradle # 描述子项目的依赖和构建等信息 │ └─src │ ├─main │ │ ├─java │ │ │ └─com.example.x │ │ └─resources │ └─test │ └─java │ └─com.example.x ├─application-core │ ├─build.gradle # 描述子项目的依赖和构建等信息 │ └─src │ └─main │ └─java │ └─com.example.x └─gradle └─wrapper ├─gradle-wrapper.jar # 一个很轻量的可执行 jar,用于下载 Gradle └─gradle-wrapper.properties # 描述 gradle 版本和下载相关配置

项目我们建好了,当我们执行gradlew clean build -x test就可以构建出来一个可执行的 SpringBoot 工程的 jar 包了。那么执行这行命令时,到底发生了什么

  1. gradlew用 Java 命令启动gradle-wrapper.jar,gradle 尝试复用或创建一个指定版本的 gradle,下载解压后,启动gradle daemon后台服务;
  2. gradle 扫描当前目录下的gradle.properties,用户目录(~/.gradle)下的gradle.properties等配置参数;
  3. 寻找当前目录下的settings.gradle,build.gradle 等文件,分析并魔改 Groovy/Kotlin 文件的抽象语法树,构建 Task 执行的步骤;
  4. 如果 settings.gradle 文件include了其他子项目,继续找到对应的目录里的build.gradle等文件并分析内容,在这里就是 application-boot/application-core/application-api 三个子项目;
  5. 如果依赖的特定版本的插件或库缺失,会到 gradle/maven 中心仓库或自定义仓库,下载缺少的PluginDependency
  6. 开始按照流程图执行 ‘clean’ Task,成功后再执行 ‘build’ Task,由于参数-x 指定了跳过’test’ Task,DAG 中的 ‘test’ Task 的子节点全部被排除无需执行,下面即是我们这个示例中的 Task 顺序图。
:build +— :assemble | +— :bootJar | | +— :classes | | | +— :compileJava | | | | --- :application-boot:compileJava <br>| | | | +--- :application-api:compileJava <br>| | | | — :application-core:compileJava | | | | --- :application-api:compileJava <br>| | | — :processResources | | +— :application-api:jar | | +— :application-boot:jar | | --- :application-core:jar <br>| — :jar | --- :classes <br>| +--- :compileJava <br>| — :processResources | ……. # 省略了一些输出 --- :check <br> — :test # 未被其他 Task 使用的子节点已经被排除

注:上面的执行流程也是org.barfuin.gradle.taskinfo这个 Gradle Plugin 提供的 ‘tiTree’ Task 生成的,另外,由于我们使用了 SpringBoot Plugin,assemble Task 中执行的是 ‘bootJar’ Task,而不是一节图中的 ‘jar’ Task。

Java + Gradle 项目实战指南

加依赖的正确方式

对于开发人员来说,不管是 Maven 还是 Gradle,最常用的功能就是二方/三方库的依赖管理,我们从最简单的场景开始。大多数情况下,Gradle 定义依赖只需要在dependencies 闭包里写一行,比如这样的:

1
2
3

implementation 'org.springframework.boot:spring-boot-starter:2.4.5'
// 不要再使用 compile 'xxx' 了

注意:compile 指令早在 Gradle 3.x 版本就已经弃用,在 Java 项目中使用 compile 会导致依赖传递(Transitive Dependency),也就是在 A 中定义的 compile 依赖,会传递到依赖了 A 的 B 项目编译期间的 Classpath,污染了 B 项目。

我们再考虑一些复杂的情况:有时候,我们又想要依赖传递出去;有时候,我们想某个库对于依赖方不是必须的;有时候只想一些依赖在单元测试的时候编译进去…

  • api 指令: 声明一个传递依赖,等价于之前的 compile,也等价于 Maven 的compile scope,慎用;
  • runtimeOnly 指令: 相当于 Maven 的runtime scope,仅运行时需要的 jar,比如 MySQL Connector, Logback/Log4j 等;
  • compileOnly 指令: 仅添加到编译时的 classpath 中,类似于 Maven 的provided scope,不会打包到最终产物中, 比如lombok适合用 compileOnly;
  • annotationProcessor 指令: JSR-269 引入了编译期注解处理器, 在 Java 1.6 之后提供了这个修改源码 AST 的机会,我们熟悉的 lombok 用的就是这个原理,需要搭配 compileOnly 使用;
  • testXXX 指令: 仅 Test Task 执行的指令,类似 Maven 的test scope, Gradle 对 test 依赖的定义更加细致,包括testImplementation, testCompileOnly等细分指令。

注:有一些 Java 相关的添加依赖指令,比如 api、annotationProcessor 等,是Java Plugin实现的,因此需要事先在 build.gradle 声明

1
2
3
4
5
6
7
8

// 在build.gradle的开头声明
plugins {
id 'java-library'
}
// 注意:
// 1. apply plugin是过时的写法,尽量不要这样用了: apply plugin: 'java-library'
// 2. java plugin功能比较基础,使用 'java-library' 代替 'java',功能更健全

指定仓库

指定仓库也是常见操作,比如添加企业内部 Maven 仓库。在单项目工程中,可以直接在build.gradle以及buildscript里声明中心仓库。

1
2
3
4
5
6
7

repositories {
mavenCentral()
// 参考文档:https://docs.gradle.org/current/userguide/declaring_repositories.html     maven {
      url "//awesome.com/maven"
          }
}

对于多项目工程/Monorepo,可以在allprojects/subprojects闭包中声明 repositories,也可以把这段脚本抽到单独的文件,使用apply from的方式引入到所有需要的地方

1
apply from: "path/to/repositories.gradle"

解决依赖冲突

依赖冲突问题想必大家都遇到过,解决这类问题,首先需要分析依赖树,Gradle 提供了相应的 Task:

1
2

# 如果没用wrapper模式,直接使用gradle命令  <br>gradlew dependencies

结果中可以看到整个项目的依赖树,如果需要定点查看某个库的依赖情况,执行dependencyInsight命令,需要注意–configuration 参数的区别。

1
2
3
4
5
6

# 编译期classpath中,依赖库的版本分析
gradlew dependencyInsight --configuration compileClasspath --dependency org.slf4j:slf4j-api
# 最终打包到runtime的classpath中,依赖库的版本分析
# 大部分情况下我们想分析最终打到jar/war中的三方库版本,configuration是runtimeClasspath
gradlew dependencyInsight --configuration runtimeClasspath --dependency spring-boot-starter-actuator

下面是我们对 slf4j-api 的依赖分析的截取结果,第一行的含义就是即使 logback-classic 库依赖的是1.7.25版本的 slft4j-api,由于其他库依赖了1.7.30这个更高版本的 slf4j-api,最终使用的是1.7.30

1
2
3
4
5
6
7
8
org.slf4j:slf4j-api:1.7.25 -> 1.7.30
+--- ch.qos.logback:logback-classic:1.2.3
|    +--- org.springframework.boot:spring-boot-dependencies:2.4.5
|    \|    +--- runtimeClasspath
|    \|    +--- project :application-boot
|    \|    \|    \--- runtimeClasspath
|    \|    +--- project :application-core
|    \|    \|    \--- project :application-boot (*)

用 dependency/dependencyInsight 找到根因后,解决依赖问题通常的做法,是排除某些有问题的依赖,或者强制指定某个库的版本

  • 全局强制排除依赖,在 build.gradle 中直接写全局的以来排除 configuration,如果是多项目工程可以在对应的build.gradle或者allprojects/subprojects闭包下写这段
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 全局排除tomcat,比如换jetty/undertow就可以用这种方式避免tomcat打包进去
configurations.all {
exclude module: 'spring-boot-starter-tomcat'
}

// 也可以使用单个指令configuration的exclude,只在implementation指令生效
configurations {
implementation {
compile.exclude group: 'org.bad' module 'bad-module'
}
}
  • 单点排除依赖
1
2
3
4
5

// 排除某个依赖中,它依赖的其他有问题库
implementation('org.springframework.boot:spring-boot-starter') {
exclude module: 'bad-module'
}
  • 强制某个依赖使用某版本
1
configurations.all {  <br>  resolutionStrategy {  <br>    // 检测到依赖冲突,直接失败,而不是自动选择一个版本  <br>    // 一般Java Web项目不需要开启这个强制检查  <br>    // failOnVersionConflict()  <br>  <br>    // 强制指定某依赖库的版本  <br>    force('net.bytebuddy:byte-buddy:1.10.22')  <br>    force('org.slf4j:slf4j-api:1.7.30')  <br>  <br>  }  <br>}

最后,验证问题是否已经解决,可以从下面几个方面入手:

  1. 再次执行依赖分析的命令;
  2. 从最终构建产物直接分析,对于 SpringBoot 工程,把最终构建的 jar 解压,在 BOOT-INF/lib 下找到三方库,确定某个库打包版本是否正确;
  3. 在部署运行后,使用 arthas 等诊断工具寻找运行期间加载的类。

可选依赖

Gradle 没有 Maven 中的 Optional Dependency, 因为 Maven 的 Optional 是一个有问题的设计,比如 A 声明需要 Optional 的库 C,这时 C 不会传递给依赖 A 的项目 B,B 项目就很容易出现各种找不到类、找不到方法的报错,写 Java 的同学一定经历过这类依赖问题导致的报错。

这个场景中,问题的本质在于:难道要让 B 去了解三方库 A 里,有哪些依赖是需要手动加到自己项目的,哪些是不需要手动加的?强迫依赖方 B 去了解三方库 A 的实现细节,学过软件工程的人都知道这不是一个正确的设计。

Gradle 设计了一个语义化更强的 Feature+Compability 方案,参考文档:https://blog.gradle.org/optional-dependencies。大概流程是这样的:

  1. 类库 A 的 gradle 文件中声明提供 Feature C: java { registerFeature(‘featureC’) {}
  2. A 的 dependencies 闭包这样写:dependencies { featureCImplementation(‘some-lib-required-for-feature-c’) }
  3. 依赖方 B 项目在依赖时声明是否需要该 Feature:implementation(‘some-group:lib-A:1.0.0’) { capabilities { requireCapability(‘featureC’) } }

发布到 Maven 仓库

Maven-Publish Plugin 可以实现发布 jar 到 Maven 仓库,具体参考文档:https://docs.gradle.org/current/userguide/publishing_maven.html。

全套组件的统一依赖管理

在 Maven 中有MavenBOM(Bill of Materials),pom.xml 的 dependencyManagement 声明整套组件的 BOM,所有相关组件都无需写特定的版本号,对于 Spring 全家桶,SpringBoot 全家桶,SpringCloud 全家桶这类生态型技术平台的依赖管理非常有用。Gradle 有没有类似的功能呢?网上搜索的资料大多是这样的:

1
dependencyManagement {  <br>  imports {  <br>      mavenBom "org.springframework.boot:spring-boot-dependencies:2.4.5"  <br>  }  <br>}|

这是已经过时的用法,新项目就别再用这种方式了!Gradle 5.0 之后支持的原生的platform指令更加简洁。

1
// 技术平台的*使用方*,仅需添加一行 platform() 即可管理一组依赖约束  <br>dependencies {  <br>  api platform("com.ecosystem:some-platform:1.0.0")  <br>}|

如果是在中大型企业中,架构组可能会自己做技术中台,比如开发一套内部生态组件,对于生态系统的开发方,如何确保多个组件之间的依赖约束呢?

答案是使用Java-Platform Plugin,具体使用方式这里不再展开,参考文档:https://docs.gradle.org/current/userguide/java_platform_plugin.html

SpringBoot/SpringCloud 组件的依赖约束

上面已经提到,对于全家桶式的一套组件,应该使用 Gradle 的Platform特性,具体代码如下:

1
 // 如果用到SpringCloud,是对SpringBoot的版本有要求的,版本映射关系在SpringCloud官网有说明  <br>buildscript {  <br>    ext {  <br>        springBootVersion = '2.4.5'  <br>        // SpringCloud 2020.0.2 全家桶,只能使用SpringBoot 2.4.x  <br>        springCloudVersion = '2020.0.2'  <br>    }  <br>}  <br>  <br>// 注意字符串用到 ${var} 的时候,一定要用双引号,这是groovy的语法决定的  <br>dependencies {  <br>  api platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}")  <br>  api platform("org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}")  <br>}|

SpringBoot Plugin 的原理

SpringBoot Plugin 会给 Gradle 加一个bootJar Task, 在打 jar 包时把设置的 mainClass、SpringBoot JarLauncher 等信息到META-INF/MANIFEST.MF中。

1
// SpringBoot Plugin生效的非常关键的设置  <br>bootJar {  <br>  mainClass.set('us.zoom.application.MyApplication')  <br>}|

最终构建结果中的 META-INF/MANIFEST.MF

1
Manifest-Version: 1.0  <br>Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx  <br>Spring-Boot-Layers-Index: BOOT-INF/layers.idx  <br>Start-Class: us.zoom.application.ApplicationFullApplication  <br>Spring-Boot-Classes: BOOT-INF/classes/  <br>Spring-Boot-Lib: BOOT-INF/lib/  <br>Spring-Boot-Version: 2.4.5  <br>Main-Class: org.springframework.boot.loader.JarLauncher|

示例:多模块 SpringBoot 项目

####项目结构 image.png

完整的 Gradle 代码

./settings.gradle

1
2
3
4
5
rootProject.name = 'application'

  include ':application-api',
          ':application-boot',
                  ':application-core'

./build.gradle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
buildscript {
    ext {
        springBootVersion = '2.4.5'
    }
}
plugins {
    id 'java-library'
    id 'org.springframework.boot' version "${springBootVersion}"
}

// java plugin内置的可设变量
group = 'com.awesome'
version = '1.0.0-SNAPSHOT'
sourceCompatibility = JavaVersion.VERSION_15
targetCompatibility = JavaVersion.VERSION_15

bootJar {
    mainClass.set('us.zoom.application.MyApplication')
}

allprojects {
    // 目前Gradle版本不支持在allprojects下声明plugins,使用的是旧的写法
    apply plugin: 'java-library'

    repositories {
        mavenCentral()
    }

    configurations.all {
        exclude module: 'spring-boot-devtools'
        exclude module: 'spring-boot-starter-tomcat'

        resolutionStrategy {
            cacheChangingModulesFor(0, 'seconds')
        }
    }

    test {
        // 要用junit可以换成:useJUnitPlatform()
        useTestNG()
    }

    dependencies {
        api platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}")

        // 相当于provided scope,不打到构建产物中
        compileOnly 'org.projectlombok:lombok:1.18.20'
        annotationProcessor 'org.projectlombok:lombok:1.18.20'
        testCompileOnly 'org.projectlombok:lombok:1.18.20'
        testAnnotationProcessor 'org.projectlombok:lombok:1.18.20'

        // 相当于test scope,仅在单元测试使用
        testImplementation 'org.testng:testng:7.4.0'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'

        // 在allprojects下只要声明lombok,junit/testng这类通用依赖
        // 不应该再添加任何属于某个子项目的依赖
    }
}

// 根项目依赖 :application-boot 子项目
// 三个子项目的依赖关系是 boot -> core -> api
// 因此root project只要依赖boot项目
dependencies {
    implementation project(':application-boot')
}

./release.gradle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// maven_user / maven_password / GROUP_ID / VERSION / snapshots / releases 等变量需要写到gradle.properties和系统变量中
publishing {
    repositories {
        maven {
            url VERSION.endsWith('-SNAPSHOT') ? System.getProperty("snapshots") : System.getProperty("releases")
            credentials {
                username System.getProperty("maven_user") == null ? "" : System.getProperty("maven_user")
                password System.getProperty("maven_password") == null ? "" : System.getProperty("maven_password")
            }
        }
    }
    publications {
        maven(MavenPublication) {
            groupId = GROUP_ID
            artifactId = "$project.name"
            version = VERSION
            from components.java
        }
    }
}

./application-api/build.gradle

1
2
3
4
5
6
7
8
9
plugins {
    id 'maven-publish'
}
apply from: "../release.gradle"

// api项目定义微服务之间调用的RPC/HTTP接口
// 这里留空,实际上要根据使用的RPC框架决定dependency
dependencies {
}

./application-core/build.gradle

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
dependencies {
    api project(":application-api")

    // 传递给boot项目
    api 'org.springframework.boot:spring-boot-starter'
    api 'org.springframework.boot:spring-boot-starter-web'
    api 'org.springframework.boot:spring-boot-starter-aop'

    // 仅在core项目中使用,不传递
    implementation 'org.springframework.boot:spring-boot-starter-undertow'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'io.micrometer:micrometer-registry-prometheus:1.6.6'
    implementation 'commons-configuration:commons-configuration:1.10'
}

./application-boot/build.gradle

1
2
3
dependencies {
    implementation project(":application-core")
}

参考

Licensed under CC BY-NC-SA 4.0
最后更新于 Jan 06, 2025 05:52 UTC
comments powered by Disqus
Built with Hugo
主题 StackJimmy 设计
Caret Up