《Java 编程实战》1.2 依赖治理:BOM、作用域与版本冲突

把散落在子模块里的版本号收回父 POM:用 dependencyManagement 与 Jackson BOM 统一版本,用 runtime、test 作用域收紧依赖,再用 Enforcer 的 dependencyConvergence 抓出 Javalin 与 Jedis 带来的真实冲突并收敛。

本节目标:让五个子模块不写版本号也能拿到一致的依赖,并用 Enforcer 把传递依赖冲突从「Maven 悄悄选一个」变成「构建失败,必须显式收敛」。
适用版本:Maven 3.9.12、Javalin 7.2.4、Jackson 2.22.4、PostgreSQL JDBC 42.7.14、JUnit 5.14.4。

承接的问题

1.1 拆完模块后,最诱人的下一步是让每个子模块自己声明依赖版本:persistence 写 PostgreSQL 42.7.14,web 写 Javalin 7.2.4,app 写 Logback 1.6.6。短期能跑,长期会出现三个问题。

第一,同一个库在不同模块出现不同版本。有人升级 web 的 Jackson,忘了 app 里手写的 Jackson 版本,运行时类路径上只有一个版本,但编译期各自按自己的版本检查,问题被推迟到启动或序列化时才暴露。

第二,子模块无法表达「我只在测试里用 JUnit」。版本写在 <dependencies> 里,作用域也写在子模块里,复制 POM 时很容易把 test 漏掉,测试库就进了生产包。

第三,传递依赖的分歧不可见。Javalin 会带入 Jetty,Jedis 会带入 Gson,Caffeine 会带入 Error Prone 注解;它们各自依赖的库版本不一定一致。Maven 默认按「路径最短优先」选一个,构建成功,但选中的版本不一定是团队想要的那个。

依赖治理要回答的就是三件事:版本从哪来、依赖进哪条类路径、冲突由谁裁决。

版本只有一个来源:properties 与 dependencyManagement

父 POM 的 <properties> 保存版本号,<dependencyManagement> 把版本绑定到坐标。子模块只写 groupId 和 artifactId,不写 <version>。这样升级一个库只需要改一行属性。

<properties>
  <javalin.version>7.2.4</javalin.version>
  <jackson.version>2.22.4</jackson.version>
  <postgresql.version>42.7.14</postgresql.version>
  <hikari.version>7.1.0</hikari.version>
  <flyway.version>13.11.1</flyway.version>
  <slf4j.version>2.0.20</slf4j.version>
  <logback.version>1.6.6</logback.version>
  <junit.version>5.14.4</junit.version>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson</groupId>
      <artifactId>jackson-bom</artifactId>
      <version>${jackson.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    <dependency>
      <groupId>io.javalin</groupId>
      <artifactId>javalin</artifactId>
      <version>${javalin.version}</version>
    </dependency>
    <dependency>
      <groupId>org.postgresql</groupId>
      <artifactId>postgresql</artifactId>
      <version>${postgresql.version}</version>
    </dependency>
  </dependencies>
</dependencyManagement>

Jackson 用的是 BOM 导入,而不是逐个列 jackson-databind、jackson-datatype-jsr310。<type>pom</type> 与 <scope>import</scope> 的组合表示:把 jackson-bom 里的 <dependencyManagement> 合并进当前 POM。子模块写 Jackson 坐标时不写版本,解析结果由 BOM 决定。

这里有一个值得注意的真实细节。mvn dependency:tree 显示 Jackson 各构件的版本并不完全相同:

+- com.fasterxml.jackson.core:jackson-databind:jar:2.22.4:compile
|  +- com.fasterxml.jackson.core:jackson-annotations:jar:2.22:compile
|  \- com.fasterxml.jackson.core:jackson-core:jar:2.22.4:compile
+- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:2.22.4:compile

jackson-annotations 是 2.22,不是 2.22.4。这不是冲突,而是 BOM 按构件维护的兼容组合:注解模块的补丁号节奏与 core 不同。BOM 的价值是给出这一组经过验证的版本,不是把所有数字强行改成一样。

父 POM 里还可以用 help:evaluate 验证属性确实被解析:

$ mvn -q help:evaluate -Dexpression=jackson.version -DforceStdout
2.22.4

本机输出。-q 配合 -DforceStdout 只打印值,适合在 CI 里断言版本。

作用域:把依赖放到正确的类路径

Maven 有六种作用域,libraryd 实际用到四种。它们决定依赖出现在编译、运行、测试哪条类路径上:

作用域编译类路径运行类路径测试类路径libraryd 里的例子
compile有有有Javalin、Jackson、HikariCP、SLF4J API
runtime无有有PostgreSQL JDBC 驱动
test无无有JUnit Jupiter、Testcontainers
import不适用不适用不适用只在 dependencyManagement 里导入 BOM

PostgreSQL 驱动用 runtime 是本节最重要的一个选择。persistence 的代码只应出现 java.sql.Connection、PreparedStatement,驱动由 DriverManager 或 HikariCP 在运行时按 JDBC URL 加载。把驱动放在 compile 会让 org.postgresql.* 在编译期可见,代码就容易写出只有 PostgreSQL 能跑的私有 API。

真实的依赖树能直接看到作用域:

$ mvn dependency:tree -pl libraryd-persistence
[INFO] com.plume:libraryd-persistence:jar:1.0.0-SNAPSHOT
[INFO] +- com.plume:libraryd-domain:jar:1.0.0-SNAPSHOT:compile
[INFO] +- org.postgresql:postgresql:jar:42.7.14:runtime
[INFO] +- com.zaxxer:HikariCP:jar:7.1.0:compile
[INFO] +- org.flywaydb:flyway-core:jar:13.11.1:compile
[INFO] +- org.flywaydb:flyway-database-postgresql:jar:13.11.1:compile
[INFO] \- org.slf4j:slf4j-api:jar:2.0.20:compile

本机输出。runtime 那一行就是边界:驱动进入运行和测试类路径,但不进入编译类路径。验证方法是在 persistence 里临时写一个 import org.postgresql.Driver,编译会失败:

[ERROR] .../DriverProbe.java:[3,22] 程序包org.postgresql不存在
[ERROR] .../DriverProbe.java:[10,16] 找不到符号
[ERROR]   符号:   类 Driver

本机输出,验证后已删除探针文件。编译失败在这里是正确行为,不是配置错误。

test 作用域同理。domain 只在测试里用 JUnit,生产 jar 的依赖列表里没有 JUnit。integration-tests 的 Testcontainers 也是 test,不会进入最终镜像。

provided 在 libraryd 里没有使用。Javalin 7 内嵌 Jetty,Servlet API 由 Jetty 传递提供,不需要容器在运行时再给一份。如果将来把同一套路由部署到外部 Tomcat,才需要把 Servlet API 改成 provided,并重新验证容器版本。

冲突调解:nearest wins 与 dependencyConvergence

Maven 的默认调解规则有两条:路径最短的版本胜出;路径深度相同时,先声明的胜出。规则本身是确定的,但它只决定「选一个」,不决定「选对」。libraryd 第一次执行 mvn package 时,Enforcer 的 dependencyConvergence 直接拦下了两处分歧。

第一处来自 Javalin 的 Jetty 树:

[ERROR] Dependency convergence error for jakarta.annotation:jakarta.annotation-api:jar:2.1.1.
[ERROR] +-com.plume:libraryd-web:jar:1.0.0-SNAPSHOT
[ERROR]   +-io.javalin:javalin:jar:7.2.4:compile
[ERROR]     +-org.eclipse.jetty.ee10.websocket:jetty-ee10-websocket-jetty-server:jar:12.1.14:compile
[ERROR]       +-org.eclipse.jetty.ee10:jetty-ee10-annotations:jar:12.1.14:compile
[ERROR]         +-jakarta.annotation:jakarta.annotation-api:jar:2.1.1:compile
[ERROR] and
[ERROR]   ...
[ERROR]         +-org.eclipse.jetty.ee10:jetty-ee10-plus:jar:12.1.14:compile
[ERROR]           +-jakarta.interceptor:jakarta.interceptor-api:jar:2.1.0:compile
[ERROR]             +-jakarta.annotation:jakarta.annotation-api:jar:2.1.0:compile

本机输出,路径行做了压缩。同一个 Jetty 版本树里,一条路径要 2.1.1,另一条要 2.1.0。默认调解会选深度更浅的 2.1.1,但 dependencyConvergence 要求所有路径一致,所以构建失败。

处理方式是在父 POM 的 dependencyManagement 里显式收敛到高版本,并写清楚原因:

<!-- Javalin 7.2.4 的 Jetty 12.1.14 树里同时出现 2.1.1 与 2.1.0,显式收敛到高版本。 -->
<dependency>
  <groupId>jakarta.annotation</groupId>
  <artifactId>jakarta.annotation-api</artifactId>
  <version>2.1.1</version>
</dependency>

第二处来自缓存与 Redis 客户端:

[ERROR] Dependency convergence error for com.google.errorprone:error_prone_annotations:jar:2.50.0.
[ERROR] +-com.plume:libraryd-app:jar:1.0.0-SNAPSHOT
[ERROR]   +-com.github.ben-manes.caffeine:caffeine:jar:3.3.1:compile
[ERROR]     +-com.google.errorprone:error_prone_annotations:jar:2.50.0:compile
[ERROR] and
[ERROR]   +-redis.clients:jedis:jar:8.0.2:compile
[ERROR]     +-com.google.code.gson:gson:jar:2.14.0:compile
[ERROR]       +-com.google.errorprone:error_prone_annotations:jar:2.48.0:compile

本机输出。Caffeine 3.3.1 带 2.50.0,Jedis 8.0.2 经 Gson 带 2.48.0。两者都是注解库,运行时影响很小,但「运行时影响小」不是让构建图保留两个版本的理由。显式管理到 2.50.0:

<!-- Caffeine 3.3.1 带 2.50.0,Jedis 8.0.2 经 Gson 带 2.48.0;收敛到高版本。 -->
<dependency>
  <groupId>com.google.errorprone</groupId>
  <artifactId>error_prone_annotations</artifactId>
  <version>2.50.0</version>
</dependency>

收敛后,-Dverbose 的依赖树会明确标出「被管理」和「因重复而省略」:

[INFO] +- com.github.ben-manes.caffeine:caffeine:jar:3.3.1:compile
[INFO] |  \- com.google.errorprone:error_prone_annotations:jar:2.50.0:compile (version managed from 2.50.0)
[INFO] \- redis.clients:jedis:jar:8.0.2:compile
[INFO]    +- com.google.code.gson:gson:jar:2.14.0:compile
[INFO]    |  \- (com.google.errorprone:error_prone_annotations:jar:2.50.0:compile - version managed from 2.48.0; omitted for duplicate)

本机输出。version managed from 2.48.0 说明 Jedis 原本要 2.48.0,被父 POM 改成了 2.50.0;omitted for duplicate 说明这条路径不再贡献第二个版本。

Enforcer 的配置放在父 POM 的 <build><plugins>,而不是只放在 pluginManagement:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <executions>
    <execution>
      <id>enforce-toolchain</id>
      <goals>
        <goal>enforce</goal>
      </goals>
      <configuration>
        <rules>
          <requireMavenVersion>
            <version>[3.9,4.0)</version>
          </requireMavenVersion>
          <requireJavaVersion>
            <version>[25,26)</version>
          </requireJavaVersion>
          <dependencyConvergence/>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>

pluginManagement 只声明版本和默认配置,不会执行插件;要让 Enforcer 在 validate 阶段跑起来,必须再写进 <build><plugins>。这一点和第 1.1 节的模块列表一样,是「声明」与「生效」的区别。

在 libraryd 里落地:子模块只写坐标

治理规则落到 POM 上,子模块的依赖声明应该短到看不出基础设施细节。persistence 负责数据库,声明驱动、连接池、迁移和日志 API:

<dependencies>
  <dependency>
    <groupId>com.plume</groupId>
    <artifactId>libraryd-domain</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>com.zaxxer</groupId>
    <artifactId>HikariCP</artifactId>
  </dependency>
  <dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-core</artifactId>
  </dependency>
  <dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
  </dependency>
  <dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
  </dependency>
</dependencies>

web 声明 Javalin、Jackson、Validation 和日志 API,全部不带版本;integration-tests 只声明 app、JUnit 和 Testcontainers:

<dependencies>
  <dependency>
    <groupId>com.plume</groupId>
    <artifactId>libraryd-app</artifactId>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

检查依赖树时有一个命令行陷阱。mvn -q dependency:tree 在本机没有任何输出:

$ mvn -q dependency:tree -pl libraryd-persistence
$ echo $?
0

本机输出,只有退出码 0。原因是 dependency:tree 通过 Maven 的 INFO 日志打印结果,-q 把 INFO 全部关掉了。排查依赖时不要加 -q,或者用 -DoutputFile=target/deps.txt 把树写到文件:

$ mvn dependency:tree -DoutputFile=target/deps.txt -pl libraryd-app
$ head -5 target/deps.txt

同一个陷阱也出现在 help:effective-pom:mvn -q help:effective-pom 在本机同样没有 stdout 输出,必须加 -Doutput=target/effective-pom.xml 才会写文件。CI 里如果只是想断言某个版本,用 help:evaluate -DforceStdout 更直接。

常见错误与边界

在子模块里再写一次版本。 子模块的 <version> 会覆盖父 POM 的 dependencyManagement,而且不会有任何警告。判断方法是搜索子模块 POM:

$ rg -n "<version>" libraryd-*/pom.xml

除了 <parent> 里的版本,子模块不应出现其他版本号。内部模块的版本由 ${project.version} 提供,第三方库由属性提供。

把测试库写成 compile。 例如漏掉 <scope>test</scope>,JUnit 会进入生产 jar 的依赖列表。mvn dependency:tree 里会出现 junit-jupiter:...:compile,最终镜像也会变大。集成测试模块的 Testcontainers 同样必须是 test。

把驱动改成 compile 图省事。 代码里一旦出现 org.postgresql.PGConnection,persistence 就只能跑在 PostgreSQL 上,卷四想换连接池或做多数据库适配时要重写。驱动留在 runtime,业务代码只依赖 JDBC 标准接口。

依赖收敛规则对遗留项目过严。 dependencyConvergence 会让任何分歧都失败。新项目从第一天开启成本最低;如果是已有几十个模块的项目,可以先只对新增模块启用,或在父 POM 里维护一份有原因的豁免列表。不要为了「先跑起来」直接删掉规则,那样冲突会重新变成隐形的。

小结

  • 版本集中在父 POM 的 <properties> 与 <dependencyManagement>,子模块只写坐标。
  • Jackson 用 BOM 导入;BOM 给出的是兼容组合,jackson-annotations 2.22 与 jackson-core 2.22.4 并存由 BOM 的条目决定。
  • 作用域决定类路径:驱动用 runtime,测试库用 test,Javalin 这类嵌入式运行时用 compile。
  • Maven 默认按路径最短选版本,dependencyConvergence 把「悄悄选一个」变成「必须显式收敛」。
  • 本节真实遇到并收敛了两处冲突:jakarta.annotation-api 2.1.1 与 error_prone_annotations 2.50.0。
  • dependency:tree 和 help:effective-pom 不要加 -q,否则看不到输出。

依赖版本稳定后,下一个问题是构建本身是否稳定:同一份代码在两台机器上产出的 jar 是否逐字节相同。下一节锁定插件与时间戳。

阅读导航:上一节:1.1 从单模块到多模块:libraryd 的拆分边界 · 下一节:1.3 可复现构建:插件锁定与可重复打包 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发