本节目标:让五个子模块不写版本号也能拿到一致的依赖,并用 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-api2.1.1 与error_prone_annotations2.50.0。 dependency:tree和help:effective-pom不要加-q,否则看不到输出。
依赖版本稳定后,下一个问题是构建本身是否稳定:同一份代码在两台机器上产出的 jar 是否逐字节相同。下一节锁定插件与时间戳。
阅读导航:上一节:1.1 从单模块到多模块:libraryd 的拆分边界 · 下一节:1.3 可复现构建:插件锁定与可重复打包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。