第 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. 第一步:确认是哪个类的哪个方法

从栈里读三件事:

  1. 异常类型是 NoSuchMethodError,不是 ClassNotFoundException —— 类找到了,但类里没有那个方法签名。范围立刻缩小到”版本不对”,而不是”依赖缺失”。
  2. 类名:com.fasterxml.jackson.databind.ObjectMapper → 冲突的是 jackson-databind
  3. 调用位置: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. 教训总结

  1. omitted for conflict with 是依赖冲突的铁证,每次看 tree 都该扫一眼这个标记。
  2. 显式声明(深度 1)永远赢过传递依赖 —— nearest wins 唯一”好”的性质,最快的止血手段。
  3. dependencyManagement 优先级高于仲裁规则 —— 多模块治理的基石。
  4. 让构建在冲突时失败,比让人在运行时发现便宜一百倍。

【思考】为什么 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 面前一脸茫然。