文章目录
- 1.概述
- 2.依赖管理
- 2.1.坐标
- 2.2.依赖的基本概念
- 2.3.依赖配置(Dependency configurations)
- 2.3.1.依赖路径
- 2.3.2.依赖配置与依赖路径的关联
- 2.4.依赖传递
- 2.4.1.准备工作
- 2.4.2.运行时依赖传递
- jar包生成与依赖配置
- 依赖树打印
- 使用 Dependency Analyzer 查看依赖
- 2.4.3.编译时依赖传递
- 2.5.依赖冲突
- 2.5.1.依赖解析(dependency resolution)
- 版本号的组成
- 版本号排序规则
- 2.5.2.排除传递依赖
- 单个排除
- 全局排除
- 2.5.3.强制指定版本号
- 3.总结
1.概述
在前面两篇文章中,我们已通过 Gradle 创建了一个多模块的项目,并介绍了一些常用的核心概念。至此,我们已能够在工作中使用 Gradle 了。
本篇内容主要讲解 Gradle 的依赖管理,旨在让大家对 Gradle 的依赖特性有一定了解,能够处理工作中出现的一系列依赖冲突问题,包含以下内容:
- 讲解坐标的概念
- 介绍依赖范围的概念及选择
- 说明依赖如何传递、如何避免依赖传递
- 阐述依赖冲突的概念、如何确认依赖冲突、如何解决依赖冲突
Gradle历史文章:
《【Gradle】(一)在IDEA中初始化gradle项目及其核心概念解释》
《【Gradle】(二)多模块项目、api推送到Maven私服、及SpringBoot可执行Jar包》
Maven相关文章:
《Maven中的依赖功能特性》
《Maven坐标的概念与规则》
对Maven感兴趣的话,可以看看我的上面两篇Maven文章,里面也介绍了一些依赖的概念,当然,如果不感兴趣的话,本篇文章也会引用这两篇Maven中的部分内容,不影响本篇文章的阅读。
2.依赖管理
2.1.坐标
在 Gradle 中,依赖的版本控制通常基于 Maven 的坐标系统,使用组 ID(groupId)、构件 ID(artifactId)和版本号(version)来唯一标识一个依赖项,这被称为 GAV(坐标)。
例如:implementation "cn.hutool:hutool-all:5.8.18"
,其中 cn.hutool
是组 ID,hutol-all
是构件 ID,5.8.18
是版本号。
Gradle的构建一般也是使用的Maven仓库,很多时候在仓库中的jar包是共用的,所以我们在编写坐标时,也应遵循Maven的规范:
groupId
:指的是当前构建隶属的实际项目,一般是 公司的网址倒序 + 项目名artifactId
:一般是指的当前项目中的其中一个模块version
:当前项目的版本号
版本号也不是随便写的,在业界有通用的规则,定义方式如下:
{主版本号}.{次版本号}.{增量版本号}-{里程碑版本}
- 主版本号:一般是指的当前的项目有了重大的架构变动,版本之间几乎完全不兼容,例如:最近出的 SpringBoot3 就已经放弃了Java8,如果不升级 JDK的话,还是只能使用SpringBoot2
- 次版本号:一般是指的项目的迭代版本,这种版本会修复大量的bug,带来一些小的新特性等,但是没有什么架构上的重大变化。
- 增量版本号:一般是用于修复一些紧急bug,改动量很小时,可以使用增量版本号。
- 里程碑版本:就是项目的当前版本处于一个什么样的阶段了,常见的里程碑版本有
SNAPSHOT
,alpha
,beta
,release
,GA
等。
在里程碑的版本中,标注SNAPSHOT
的为开发版,此时会存在大量的代码变动,alpha
和beta
分别对应的是内测版与公测版这三个版本都是属于不稳定版本,使用的时候非常容易踩坑,所以一般只用的demo体验,在正式环境中不能使用。
release
和GA
都属于是稳定的正式版本,可以在正式环境中使用。
下面是SpringBoot的一些版本号,可以体验一下:
2.2.依赖的基本概念
在软件开发中,依赖是指一个项目或模块对其他项目或模块的需求。例如,如果一个项目需要使用某个第三方库来实现某些功能,那么这个项目就对该第三方库存在依赖。
假设现在有 A、B、C 三个构件,如下图中表示的就是A依赖B和C
通过坐标定义:
dependencies {
implementation "com.ls:B:1.0.0"
implementation "com.ls:C:1.0.0"
}
2.3.依赖配置(Dependency configurations)
在这个章节会了解到Gradle中的几种常用的依赖路径,以及依赖配置和依赖范围的对应关系《官方参考文档》
2.3.1.依赖路径
从宏观的角度来看,常用的依赖类型有两种:编译路径(CompileClasspath)和运行时路径(RuntimeClasspath),在这两个概念的基础上,又加入了测试代码和非测试代码的区分,概念如下:
CompileClasspath
:编译类路径,在编译项目时需要的依赖路径。RuntimeClasspath
:运行时类路径,在运行项目时需要的依赖路径。TestCompileClasspath
:测试代码编译类路径,是在编译测试代码时所需的依赖路径。TestRuntimeClasspath
:测试代码运行时类路径,用于运行测试代码时所需的依赖路径。
除此之外,还有一种特殊的路径annotationProcessor
,它也是一种依赖配置,在编译时生效。主要是用于将某些注解添加到依赖路径中,启用对注解的支持。在上篇文章中提到的lombok
的注解支持就是这种类型:
dependencies {
compileOnly "org.projectlombok:lombok:$lombokVersion"
annotationProcessor "org.projectlombok:lombok:$lombokVersion"
}
说的更直白一点,所谓的编译时路径,就是写代码的时候可以直接Import使用的路径,运行时路径就是写代码的时候无法引入,但在程序运行的时候能够访问到(常见于间接依赖中)。
2.3.2.依赖配置与依赖路径的关联
- implementation:添加直接依赖包到编译时和运行时环境
- api:添加直接依赖包和间接依赖包到编译时和运行时环境,即向上层传递内部依赖关系
- compileOnly:添加依赖包,不添加到运行时路径
- runtimeOnly :不添加到编译时路径,添加运行时路径
- annotationProcessor:添加到注解处理器的类路径,不添加到运行时路径
更多依赖配置对应的依赖路径关系如下表所示:
依赖配置 | Compile Classpath | Runtime Classpath |
---|---|---|
api | √ | √ |
implementation | √ | √ |
compileOnlyApi | √ | × |
compileOnly | √ | × |
runtimeOnly | × | √ |
testImplementation | √ | √ |
testCompileOnly | √ | × |
testRuntimeOnly | × | √ |
annotationProcessor | √ | × |
在上面的表格中,有test
前缀的依赖配置,只会在test
任务中生效。
此外,可以看到还多了一个api
,这也是一个可能会使用到的功能,主要是用在依赖传递中,在下面会讲解。
2.4.依赖传递
我们上面提到了编译时和运行时两种依赖路径,依赖传递也分为编译时依赖传递和运行时依赖传递,其中编译时依赖传递还依赖于api
这个依赖配置,要使用这个配置,还需要额外引入java-library
插件。下面就通过一个demo案例来体验一下两种依赖传递,同时,会介绍IDEA中自带的一种依赖查看工具 Dependency Analyzer。
2.4.1.准备工作
需要创建3个子模块进行模拟,分别是a-demo
,b-demo
,c-demo
,同时需要将它们打包到本地Maven仓库中。
创建子模块:
创建3个子模块,并修改每个子模块的build.gradle,里面只放入version = 1.0.0
,其他配置由根项目进行配置。
根项目配置
根项目中需要配置的有三个,插件配置、仓库配置、发布配置,配置的含义在本系列的前两篇文章中已经讲过了,这里就不再赘述了,根项目完整的配置如下,
plugins {
id 'java'
id 'org.springframework.boot' version "$springBootVersion" apply false
id 'io.spring.dependency-management' version "$springDenpendencyVersion" apply false
}
allprojects {
repositories {
mavenLocal()
maven {
url 'https://maven.aliyun.com/repository/public'
}
mavenCentral()
}
}
subprojects {
apply plugin: 'java'
apply plugin: 'maven-publish'
apply plugin: 'java-library'
sourceCompatibility = JavaVersion.VERSION_1_8
java {
withSourcesJar()
}
publishing {
publications {
demo(MavenPublication) {
from components.java
}
}
repositories {
maven {
name = "mavenNexus"
allowInsecureProtocol = true
def releasesRepoUrl = 'http://192.168.200.101:8081/repository/maven-releases/'
def snapshotsRepoUrl = 'http://192.168.200.101:8081/repository/maven-snapshots/'
url = version.endsWith('SNAPSHOT') ? snapshotsRepoUrl : releasesRepoUrl
credentials {
username 'admin'
password 'admin123'
}
}
}
}
dependencies {
compileOnly "org.projectlombok:lombok:$lombokVersion"
annotationProcessor "org.projectlombok:lombok:$lombokVersion"
}
}
gradle.properties配置
资源文件中需要配置项目整体的groupId和默认的version。
group=com.ls
version=1.0.0
2.4.2.运行时依赖传递
jar包生成与依赖配置
要感受到依赖传递,需要依次对c-demo
,b-demo
两个依赖打包,首先是对c进行打包和推送到本地Maven仓库。
推送完成后修改b-demo
的build文件,再按同样的方式打包:
最后,在a-demo
中引入b-demo
,并刷新Gradle引用。
依赖树打印
这里有两种方式可以查看依赖关系,一种是gradle
自带的依赖树打印功能gradlew dependencies
,另一种则是使用IDEA中的 Dependency Analyzer工具。
我们先看看第一种方式,要看a-demo
的依赖关系,首先打开终端控制台,进入a-demo
模块,然后再利用根目录的gradlew
文件执行打印依赖树指令:
cd a-demo
../gradlew dependencies
在上图中,我们可以看到编译时路径和运行时路径 的依赖关系。
- 编译时路径中:
有b-demo
和lombok
,其中 b-demo 的依赖配置是implementation
,上面提到了,这种依赖配置会将jar包添加到编译时和运行时两种依赖路径中,lombok
是在根项目中配置的compileOnly
,每个子模块都继承了这个配置,所以可以看到在编译时路径中会存在,也正是因为在编译时存在,在代码中才可以直接使用。
可以看到的是,目前的配置中,编译时并没有发生依赖传递(即没有出现间接依赖)。 - 运行时路径中:
我们可以看到的是,在运行时路径中除了b-demo
这个直接依赖以外,还有c-demo
这个间接依赖,这是因为implementation
可以实现包在运行时路径上传递。
使用 Dependency Analyzer 查看依赖
如果觉得上面这种打印依赖树的方式有点繁琐,还可以使用IDEA的自带工具(好像是2022以上版本就有了)。如下图,可以直观的看到编译时和运行时的依赖关系,和上面打印出的结果是一致的。
如果想看到更详细的信息,可以选择红框中的任意一个包,右键选择Analyze Dependencies
进入到工具弹窗中。
通过这里的一系列操作就可以查看到自己想看的依赖关系了,举个例子:假如我现在想看a-demo
的运行时依赖关系,通过树状展示,可以这么做。
2.4.3.编译时依赖传递
编译时依赖传递需要通过api
这个依赖配置实现,通过java-library
插件引入。在根项目的subprojects
中已经设置了apply plugin: 'java-library'
,这里可以直接使用。
现在我希望将b-demo
中引入的c-demo
包,在编译时和运行时都通过,通过间接依赖的形式传递给a-demo
,只需要修改B模块中的build文件,将 implementation 修改为 api
,修改完成后重新打包。在a-demo
里面引入b。
- b-demo:
version = '1.0.1' dependencies { api 'com.ls:c-demo:1.0.0' }
- a-demo:
version = '1.0.0' dependencies { implementation 'com.ls:b-demo:1.0.1' }
最后的结果是,两种路径的依赖都传递了:
2.5.依赖冲突
简单的说,依赖冲突就是在一个项目中引入了 groupId 和 ArtifactId 一样,但 version 不一样的jar包,程序在使用时不能确定要使用哪一个包。
《依赖解析官网文档》
2.5.1.依赖解析(dependency resolution)
不管是Maven还是Gradle都有处理依赖冲突的“调解机制”,在Gradle中叫“依赖解析(dependency resolution)”机制,两者之间还是有一定的区别,在工作中需要注意,不能混为一谈。
- Maven的处理方式是通过“就近原则”,即选择依赖路径中最近的那个版本,详情可以看这篇文章《Maven坐标的概念与规则》
- Gradle使用的是“最新版本优先”策略,在有多个版本冲突时,Gradle 默认选择最高的版本。
在判断多个版本的高低时,是通过定义好的版本排序机制来处理的,如果想对这个排序机制有比较详细的了解,可以去看看《版本排序官方文档》,下面先聊一下版本号的组成规则,再解释一下这个排序机制。
版本号的组成
版本号实际上是由两部分组成的:BaseVersion
+Qualifier
。
- BaseVersion:基础版本号,规范的情况下由
x.y.z
3个数字组成,但也有其他字母组成的情况。 - Qualifier:限定符,规范的情况下有
beta
,SNAPSHOT
,RELEASE
,GA
等,也可能是其他不规则的符号,或者没有限定符。
下面的官网提供的例子,如何分解基础版本号与限定符:
在版本号里面有.
,+
,-
,_
这样的分隔符,以及字母和数字之间有一个空串
分隔符,出现这些分隔符的时候会找第一个不是.
的分隔符,左侧就是基础版本号,右侧就是限定符。
版本号排序规则
- 第一步:通过分隔符将版本号拆分成不同的组成部分
通过.
,+
,-
,_
这几个符号进行拆分,同时,如果有字母和数字的组合,也会拆解成不同的部分。
例如:1a1
,1.a.1
,1-a-1
不管分隔符是什么,都会拆分成[1,a,1]
三个部分。 - 第二步:分别对比每个部分的值
就像字符串的对比那样,从左到右,一个一个的对比,其大小规则为:2 > 1 > 0 > b > a > B > A
m遇到多个部分组合比较的时候,还要注意一个规则:1.1.0 > 1.1 > 1.1.a
- 第三步:特殊含义的限定符比较
比较规范的版本号中,右侧的限定符一般是用来标识当前的jar包状态的,例如是开发状态,还是发布状态,是测试版,还是稳定版等等。
这种情况也有优先级顺序:1.0-dev < 1.0-alpha < 1.0-zeta < 1.0-rc < 1.0-snapshot < 1.0-final < 1.0-ga < 1.0-release < 1.0-sp < 1.0
上面的规则比较多,不太方便记忆,但是也没关系,在大多数情况下版本号都是比较规范的,只需要比较数字的大小就可以了。
2.5.2.排除传递依赖
我们在引入多种三方jar包的时候,通过依赖传递的特性,可能会间接引入了某一个jar包的多个版本,但我们并不想使用Gradle自动指定的最高版本,这时候我们就可以通过排除传递依赖的方式,把最高版本的包排除掉。《排除传递依赖官方文档》
单个排除
语法也比较简单,在implementation
引入依赖的时候,通过exclue
排除依赖就可以了(需要将语法由空格隔开,调整为用小括号包裹)。exclue
的参数常用的有4种组合,分别对应不同的功能:
-
排除所有传递依赖
artifactId
在exclue
的参数中使用module
标记。implementation("com.ls:demo-api:1.0.0") { exclude(group: '*') } implementation("com.ls:demo-api:1.0.0") { exclude(module: '*') }
-
指定排除某个
groupId
的依赖implementation("com.ls:demo-api:1.0.0") { exclude(group: 'org.slf4j') }
-
指定排除某个构件
implementation("com.ls:demo-api:1.0.0") { exclude(module: 'log4j') }
-
指定排除某个
groupId
下的某个构件依赖implementation("com.ls:demo-api:1.0.0") { exclude group: 'com.ls', module: 'c-demo' }
全局排除
除了一个一个的排除以外,有时候我们还想一次排除项目中的所有指定的传递依赖,例如前两年的log4j
包出现了安全问题,需要把它从全局排除掉。
以上面多个子模块的demo项目为例,先查依赖找到log4j
包:
// 根项目的 build.gradle 文件
subprojects {
configurations.configureEach {
exclude group: 'org.apache.logging.log4j', module: 'log4j-to-slf4j'
exclude group: 'org.apache.logging.log4j', module: 'log4j-api'
}
}
说明:configurations.configureEach
表示在所有的依赖配置中都排除掉,是Gradle6以后得功能,旧版本里面可以使用configurations.all
,排除后的结果如下图。
如果只想排除一定的依赖配置,可以通过下面的语法来实现:
// 根项目的 build.gradle 文件
subprojects {
configurations {
compileOnly.exclude group: 'org.apache.logging.log4j', module: 'log4j-to-slf4j'
implementation.exclude group: 'org.apache.logging.log4j', module: 'log4j-api'
}
}
2.5.3.强制指定版本号
可以通过force
来强制使用某个版本号,这个功能也是在configurations
中进行配置,平时使用的不多,这里就简单提一下:
configurations.all {
resolutionStrategy {
force 'com.ls:c-demo:1.0.1'
}
}
指定了之后,就不会管依赖传递中c-demo
的版本号了,通通使用1.0.1版本。
3.总结
本文着重针对 Gradle 的依赖管理展开了全方位且极为详尽的介绍。在此过程中,清晰明确地提及了诸如坐标、依赖的基础概念、依赖配置、依赖传递还有依赖冲突等相关特性。在充分熟知并掌握了这些特性之后,于后续开展的工作当中,就能够更加精确、细致地对依赖进行管理,并且可以妥善、有效地处理由依赖冲突所引发的各种各样的问题。