Gradle支持多项目构建,允许我们将一个大型项目分解成多个子模块,然后通过单个构建将相互依赖的模块链接在一起。
多项目构建有时也称为多模块项目,Gradle把里面的每个模块都称作一个子项目。
一个多项目构建通常由一个根项目和一个或多个子项目组成。
多项目结构
下面是一个包含了两个子项目的多项目构建的结构示例:
文件布局看起来像这样 :
① settings文件,定义项目名称和结构,包含所有子项目
② build文件,每个子项目都有自己的构建脚本文件
settings文件
settings文件是每个Gradle项目的入口点,通常位于根目录下。
Gradle支持单项目和多项目构建:
对于单项目构建,settings文件是可选的;
对于多项目构建,settings文件是强制性的,它声明了所有子项目。
settings文件是一个脚本,它要么是用Groovy语言编写的settings.gradle文件,或者是Kotlin编写的settings.gradle.kts文件。
就目前而言,Groovy DSL和Kotlin DSL是Gradle脚本唯一接受的语言。
我们来看一下样例settings文件
//settings.gradle.kts
rootProject.name = "root-project"
include("sub-project-1")
include("sub-project-2")
include("sub-project-3")
首先定义了项目的名称
rootProject.name = "root-project"
然后通过include方法添加子项目
include("sub-project-1")
build文件
build文件是一个构建脚本,通常详细描述了构建配置、任务和插件。
它要么是用Groovy语言编写的build.gradle文件,或者是Kotlin编写的build.gradle.kts文件。
在build文件中,可以添加两种类型的依赖项:
1.Gradle和构建脚本依赖的Library和/或plugins。
2.项目源代码所依赖的Library。
我们看一个样例的build文件
//build.gradle.kts
plugins {
id("application") ①
}
application {
mainClass = "com.example.Main" ②
}
①添加插件
插件扩展了Gradle的功能,可以为项目贡献任务。
添加插件到构建称为应用插件, 之后我们就可以使用附加功能,比如插件提供的任务。
②使用约定属性
插件将任务添加到项目中,也会添加属性和方法。
application插件定义了打包、分发应用的任务,例如run任务。
同时提供了一个方法来申明Java应用程序的执行入口:
application {
mainClass = "com.example.Main"
}
这个例子中mainClas是com.example.Main.java。
多项目路径
项目路径具有以下模式: 它以一个可选的冒号(:)开头,表示根项目。
根项目(:)是路径中唯一不用其名称指定的项目,根项目路径对于Gradle来说并不重要,因为在构建脚本中通常使用相对路径来引用其他项目。
项目路径的其余部分是项目名称的冒号分隔序列,其中,下一个项目是前一个项目的子项目:
:sub-project-1
可以通过运行 gradle projects 命令查看构建中所有项目的路径 :
项目路径通常反映了文件系统布局,比如子项目sub-project-1,其项目路径为:sub-project-1,在根项目下就有一个名为sub-project-1的文件目录,但是我们也可以在settings中对其修改:
project(':sub-project-1').projectDir = new File('aha-here')
此时,:sub-project-1对应的项目文件目录变成了aha-here。
多项目标准
Gradle社区对于多项目构建结构有两个标准:
1. 使用buildSrc的多项目构建 - buildSrc是Gradle根项目目录下一个特殊的子项目,包含了子项目之间的公共构建逻辑。
2. 复合构建 - 一个包含其他构建的构建,其中build-logic是Gradle项目根目录下的构建目录,包含可重用构建逻辑。
使用buildSrc的多项目构建
多项目构建中的子项目通常共享一些公共依赖项。
例如,一个多项目构建包含有多个模块,如api, shared, services,项目结构为:
在这个例子中,settings.gradle.kts包含了所有子项目,子项目的顺序无关紧要。
include("api", "services", "shared")
子项目之间存在依赖关系,比如api或services依赖于shared,Gradle为了构建构建api或者services,必须先构建shared。
Gradle并没有在每个子项目构建脚本中复制粘贴相同的Java版本和库,而是提供了一个buildSrc目录来存储共享的构建逻辑,这些逻辑能够自动应用到子项目中。
buildSrc是一个被gradle识别和保护的目录,如果目录下有一个build.gradle(.kts)文件,就会自动作为一个特殊的子项目包含在构建中,这有一些好处:
1.可重用的构建逻辑:
允许我们以结构化的方式组织和集中自定义构建逻辑、任务和插件。在buildSrc中编写的代码可以在整个项目中重用,从而更容易维护和共享公共构建功能。
2.与主构建隔离:
放置在buildSrc中的代码与项目的其他构建脚本隔离。这有助于保持主构建脚本更清晰,更专注于特定于项目的配置。
3.自动编译和类路径:
buildSrc目录的内容会被自动编译并包含在主构建的类路径中。这意味着在buildSrc中定义的类和插件可以直接在项目的构建脚本中使用,而无需任何额外的配置。
4.易于测试:
因为buildSrc是一个单独的构建,所以可以很容易地测试自定义的构建逻辑。您可以为构建代码编写测试,确保其按照预期的方式运行。
5.Gradle插件开发:
如果我们正在开发自定义插件,buildSrc是一个存放插件代码的好地方,在项目中是非常容易访问的。
对于多项目构建,只能有一个buildSrc目录,该目录必须位于根项目目录中。
在buildSrc中共享构建逻辑
buildSrc使用与Java、Groovy和Kotlin项目相同的源代码约定,它还提供了对Gradle API的直接访问。
下面的示例演示了使用buildSrc的多项目构建:
①: 创建MyCustom任务
②: 一个共享的构建脚本文件
③: 使用MyCustomTask任务和共享构建脚本。
在buildSrc中,我们创建了一个构建脚本shared.gradle(.kts),它里面包含了多个子项目共有的依赖项和其他构建信息:
#shared.gradle.kts
repositories {
mavenCentral()
}
dependencies {
implementation("org.slf4j:slf4j-api:1.7.32")
}
我们还创建了一个辅助任务MyCustomTask,用作多个子项目的构建逻辑的一部分:
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
open class MyCustomTask : DefaultTask() {
@TaskAction
fun calculateSum() {
// Custom logic to calculate the sum of two numbers
val num1 = 5
val num2 = 7
val sum = num1 + num2
// Print the result
println("Sum: $sum")
}
}
api和shared项目的构建脚本中可以直接使用buildSrc中的共享构建逻辑。
// build.gradle.kts
// 应用特定于项目的任何其他配置
// 使用buildSrc中定义的构建脚本
apply(from = rootProject.file("buildSrc/shared.gradle"))
// 使用buildSrc中自定义的任务,该任务是自动可用的,因为它是buildSrc的一部分。
tasks.register<MyCustomTask>("myCustomTask")
使用约定插件共享构建逻辑
上面的共享方式在使用上还是不方便,子项目需要手动添加buildSrc中的共享逻辑,所以Gradle推荐使用插件来组织构建逻辑。
我们可以在buildSrc中编写一个插件,把项目中子项目共同的构建逻辑封装起来。这种插件被称为约定插件。
然后在其他项目中应用插件即可。
//api/build.gradle.kts
plugins {
id("myproject.java-conventions")
}
复合构建
复合构建简单地说就是包含其他构建的构建。在许多方面,复合构建与多项目构建类似,只不过它包含的是完整的构建而不是子项目。
复合构建适用的场景:
- 对多个独立开发的库进行组合。
- 将一个复杂的多项目构建进行拆分,分解成更小、更独立的块,这些块可以根据需要单独或一起工作。
一个被包含在复合构建中的构建我们称之为包含构建,包含构建不会和复合构建或其他包含构建共享任何配置。
定义复合构建
下面的示例演示了如何将两个通常单独开发的Gradle构建组合成一个复合构建。
my-utils是一个多项目构建,包含了两个Java库,number-utils和string-utils。my-app构建使用了这些库里的函数。
需要注意的是,my-app构建不直接依赖my-utils,而是声明了对my-utils构建结果的二进制依赖:
#my-app/app/build.gradle.kts
plugins {
id("application")
}
application {
mainClass = "org.sample.myapp.Main"
}
dependencies {
implementation("org.sample:number-utils:1.0")
implementation("org.sample:string-utils:1.0")
}
通过--include-build定义复合构建
通过命令行选项--include-build告诉Gradle现在执行的构建是一个复合构建,需要先去执行包含构建的构建,然后将其构建结果作为依赖项加入到当前构建中。
最后,对my-app执行run任务。
在settings文件中通过includeBuild定义复合构建
settings文件中可以同时添加子项目和包含构建。
include("sub-project") 添加子项目
includeBuild("my-utils") 添加包含构建,作为子项目
在这个例子中,settings.gradle(.kts)文件复合了两个独立的构建:
#settings.gradle.kts
rootProject.name = "my-composite"
includeBuild("my-app")
includeBuild("my-utils")
从my-composite执行my-app构建的run任务,可以运行如下命令:
./gradlew my-app:app:run
我们可以在my-composite里定义一个run任务,并让它依赖my-app:app:run,然后执行./gradlew run就会先执行my-app:app:run:
//build.gradle.kts
tasks.register("run") {
dependsOn(gradle.includedBuild("my-app").task(":app:run"))
}
定义Gradle插件的包含构建
包含构建的一个特殊情况是定义Gradle插件的构建。
这些构建应该是在settings文件的pluginManagement{}块里使用includeBuild语句来包含。
使用这种机制,包含构建也可以贡献一个settings插件,可以在settings文件本身中应用:
//settings.gradle.kts
pluginManagement {
includeBuild("../url-verifier-plugin")
}
包含构建的限制
大多数构建都可以包含在复合构建中,包括其他复合构建,但存在一些限制。
在前面我们提到,Gradle确保每个项目具有唯一的项目路径,这使得项目可以无冲突的进行标识和寻址。
在复合构建中,Gradle 会为每个包含构建的项目添加额外的限定符,以避免项目路径冲突。复合构建中标识项目的完整路径称为“构建树路径”,它由一个包含构建的构建路径和项目的项目路径组成,比如:
:included-build-name:project-name。
但在复合构建中,包含构建可以在磁盘上的任意位置,因此它们的构建路径由包含目录的名称决定,这有时还会导致冲突。
在复合构建中,Gradle为了避免项目路径冲突,使用构建树路径来标识一个项目。这个路径由包含构建的构建路径和项目路径组成。
默认情况下,构建路径和项目路径是从磁盘上的目录名称和结构派生的。由于包含构建可以位于磁盘上的任何位置,因此它们的构建路径由包含目录的名称确定,这有时会导致冲突。构建路径冲突可能会发生在以下场景中:
1.目录名称重复
假设我们有两个构建项目,分别位于磁盘dir1和dir2目录下
dir1
|---- myapp
| |---- build.gradle
dir2
|---- myapp
| |---- build.gradle
尝试将这两个项目作为复合构建的一部分包含进来,并且没有指定特定的构建名称,Gradle 会使用目录名称作为构建路径。在这种情况下,两个目录名称相同都是myapp,构建路径发生了冲突。
* What went wrong:
Included build /Users/roy/Downloads/dir2/myapp has build path :HelloGradle which is the same as included build /Users/roy/Downloads/dir1/myapp
2.自定义构建名称冲突
在复合构建中,你可以通过指定 settings.gradle 文件中的 buildName 属性来为包含构建定义自定义名称。但是,如果你为不同的构建指定了相同的 buildName,也会导致冲突。
例如,考虑以下情况:
main-project
|---- settings.gradle
|---- build.gradle
included-builds
|---- custom-build-name
| |----build.gradle
如果 main-project 的 settings.gradle 文件中包含如下配置:
includeBuild('../included-builds/custom-build-name') {
name = 'custom'
}
同时,另一个不同的构建也使用了相同的 buildName:
includeBuild('../another-included-build/custom-build-name') {
name = 'custom'
}
总结来说,包含构建必须满足以下要求:
- 每个包含构建必须具有唯一的构建路径。
- 每个包含构建路径不得与主构建中的任何项目路径冲突。
这些条件保证了即使在复合构建中,每个项目也可以唯一标识。
如果冲突发生,Gradle 通常会报告错误,指出构建路径冲突,并提示你如何解决它。根据提示,你可以调整目录结构或 buildName 配置来解决冲突。
//settings.gradle.kts
includeBuild("some-included-build") {
name = "other-name"
}
我们修改之前目录名称重复的例子,修改 settings.gradle.kts:
rootProject.name = "my-composite"
includeBuild("../dir1/myapp")
includeBuild("../dir2/myapp") {
name="myapp2"
}
对dir2/myapp构建配置新的名称myapp2,这样它的构建路径变为:myapp2后,就不会和dir1/myapp构建的:myapp冲突。
执行 $ ./gradlew projects, 看到项目结构为:
Included builds
+--- Included build ':myapp'
\--- Included build ':myapp2'
使用复合构建
复合构建在使用上类似于常规的多项目构建,比如执行任务、运行测试,将构建导入IDE。
执行任务
可以从命令行或IDE执行包含构建中的任务。执行任务将导致执行任务的依赖项,以及那些用来构建其他包含构建中的依赖项的任务。
我们使用完全限定路径来调用包含构建中的任务,比如:included-build-name:project-name:taskName,项目和任务名称也可以是缩写形式的:
$ ./gradlew :included-build:subproject-a:compileJava
> Task :included-build:subproject-a:compileJava
$ ./gradlew :i-b:sA:cJ
> Task :included-build:subproject-a:compileJava
导入到IDE
复合构建最有用的特性之一是IDE集成。
导入复合构建可以把那些来自独立Gradle构建的源代码轻松地组织起来进行开发。
当你将复合构建导入 IDE时,每一个包含构建,每个子项目都会被作为一个IntelliJ IDEA模块或Eclipse项目来导入。可以在 IDE 中轻松地在不同项目之间导航,进行重构,以及其他常见的开发任务。
在导入复合构建后,Gradle 会自动配置源代码依赖关系,这意味着你可以在 IDE 中直接从一个项目跳转到另一个项目,而无需手动设置复杂的依赖管理。这种跨构建的导航和重构支持极大地提高了开发效率,并减少了出错的可能性。
声明由包含构建替代的依赖项
在Gradle中,当你声明一个项目依赖应该被一个复合构建中包含的另一个项目所替代时,你正在使用依赖替代(dependency substitution)的功能。这通常用于在本地开发环境中替换远程依赖,以便进行更快速的开发和测试。
Gradle的算法在确定一个项目可以提供哪些依赖时,会检查包含构建的项目中定义的group和name属性。如果找到与外部依赖匹配的${project.group}:${project.name},Gradle就会用相应的项目依赖来替代外部依赖。
默认情况下,主构建中的项目(即主构建自己的子项目)不会被注册为可替代的依赖。这意味着,尽管这些项目有group和name属性,但它们不会作为潜在的依赖替代者。为了使主构建中的项目可以通过${project.group}:${project.name}的形式被引用,你需要使用includeBuild(".")语句将主构建当作一个包含构建来对待。
Gradle确定的默认替换有些情况下是不够的,或者必须进行纠正,可以显式声明包含构建提供的替换。
例如,有一个名为anonymous-library的单项目构建,它会生成一个Java实用程序库,但不声明group属性的值:
//build.gradle.kts
plugins {
java
}
当这个构建包含在复合构建中,它就会尝试替换依赖项 undefined:anonymous-library (undefined是project.group的默认值, anonymous-library是根项目名称)。
显然,这在组合构建中是没有用的。
要在复合构建中使用非发布的库,您可以显式声明它提供的替换:
//settings.gradle.kts
includeBuild("anonymous-library") {
dependencySubstitution {
substitute(module("org.sample:number-utils")).using(project(":"))
}
}
通过这种配置,任何对org.sample:number-utils的依赖都将被项目anonymous-library所替代。
依赖包含构建中的任务
包含构建是相互隔离的,不能声明直接依赖关系,一个组合构建可以申明任务依赖它的包含构建。
使用Gradle.getIncludedBuilds()或Gradle.includedBuild(java.lang.String)访问包含构建,并通过IncludedBuild.task(java.lang.String)方法获得任务引用。
//build.gradle.kts
tasks.register("run") {
dependsOn(gradle.includedBuild("my-app").task(":app:run"))
}