依赖管理深入
依赖管理,说白了就是管好你项目里那些“别人家的代码”。我刚开始用 Gradle 时,觉得 dependencies 块里写几行坐标就完事了。结果有一次上线前发现两个 jar 包里的类签名不一样,直接启动报错……嗯,从那以后我才认真啃了这块内容。
依赖分类:你到底该用哪个配置?
Gradle 提供了多种依赖配置,每种对应不同的作用域。我挑几个最常用的说一下:
| 配置名 | 作用 | 是否可传递 | 典型场景 |
|---|---|---|---|
implementation |
模块内部使用,不会暴露给消费者 | 是 | 内部实现细节,比如日志库 |
api |
模块内部使用,并且暴露给消费者 | 是 | 公开接口中的依赖,比如 Spring 注解 |
compileOnly |
仅编译期需要,运行时由其他提供 | 否 | Servlet API、Lombok |
runtimeOnly |
运行期需要,编译期不需要 | 否 | JDBC 驱动实现 |
annotationProcessor |
注解处理器 | 否 | Lombok、MapStruct |
我个人习惯:能用 implementation 绝不用 api。api 会强制上游模块也看到这个依赖,容易造成“依赖耦合”。我在一个多模块项目中遇到过,修改了某个模块的 api 依赖,结果全项目都要重新编译……那痛苦,谁用谁知道。
dependencies {
implementation 'org.slf4j:slf4j-api:2.0.9'
api 'org.springframework:spring-core:6.0.11'
compileOnly 'javax.servlet:javax.servlet-api:4.0.1'
runtimeOnly 'mysql:mysql-connector-java:8.0.33'
annotationProcessor 'org.projectlombok:lombok:1.18.28'
}
gradle dependencies 或 gradle dependencyInsight 查看依赖树,能帮你快速定位配置选错了没有。
传递性依赖:自动“下崽”的副作用
Gradle 默认帮你把依赖的依赖一起拉下来,这就是传递性。多数时候是好事,但版本冲突就头疼了。举个例子:A 依赖 B 1.0,C 依赖 B 2.0,你同时用了 A 和 C,Gradle 会怎么选?
它的策略是:默认选最高版本。但如果你有特殊需求,可以用 transitive = false 关掉某个依赖的传递性:
dependencies {
implementation('com.example:foo:1.0') {
transitive = false // 只拉 foo 本身,它的依赖自己处理
}
}
我曾经在微服务项目中,因为一个老库引入了 log4j 1.x,而新库需要 log4j 2.x,导致冲突。当时临时禁用传递性,手动加入 log4j 2.x,虽然解决了问题,但后来还是老老实实升级了那个老库。所以我的建议是:能别禁用传递性就别禁用,那是最后的手段。
依赖冲突解决:谁说了算?
当两个依赖版本冲突时,Gradle 默认选择最高版本。但有时候最高版本不兼容你的代码怎么办呢?这时候你需要手动干预。
我常用的方式是配置 resolutionStrategy:
configurations.all {
resolutionStrategy {
// 强制使用某个版本,不管谁依赖了谁
force 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
// 或者排除某个版本
eachDependency { DependencyResolveDetails details ->
if (details.requested.group == 'org.springframework') {
details.useVersion '6.0.11'
details.because '统一Spring版本,避免兼容性问题'
}
}
}
}
force 是全局的,会覆盖所有传递进来的版本。eachDependency 更灵活,可以按组或名筛选。
如果你想排查到底是谁带来了冲突版本,用 gradle dependencyInsight --configuration compileClasspath --dependency jackson-databind。这条命令能告诉你每个 jar 是哪条路径引入的。我调试 NoSuchMethodError 时基本靠它。
强制版本:霸王硬上弓
除了在 resolutionStrategy 里用 force,你还可以在依赖声明时直接加 force = true:
dependencies {
implementation('com.google.guava:guava:32.1.2-jre') {
force = true
}
}
这个写法作用范围更小,只针对这一个依赖的传递版本。我个人更推荐用 resolutionStrategy.force,因为它统一管理,方便后续维护。之前有一个项目,每个人都在自己的模块里写 force = true,结果版本乱套……最后我统一提到根项目的 allprojects 块里,世界清静了。
动态版本:方便但危险
你可以在版本号里用 + 表示最新版本:
dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.+' // 动态版本
implementation 'commons-io:commons-io:2.+' // 也是动态
}
Gradle 会每隔一定时间(默认 24 小时)去检查是否有新版本。好处是自动拿到补丁升级,坏处是构建结果不可复现——今天构建明天可能不一样。
latest.release,结果某天依赖升级了不兼容的 API,构建失败了三天才发现……后来改为固定版本 + Renovate Bot 自动 PR,既安全又省心。
动态版本在生产环境慎用,但开发环境或内部库可以酌情使用。如果你非用不可,建议设置缓存刷新间隔:
configurations.all {
resolutionStrategy.cacheDynamicVersionsFor 10, 'minutes'
}
上面这张图基本概括了本章的核心知识点。总结一下:依赖配置选对(implementation 优先),传递性依赖要心中有数,冲突了先查再改,强制版本是双刃剑,动态版本慎用。我在实际工作中,大部分时间都在用 gradle dependencies 和 dependencyInsight 这两条命令,真的能做到“一眼看出问题”。