第 05 章(节选) 两个实战
本篇来自《Go 程序员的 Java 修炼之路》第 05 章「第 05 章 Maven 深水区:坐标、仓库与依赖仲裁(依赖到底从哪来)」。 返回:第 05 章索引
05.10 实战一:从零搭一个多模块项目
order-service/
├── pom.xml # parent:packaging=pom
├── order-api/ ├── pom.xml └── src/main/java/com/example/order/api/
├── order-core/ ├── pom.xml └── src/main/java/com/example/order/core/
└── order-web/ ├── pom.xml └── src/main/java/com/example/order/web/
父 pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>order-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<!-- 父模块必须是 pom:不产生代码产物,只做聚合和配置共享 -->
<packaging>pom</packaging>
<modules> <!-- 聚合:目录名即模块名 -->
<module>order-api</module>
<module>order-core</module>
<module>order-web</module>
</modules>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>2.7.18</spring-boot.version>
<jackson.version>2.15.3</jackson.version>
<mybatis.version>3.5.13</mybatis.version>
</properties>
<!-- 版本治理:只登记不引入。子模块写了依赖才会真的拉下来 -->
<dependencyManagement>
<dependencies>
<!-- Spring Boot BOM 管了上千个依赖的版本 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type><scope>import</scope>
</dependency>
<!-- 覆盖/补充 BOM 的放后面(后者优先) -->
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.version}</version>
<type>pom</type><scope>import</scope>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>${mybatis.version}</version>
</dependency>
<!-- 内部模块也统一版本,子模块引用时就不写 version -->
<dependency>
<groupId>com.example</groupId>
<artifactId>order-api</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- 所有子模块共有的依赖 -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<!-- pluginManagement:只锁定版本和配置,不实际执行 -->
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration><release>${java.version}</release></configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<!-- 不锁插件版本,Maven 会用内置默认版(很老)并打 WARNING -->
<plugin>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<id>enforce</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<requireMavenVersion><version>[3.8,)</version></requireMavenVersion>
<requireJavaVersion><version>[17,)</version></requireJavaVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>子模块 pom
<project>
<modelVersion>4.0.0</modelVersion>
<!-- 继承:拿到 properties / dependencyManagement / pluginManagement -->
<parent>
<groupId>com.example</groupId>
<artifactId>order-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<!-- relativePath 默认 ../pom.xml,找不到就去仓库里找 -->
</parent>
<artifactId>order-core</artifactId>
<!-- 不写 groupId / version:从 parent 继承 -->
<dependencies>
<!-- 内部模块:版本在父 pom 的 dependencyManagement 里 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>order-api</artifactId>
</dependency>
<!-- 版本由 Spring Boot BOM 管,不写 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
</dependency>
<!-- 运行时才需要:编译期只依赖 JDBC 接口 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>构建命令
mvn install -N # 先装父 pom(-N 不递归)
mvn clean install # 全量
mvn install -pl order-core -am # 只构建 order-core 及其依赖
mvn install -pl order-core -amd # 看谁受影响
mvn clean install -T 1C # 并行
mvn help:effective-pom -pl order-core # 看最终生效的 pom(终极武器)
mvn dependency:tree -pl order-web把 mvn help:effective-pom 的输出读一遍。 那是展开后的完整 pom —— 所有继承来的配置、BOM 展开的版本、插件默认配置。读完那一刻你会真正理解 Maven 在干什么。
05.11 实战二:排查一个真实的依赖冲突
照着这个 SOP 走,五分钟能定位;大部分 Java 工程师做不到五分钟。
1. 现象
2026-09-03 09:14:22.871 ERROR [http-nio-8080-exec-7] o.a.c.c.C.[.[.[/].[dispatcherServlet] :
Servlet.service() for servlet [dispatcherServlet] threw exception
java.lang.NoSuchMethodError: com.fasterxml.jackson.databind.ObjectMapper.readValue
at com.example.order.web.OrderController.detail(OrderController.java:47)
订单详情接口跑了三个月没出过事。周三唯一的变化是同事加了个报表 SDK。
2. 第一步:确认是哪个类的哪个方法
从栈里读三件事:
- 异常类型是
NoSuchMethodError,不是ClassNotFoundException—— 类找到了,但类里没有那个方法签名。范围立刻缩小到”版本不对”,而不是”依赖缺失”。 - 类名:
com.fasterxml.jackson.databind.ObjectMapper→ 冲突的是jackson-databind - 调用位置:
OrderController.java:47
(具体是哪个 readValue 重载不重要,真实项目以你看到的栈为准。判断方法永远一样:看栈里报的类名 + 方法签名,再对比两个版本的 javadoc 或 javap 输出。)
3. 第二步:看依赖树
mvn dependency:tree -Dverbose -Dincludes=com.fasterxml.jackson.core:jackson-databind[INFO] com.example:order-web:jar:1.0.0-SNAPSHOT
[INFO] +- com.thirdparty:report-sdk:jar:1.4.0:compile
[INFO] | \- com.fasterxml.jackson.core:jackson-databind:jar:2.9.0:compile
[INFO] \- org.springframework.boot:spring-boot-starter-web:jar:2.7.18:compile
[INFO] \- org.springframework.boot:spring-boot-starter-json:jar:2.7.18:compile
[INFO] \- (com.fasterxml.jackson.core:jackson-databind:jar:2.15.3:compile - omitted for conflict with 2.9.0)
看到 omitted for conflict with 2.9.0,根因就定了。 report-sdk 在深度 2 引入 2.9.0,starter-json 在深度 3 引入 2.15.3 —— 2.9.0 路径短,nearest wins,它赢了。你的代码编译期用 2.15.3,运行时加载 2.9.0,签名对不上。
4. 第三步:验证(别急着改)
# 办法一:看产物里到底放了哪个 jar
mvn package -DskipTests && unzip -l target/order-web.jar | grep jackson-databind
# 办法二:直接看运行时那个类里有没有这个方法
javap -classpath ~/.m2/repository/com/fasterxml/jackson/core/jackson-databind/2.9.0/jackson-databind-2.9.0.jar \
com.fasterxml.jackson.databind.ObjectMapper | grep readValue这一步很重要:理论推出来的结论必须验证。见过太多”看着像冲突,其实是别的原因” —— 比如 jackson-core 和 jackson-databind 版本错配(core 被仲裁成 2.13、databind 是 2.9),症状一模一样但修法不同。
5. 第四步:三种解决方案及代价
方案 A:<exclusions> 排除
<dependency>
<groupId>com.thirdparty</groupId>
<artifactId>report-sdk</artifactId>
<version>1.4.0</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
</exclusions>
</dependency>- 代价:可能破坏 report-sdk —— 它是在 2.9.0 上编译测试的,强制跑在 2.15.3 上可能反而炸。且 exclusion 会传染,下游模块也看不到了。
- 适用:确认被排除的依赖对引入方无用,且写注释说明为什么排除。
方案 B:显式声明正确版本(推荐止血)
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.3</version> <!-- 深度 1,nearest wins 保证它赢 -->
</dependency>- 原理:显式声明路径深度是 1,一定赢过所有传递依赖。
- 代价:自己维护版本号。适用:单模块项目或临时止血。
方案 C:dependencyManagement 统一管理(多模块正解)
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>2.15.3</version>
<type>pom</type><scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>- 原理:
dependencyManagement的优先级高于仲裁规则,所有路径都被强制成这个版本(输出会变成version managed from,而不是omitted for conflict with)。 - 适用:多模块项目的唯一正解。
单模块、临时止血? → 方案 B
多模块、长期治理? → 方案 C
确认被排除项对引入方无用? → 方案 A(写注释说明原因)
6. 第五步:验证
mvn dependency:tree -Dverbose -Dincludes=com.fasterxml.jackson.core:jackson-databind
# 期望:只剩 2.15.3,没有 omitted 了
mvn test
mvn dependency:analyze注意标记的变化:omitted for conflict with → version managed from,等于从”被动挨打”变成”主动管控”。
7. 第六步:防复发
<plugin>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<id>enforce-deps</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules><dependencyConvergence/></rules>
</configuration>
</execution>
</executions>
</plugin>加上后,下次有人引入冲突依赖 CI 直接红,而不是等线上炸。第一次跑大概率红一大片 —— 一个三五年的项目,依赖树里通常有几十处分歧。处理方式:先把现有冲突用 dependencyManagement 一个个钉死,清理完再加 banDuplicateClasses;老项目治理不动的话,先用 mvn enforcer:display-info 看看规模再决定。
8. 教训总结
omitted for conflict with是依赖冲突的铁证,每次看 tree 都该扫一眼这个标记。- 显式声明(深度 1)永远赢过传递依赖 —— nearest wins 唯一”好”的性质,最快的止血手段。
dependencyManagement优先级高于仲裁规则 —— 多模块治理的基石。- 让构建在冲突时失败,比让人在运行时发现便宜一百倍。
【思考】为什么 Java 会有
NoSuchMethodError而 Go 几乎不会遇到?呼应第 00 章那个问题,现在你能给出更深一层的答案吗?
参考答案
直接答案:因为 Go 是编译期链接,Java 是运行时寻址。
Go: 编译期编译器就知道 json.Unmarshal 是哪个包的哪个函数,链接器把调用点地址直接写死。符号不存在?编译失败,你根本拿不到二进制。
Java: javac 生成的字节码里只是一个字符串形式的符号引用:
javap -c -p target/classes/com/example/order/web/OrderController.class | grep -A2 readValue
# 47: invokevirtual #61 // Method com/fasterxml/jackson/databind/ObjectMapper.readValue:(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;那个 #61 是常量池索引,指向 CONSTANT_Methodref 结构,里面存的是类名字符串 + 方法名 + 描述符。没有版本号,没有”来自哪个 jar”。
运行时 ClassLoader 按全限定名找类,找到第一个就加载,再在里面找方法签名。找不到 → NoSuchMethodError。名字一样,内容可能完全不同。这就是 Jar Hell 的技术根源。
注意 Java 那一行的后半句:ClassLoader 是懒加载的。 所以:
- 服务启动成功 ✅
- 健康检查通过 ✅
- 前 999 个请求正常 ✅
- 第 1000 个请求走到那条分支 → 第一次加载
ObjectMapper→NoSuchMethodError❌
这就是为什么”跑了三个月没事”和”突然炸了”可以同时成立。
第 00 章那个问题的完整因果链:
Java 部署单元 = 一组 jar + JVM
→ 类运行时按全限定名查找 → 全限定名没有版本概念
→ 多个版本的同名类可以在 classpath 共存
→ ClassLoader 找到哪个算哪个
→ 编译期用 A 版、运行期加载 B 版 → NoSuchMethodError
Java 为此付出的整套代价(第 07 章的主题):Maven/Gradle 仲裁、BOM、Shade/Relocation、ClassLoader 隔离、banDuplicateClasses。这五项在 Go 世界里一个都不存在,因为 Go 在编译期就把这个洞堵死了。
最后一层:为什么 Java 还要坚持运行时链接? 因为它换来了热部署、插件化、动态代理、字节码增强 —— 你可以不停机换 jar、运行时生成代理类。Go 放弃了这些,换来确定性和部署简单。
这笔交易没有对错,只有你处在哪个场景。但你必须知道它存在 —— 否则你会用 Go 的直觉去理解 Java,然后在 NoSuchMethodError 面前一脸茫然。