第3章 依赖管理基础:依赖声明、仓库配置、依赖传递、版本冲突解决
依赖管理说白了就是告诉Gradle:“嘿,我需要哪些外部库,从哪下载,怎么处理版本打架”。我刚学Gradle时觉得不就是写个implementation 'xxx'吗?直到遇到一次jar包冲突导致运行时ClassNotFoundException,才意识到这背后门道挺多。今天咱们就一口气把这块理清楚。
3.1 依赖声明——告诉Gradle你要什么
在每个模块的build.gradle里,dependencies {}块就是干这个的。但不同的配置(configuration)控制着依赖的作用范围,我习惯用这张表来记:
| 配置名称 | 作用范围 | 常见场景 |
|---|---|---|
implementation | 仅当前模块编译可用,对外隐藏 | 大部分业务库,防止接口泄露 |
api | 当前模块和依赖它的模块均可访问 | 框架核心库(如Spring Boot) |
compileOnly | 仅编译期需要,运行时不打包 | lombok、javax注解 |
runtimeOnly | 仅运行时需要,编译期看不到 | 日志实现(如logback) |
testImplementation | 仅测试代码有效 | JUnit、Mockito |
举个例子,我在多模块项目里喜欢把公共API用api暴露出去:
// common模块
dependencies {
api 'org.springframework:spring-core:5.3.20'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.13.3'
}
注意:api会向上传播依赖,如果滥用会导致传递链爆炸。我一个项目里就是因为太多api导致构建时间长了三倍,后来全部改成implementation才缓解。
3.2 仓库配置——让Gradle知道去哪下载
声明了依赖还得告诉Gradle去哪找。最常见的配置就在repositories {}块里:
repositories {
mavenCentral()
google() // Android项目必备
maven { url "https://jitpack.io" }
// 公司私有仓库
maven { url "https://nexus.mycompany.com/repository/maven-public/" }
}
Gradle默认没有仓库,你得显式写。我见过新手没配仓库直接报Could not resolve,然后一脸懵逼。另外注意顺序:Gradle会按声明的先后顺序查找,所以把内网仓库放在前面可以加速依赖下载,尤其是你公司有Nexus私服的时候。
建议:在settings.gradle里统一配置全局仓库,避免每个子模块重复写。比如:
dependencyResolutionManagement {
repositories {
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
3.3 依赖传递——省心也闹心
默认情况下,你引入一个库,它自身的依赖也会被自动拉进来。这就是传递依赖。比如你依赖了spring-boot-starter-web,它背后会拉来Tomcat、Jackson等几十个jar。省心吧?但有时候你只想用Spring的注解,不想用它的日志框架,就得控制传递。
我曾经在一个微服务项目里,两个模块分别引入了不同版本的log4j,结果运行时轮流输出两个版本的内容,日志乱成一锅粥。后来我用transitive = false把一部分传递依赖屏蔽了:
dependencies {
implementation('org.springframework.boot:spring-boot-starter-web:2.7.0') {
transitive = false // 只拉spring-boot-starter-web本身,不拉它的子依赖
}
}
但全关掉也不对——你得手动补齐那些被屏蔽的库。所以更好的做法是用exclude精确排除:
implementation('org.springframework.boot:spring-boot-starter-web:2.7.0') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
传递依赖控制的关键点:
- 所有依赖默认都是传递的(
transitive = true) - 用
exclude按坐标排除,粒度更细 - 也可以用
@jar这种符号来只拉jar而不拉pom,但不常用
3.4 版本冲突解决——别让jar包互相打架
这是依赖管理最头疼的部分。当同一个库的不同版本被传递进来时,Gradle默认采用最新版本(基于Maven的"nearest wins"策略有一个变体)。记住一点:Gradle版本选择是冲突时选版本号最大的那个。但如果你用api或者Maven的依赖管理,情况会复杂一点。
举个例子,你的项目里直接声明了guava:30.0-jre,而某个传递依赖拉来了guava:28.0-jre,最终会用30.0。如果反过来你直接声明了28.0,但传递拉来了30.0,Gradle会选28.0——因为直接声明优先级高于传递。
我有个血泪教训:有一次排查半天NoClassDefFoundError,最后发现是传递依赖里某个库的javax.servlet版本太低,覆盖了Tomcat自带的版本。解决方案是强制声明一个版本:
configurations.all {
resolutionStrategy {
force 'javax.servlet:javax.servlet-api:4.0.1'
}
}
或者用更推荐的dependency constraints(约束声明):
dependencies {
constraints {
implementation('javax.servlet:javax.servlet-api:4.0.1') {
because '强制使用4.0.1以避免老版本bug'
}
}
}
另外,Gradle提供了强大的ResolutionStrategy,可以对特定依赖设定版本规则:
configurations.all {
resolutionStrategy {
// 如果有冲突,直接报错,不要悄悄选
failOnVersionConflict()
// 或者指定模块的版本
eachDependency { details ->
if (details.requested.name == 'javax.servlet-api') {
details.useVersion '4.0.1'
}
}
}
}
曾经踩过的坑:在多模块项目里,如果不同子模块用api暴露了同一依赖的不同版本,最终会选中最高版本,导致其中一个模块编译通过但运行时报错(因为用了旧版本的API)。建议在根项目里统一用dependencyManagement(类似Maven的parent POM)来锁定版本。
下面这张图总结了依赖管理的完整脉络:
最后,我总结一下日常开发中最实用的三个命令:
gradle dependencies— 以树形展示所有依赖,一眼看出哪里引入了什么版本。gradle dependencyInsight --dependency guava— 精确调查某个库的来源和冲突原因。gradle build --scan— 生成构建扫描报告,里面有详细的依赖解析图,非常适合大型项目。
依赖管理看似琐碎,但只要掌握好这几个核心点——声明、仓库、传递、冲突——多模块项目里的jar包问题基本都能稳稳拿捏。我自己每次建新项目都会先把根项目的dependencyManagement写好,再逐个添加模块,后面几乎没再为版本问题头疼过。记住:控制优先于妥协,声明优先于传递。