第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多模块依赖管理核心知识体系 依赖管理 3.1 依赖声明 3.2 仓库配置 3.3 依赖传递 3.4 版本冲突 implementation api / compileOnly mavenCentral 私有/自定义仓库 exclude 排除 transitive控制 force / 约束 resolutionStrategy ⚠ 实战建议 先声明依赖 → 选对配置 → 配好仓库 → 处理传递 → 解决冲突 常用命令:gradle dependencies 查看依赖树,gradle dependencyInsight 定位冲突

最后,我总结一下日常开发中最实用的三个命令:

  • gradle dependencies — 以树形展示所有依赖,一眼看出哪里引入了什么版本。
  • gradle dependencyInsight --dependency guava — 精确调查某个库的来源和冲突原因。
  • gradle build --scan — 生成构建扫描报告,里面有详细的依赖解析图,非常适合大型项目。

依赖管理看似琐碎,但只要掌握好这几个核心点——声明、仓库、传递、冲突——多模块项目里的jar包问题基本都能稳稳拿捏。我自己每次建新项目都会先把根项目的dependencyManagement写好,再逐个添加模块,后面几乎没再为版本问题头疼过。记住:控制优先于妥协,声明优先于传递。