Java 基础体系 · 第 78/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Maven 完整基础:生命周期、依赖、插件、BOM、仓库和可重复构建

Maven 是一个以 项目对象模型(Project Object Model,POM) 为中心的 Java 构建工具。它不仅负责把 .java 文件编译成 .class 文件,还负责测试、打包、依赖解析、插件执行、发布工件以及多模块工程的构建顺序。

本文以 Java 25 LTS 为目标版本,完整说明 Maven 中最容易混淆、也最影响构建结果的几个概念:

  • POM 与坐标
  • 生命周期、阶段和插件目标
  • 依赖、传递依赖、作用域和冲突仲裁
  • dependencyManagement 与 BOM
  • 插件配置与插件版本
  • 本地仓库、远程仓库、镜像和认证
  • 多模块 Reactor 构建
  • 可重复构建、依赖锁定和故障诊断

Maven 的核心关系可以先概括为:

POM
 ├── 声明项目身份、依赖和构建配置
 ├── 选择 packaging,从而决定生命周期绑定
 ├── 通过生命周期阶段触发插件目标
 └── 通过仓库解析依赖和插件

一、开始前:安装 JDK 25 和 Maven

Maven 本身运行在 JVM 上。构建项目时,Maven 使用的 JDK 与项目编译目标不一定相同,但实际编译器必须能够生成目标版本的字节码。

先检查环境:

java -version
mvn -version

典型输出应能确认:

Java version: 25
Java home: ...
Apache Maven version: ...

这里有三个容易混淆的版本:

  1. 运行 Maven 的 JDK 版本
    JAVA_HOME 或 Maven Toolchains 决定。

  2. 编译器使用的 Java 版本
    通常由 Maven Compiler Plugin 的 release 参数决定。

  3. 项目运行时的 JRE/JDK 版本
    例如测试执行时实际使用的 Java 25。

对于 Java 25,推荐使用:

<properties>
    <maven.compiler.release>25</maven.compiler.release>
</properties>

release 的意义不是简单地把字节码版本设置成 25。它会要求编译器同时使用对应 Java 平台的语言规则、类文件目标版本和公开 API 约束,避免出现“字节码版本较低,但错误调用了更高版本 JDK API”的问题。

相比之下:

<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>

只分别控制语言级别和类文件目标级别,不能完整表达对应平台 API。对现代 JDK 项目,优先使用 release

Maven 和插件也有自己的 JDK 兼容范围。实际工程中应选择支持 Java 25 的 Maven 版本和插件版本,并显式固定这些版本,而不是依赖开发机当前安装的最新版本。

二、POM:Maven 构建的输入模型

POM 是 XML 格式的项目描述文件,默认文件名为:

pom.xml

一个最小的 Java 25 项目可以写成:

<?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
           https://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>hello-maven</artifactId>
    <version>1.0.0</version>

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>...</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>...</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>...</version>
                <configuration>
                    <release>25</release>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

示例中的 ... 不是可直接执行的版本号。实际项目应选择经过验证的插件和依赖版本,并将其写死。不能把 ... 原样放入 POM。

2.1 项目坐标

Maven 用坐标唯一标识一个工件,基本形式是:

groupId:artifactId:packaging:classifier:version

常见简写是:

groupId:artifactId:version

各部分含义如下:

  • groupId:组织、团队或项目组的命名空间,例如 com.example
  • artifactId:工件名称,例如 hello-maven
  • version:版本号,例如 1.0.0
  • packaging:打包类型,默认是 jar
  • classifier:同一版本下的附加变体,例如 sourcesjavadoc

例如:

com.example:hello-maven:1.0.0

默认对应:

hello-maven-1.0.0.jar

如果还发布源码包,可能会有:

hello-maven-1.0.0-sources.jar

这里的 sources 是 classifier,不是另一个 Maven 项目。

2.2 POM 继承与有效 POM

Maven 不只读取当前 pom.xml,还会合并:

  • 当前项目 POM
  • 父 POM
  • 超级 POM
  • 用户级 settings.xml
  • 全局 settings.xml
  • 命令行属性

最终结果称为 有效 POM(effective POM)

可以查看有效配置:

mvn help:effective-pom

可以查看 Maven 读取到的 settings:

mvn help:effective-settings

当你发现“POM 中没有配置,但 Maven 却执行了某个插件”时,通常应先检查有效 POM,而不是猜测 Maven 内部行为。

三、生命周期:Maven 如何组织构建流程

3.1 生命周期、阶段和目标不是同一个概念

Maven 中有三层概念:

  1. 生命周期(lifecycle)
    一组有顺序的构建阶段。

  2. 阶段(phase)
    生命周期中的命名节点,例如 compiletestpackage

  3. 插件目标(goal)
    某个插件提供的具体操作,例如编译 Java、运行测试、生成 JAR。

例如:

mvn compile

表面上执行的是 compile 阶段,但 Maven 实际会找到当前项目 packaging 对应的绑定,并执行类似:

maven-compiler-plugin:compile

因此:

compile 是阶段
compiler:compile 是插件目标

不能把二者混为一谈。

3.2 三条标准生命周期

Maven 内置三条标准生命周期:

default 生命周期

负责构建项目,主要阶段包括:

validate
initialize
generate-sources
process-sources
generate-resources
process-resources
compile
process-classes
generate-test-sources
process-test-sources
generate-test-resources
process-test-resources
test-compile
process-test-classes
test
prepare-package
package
pre-integration-test
integration-test
post-integration-test
verify
install
deploy

clean 生命周期

负责删除构建输出:

pre-clean
clean
post-clean

site 生命周期

负责生成项目站点和报告:

pre-site
site
post-site
site-deploy

这些生命周期相互独立。因此:

mvn clean

不会自动执行 compile;而:

mvn clean package

会先执行 clean 生命周期,再从 default 生命周期开始执行到 package

3.3 阶段具有累积性

执行:

mvn test

并不只执行测试插件。它会依次执行从 default 生命周期起点到 test 的所有相关阶段:

validate
initialize
...
compile
...
test-compile
test

执行:

mvn package

通常会至少经历:

validate
compile
test-compile
test
package

如果执行:

mvn install

则会继续执行 package 之后的阶段,最终把工件安装到本地仓库。

构建路径可以抽象为:

flowchart LR
    A[mvn clean package] --> B[clean 生命周期]
    B --> C[validate]
    C --> D[compile]
    D --> E[test-compile]
    E --> F[test]
    F --> G[package]
    G --> H[target/*.jar]

package 产生的是当前项目的构建产物;install 还会把它复制到本地仓库;deploy 则会发布到远程仓库。

3.4 packaging 决定默认绑定

packaging 决定 Maven 为生命周期阶段绑定哪些插件目标。

最常见的类型是:

<packaging>jar</packaging>

对于普通 Java JAR 项目,典型绑定包括:

阶段 典型目标
process-resources resources:resources
compile compiler:compile
process-test-resources resources:testResources
test-compile compiler:testCompile
test Surefire 的测试目标
package jar:jar
install install:install
deploy deploy:deploy

这里的“典型”很重要:具体插件版本和 Maven 版本可能影响默认绑定细节。需要确认实际行为时,使用 mvn help:effective-pom 或打开详细日志。

常见 packaging 还包括:

  • pom:父项目、BOM 或聚合项目
  • war:Web 应用归档
  • ear:企业应用归档

一个 pom 项目通常不编译 Java 源码,但可以管理模块、依赖版本或插件配置。

3.5 直接调用插件目标

除了阶段,也可以直接调用插件目标:

mvn org.apache.maven.plugins:maven-compiler-plugin:compile

或者使用插件前缀:

mvn compiler:compile

完整坐标调用更明确,因为插件前缀可能依赖插件组搜索和本地元数据:

groupId:artifactId:version:goal

例如:

mvn org.apache.maven.plugins:maven-compiler-plugin:版本:compile

直接调用插件目标不会自动提供完整生命周期上下文。比如直接运行 compiler:compile,可能没有先执行资源处理、代码生成或依赖准备。因此,正常构建通常使用:

mvn clean verify

而不是把多个插件目标手工拼接起来。

四、插件:生命周期阶段真正执行工作的组件

插件是 Maven 执行具体任务的扩展组件。常见插件包括:

  • Compiler Plugin:编译主代码和测试代码
  • Surefire Plugin:运行单元测试
  • Failsafe Plugin:运行集成测试
  • Resources Plugin:复制和过滤资源
  • Jar Plugin:生成 JAR
  • Source Plugin:生成源码包
  • Javadoc Plugin:生成 API 文档
  • Enforcer Plugin:验证构建约束
  • Dependency Plugin:分析、复制和预取依赖

插件由一个或多个目标组成:

插件:目标

例如:

compiler:compile
compiler:testCompile
jar:jar

4.1 插件版本必须显式固定

依赖版本不固定会导致依赖漂移,插件版本不固定同样会导致构建行为漂移。

推荐显式声明插件版本:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <version>...</version>
            <configuration>
                <release>25</release>
            </configuration>
        </plugin>

        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>...</version>
        </plugin>
    </plugins>
</build>

<plugins> 中的插件会参与当前项目构建;<pluginManagement> 只提供默认配置和版本,除非插件同时出现在 <plugins> 中,否则不会自动执行。

例如:

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>...</version>
            </plugin>
        </plugins>
    </pluginManagement>

    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
        </plugin>
    </plugins>
</build>

第一部分管理版本,第二部分启用插件。

4.2 绑定自定义目标

插件目标可以绑定到指定阶段:

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>exec-maven-plugin</artifactId>
    <version>...</version>
    <executions>
        <execution>
            <id>print-java-version</id>
            <phase>verify</phase>
            <goals>
                <goal>exec</goal>
            </goals>
            <configuration>
                <executable>${java.home}/bin/java</executable>
                <arguments>
                    <argument>-version</argument>
                </arguments>
            </configuration>
        </execution>
    </executions>
</plugin>

当执行:

mvn verify

Maven 到达 verify 阶段时,才会执行这个 exec 目标。

一个 execution 的 id 用于区分同一个插件的多次执行。绑定目标时,必须考虑:

  • 目标执行的阶段是否足够晚或足够早
  • 它依赖的输入是否已经生成
  • 它产生的输出是否会被后续阶段消费
  • 是否会在 mvn testmvn packagemvn verify 中重复执行

4.3 编译、测试和集成测试的边界

普通单元测试通常放在:

src/test/java

并由 Surefire 在 test 阶段执行。

集成测试通常放在:

src/test/java

但命名为 *IT.java,由 Failsafe 在以下阶段执行:

pre-integration-test
integration-test
post-integration-test
verify

常见流程是:

  1. pre-integration-test:启动测试依赖的服务
  2. integration-test:运行集成测试
  3. post-integration-test:停止服务和清理资源
  4. verify:检查集成测试结果

如果只执行:

mvn package

可能不会执行完整的集成测试闭环,因为 package 早于 verify。需要集成测试时,应执行:

mvn verify

五、依赖:从声明到类路径的完整过程

5.1 直接依赖和传递依赖

项目在 POM 中直接声明的依赖是直接依赖:

<dependency>
    <groupId>org.example</groupId>
    <artifactId>example-core</artifactId>
    <version>1.2.3</version>
</dependency>

如果 example-core 又依赖 example-util,则当前项目会间接获得 example-util。后者称为传递依赖。

依赖关系可以表示为图:

当前项目
 ├── A:1.0
 │    └── C:1.0
 └── B:2.0
      └── C:2.0

当前项目同时得到 C:1.0C:2.0 的请求,但最终类路径通常只能选择一个版本。

5.2 依赖仲裁:为什么最终只有一个版本

Maven 的经典依赖仲裁规则是 nearest definition,即离当前项目最近的定义优先。

将依赖树表示为深度:

项目
├── A:1.0
│   └── C:1.0       深度 2
└── B:2.0
    └── D:1.0
        └── C:2.0   深度 3

C:1.0 距离项目更近,因此通常选择 C:1.0

如果两个版本处于相同深度,经典 Maven 3 行为通常受声明顺序影响。这个规则容易因依赖添加顺序而改变,因此不能把“最终选中的版本”当作稳定的架构约束。

查看实际依赖树:

mvn dependency:tree

查看详细的冲突信息:

mvn dependency:tree -Dverbose

例如可能看到:

com.example:app
+- org.example:a:1.0
|  \- org.example:c:1.0
\- org.example:b:2.0
   \- org.example:c:2.0 (omitted for conflict with 1.0)

5.3 显式声明解决关键冲突

如果项目直接依赖某个库,就应在项目中明确声明它需要的版本:

<dependency>
    <groupId>org.example</groupId>
    <artifactId>c</artifactId>
    <version>2.0</version>
</dependency>

这样 C 在当前项目层级被直接声明,通常会优先于更深层的传递版本。

但这并不保证二进制兼容。强行把 C:1.0 改成 C:2.0 可能导致:

  • NoSuchMethodError
  • NoClassDefFoundError
  • ClassNotFoundException
  • 行为语义变化
  • SPI 实现不兼容

因此,依赖仲裁只解决“选择哪个工件”,不负责证明该版本与调用方兼容。

5.4 作用域决定依赖进入哪些类路径

常用作用域如下:

scope 编译主代码 编译测试 运行主代码 测试运行 传递给下游
compile
provided 否,通常由容器提供 通常否
runtime
test
system 取决于配置 取决于配置

例如 JUnit 只应进入测试类路径:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>...</version>
    <scope>test</scope>
</dependency>

数据库驱动常见为:

<dependency>
    <groupId>org.example</groupId>
    <artifactId>database-driver</artifactId>
    <version>...</version>
    <scope>runtime</scope>
</dependency>

Web 容器已经提供 Servlet API 时,应用通常使用:

<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>...</version>
    <scope>provided</scope>
</dependency>

provided 的含义是“编译时需要,但部署运行环境负责提供”,不是“永远不会被打包”。最终行为还取决于 WAR、容器或其他打包插件的规则。

system 依赖直接指向本机文件,例如:

<scope>system</scope>
<systemPath>${project.basedir}/lib/vendor.jar</systemPath>

它依赖特定文件路径,不能正常从仓库解析,也会破坏跨机器构建,除非是在极特殊的遗留场景中使用。

5.5 optional 与 exclusions

optional 控制依赖是否继续传递给下游项目:

<dependency>
    <groupId>org.example</groupId>
    <artifactId>feature-x</artifactId>
    <version>1.0.0</version>
    <optional>true</optional>
</dependency>

如果项目 A 声明了 optional 依赖 B,那么 C 依赖 A 时,通常不会自动获得 B

这适合表示:

  • 某个可选功能
  • 某个平台专用实现
  • 只有直接使用者才应决定的适配器

exclusions 则是在某条依赖边上排除传递依赖:

<dependency>
    <groupId>org.example</groupId>
    <artifactId>feature-x</artifactId>
    <version>1.0.0</version>
    <exclusions>
        <exclusion>
            <groupId>org.example</groupId>
            <artifactId>old-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>

排除是局部的:从某个依赖路径排除,并不等于从整个依赖图删除。如果另一个路径仍然引入该工件,它仍可能出现在最终类路径中。

六、BOM 与 dependencyManagement

6.1 dependencyManagement 不会自动引入依赖

下面的配置只管理版本:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.example</groupId>
            <artifactId>example-core</artifactId>
            <version>1.2.3</version>
        </dependency>
    </dependencies>
</dependencyManagement>

它不会让 example-core 自动进入类路径。还必须在 <dependencies> 中声明:

<dependencies>
    <dependency>
        <groupId>org.example</groupId>
        <artifactId>example-core</artifactId>
    </dependency>
</dependencies>

前者是“规则”,后者是“实际使用”。

6.2 BOM 是什么

BOM 是 Bill of Materials,即一组经过协调的依赖版本清单。BOM 通常本身是:

<packaging>pom</packaging>

导入 BOM 的写法是:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.example</groupId>
            <artifactId>example-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

之后可以省略 BOM 管理范围内依赖的版本:

<dependencies>
    <dependency>
        <groupId>org.example</groupId>
        <artifactId>example-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.example</groupId>
        <artifactId>example-json</artifactId>
    </dependency>
</dependencies>

这里的 import 只在 dependencyManagement 中具有导入 BOM 的特殊意义。它不是运行时作用域,也不会把 BOM 本身放进应用类路径。

6.3 BOM、父 POM 和普通依赖的区别

三者解决的问题不同:

机制 主要作用
普通依赖 当前项目实际使用某个工件
dependencyManagement 统一约束依赖版本、scope、exclusions 等默认值
BOM 以 POM 形式提供一组协调版本
parent POM 继承属性、依赖管理、插件管理和其他构建配置

一个项目可以同时:

  • 继承一个父 POM
  • 导入多个 BOM
  • 直接声明自己的业务依赖

父 POM 的继承关系只有一条,而 BOM 可以在依赖管理中导入多个。多个 BOM 之间如果管理同一个坐标,最终结果需要检查有效 POM,不能仅凭文件阅读顺序猜测。

6.4 BOM 不保证所有依赖都兼容

BOM 提供的是一组版本选择,不是形式化兼容性证明。仍然需要验证:

  • 主版本之间是否有二进制兼容问题
  • 是否存在重复实现
  • 运行时环境是否提供了另一版本
  • 模块系统是否出现拆分包或读取边界问题
  • 测试和生产类路径是否不同

可以使用:

mvn dependency:tree

并配合 Enforcer 等规则检查依赖收敛。例如,要求同一坐标不能出现多个版本:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-enforcer-plugin</artifactId>
    <version>...</version>
    <executions>
        <execution>
            <id>enforce-dependency-convergence</id>
            <goals>
                <goal>enforce</goal>
            </goals>
            <configuration>
                <rules>
                    <dependencyConvergence/>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

这类规则可以把运行期冲突提前变成构建失败,但也可能暴露第三方库自身无法统一版本的问题,需要结合依赖树处理。

七、仓库:Maven 从哪里取得依赖和插件

Maven 仓库保存工件及其元数据。一个普通依赖通常至少涉及:

groupId/artifactId/version/
 ├── artifact.jar
 ├── artifact.pom
 ├── checksum
 └── 其他元数据

7.1 本地仓库

默认本地仓库通常是:

~/.m2/repository

它承担三项作用:

  1. 缓存远程下载的依赖和插件
  2. 保存 mvn install 安装的本地工件
  3. 在离线或网络故障时提供已缓存内容

可以指定其他本地仓库:

mvn -Dmaven.repo.local=/path/to/m2-repository verify

这对 CI 隔离缓存、不同项目使用不同缓存目录很有用,但多个进程同时写同一个仓库目录时仍可能发生锁竞争或缓存损坏,应由 CI 系统管理并发策略。

7.2 远程仓库、插件仓库和镜像

项目依赖通常配置在:

<repositories>
    <repository>
        <id>company-releases</id>
        <url>https://repo.example.com/releases</url>
        <releases>
            <enabled>true</enabled>
        </releases>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>

插件仓库是另一类配置:

<pluginRepositories>
    <pluginRepository>
        <id>company-plugins</id>
        <url>https://repo.example.com/plugins</url>
    </pluginRepository>
</pluginRepositories>

依赖仓库和插件仓库概念上不同。一个仓库服务器可能同时提供二者,但 POM 配置中的用途并不相同。

企业环境常通过 settings.xml 配置镜像:

<settings>
    <mirrors>
        <mirror>
            <id>company-mirror</id>
            <mirrorOf>*</mirrorOf>
            <url>https://repo.example.com/maven-group</url>
        </mirror>
    </mirrors>
</settings>

mirrorOf 的匹配范围决定哪些仓库请求会被重定向到该镜像。错误的镜像配置可能造成:

  • 所有依赖都无法解析
  • 插件能下载但项目依赖不能下载
  • 内部仓库工件被错误转发到公共仓库
  • 认证信息未匹配正确的 id

7.3 release、SNAPSHOT 和更新策略

1.0.0 通常表示 release 版本;1.0.0-SNAPSHOT 表示开发中的快照版本。

SNAPSHOT 不是一个永久固定文件。仓库可能通过元数据把它映射到不同时间生成的实际快照工件。因此,直接依赖 SNAPSHOT 会损害可重复构建。

仓库还会记录更新时间、校验和以及元数据。构建失败时,先区分是:

Could not find artifact

还是:

Could not transfer artifact

前者通常是坐标、版本或仓库内容问题;后者更可能是网络、证书、认证、代理或镜像问题。

强制刷新远程元数据:

mvn -U verify

-U 会要求 Maven 检查更新,不代表一定能修复问题,也可能增加网络请求并取得新的 SNAPSHOT。

离线构建:

mvn -o verify

如果本地缓存不完整,离线模式会立即失败。它不会凭空生成缺失依赖。

7.4 认证信息不应写入 POM

仓库账号和密码应放入 settings.xml<servers> 中:

<settings>
    <servers>
        <server>
            <id>company-releases</id>
            <username>...</username>
            <password>...</password>
        </server>
    </servers>
</settings>

这里的 id 必须与仓库或发布配置中的 id 匹配。密码不应提交到 Git,也不应直接写在项目 POM 中。

实际生产环境还应使用 CI Secret、短期令牌或加密凭据,并限制发布权限。

八、多模块项目与 Reactor

多模块项目通常有一个聚合 POM:

<project>
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>demo-parent</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <modules>
        <module>demo-api</module>
        <module>demo-app</module>
    </modules>
</project>

目录结构:

demo-parent/
├── pom.xml
├── demo-api/
│   ├── pom.xml
│   └── src/
└── demo-app/
    ├── pom.xml
    └── src/

demo-app 依赖 demo-api

<dependency>
    <groupId>com.example</groupId>
    <artifactId>demo-api</artifactId>
    <version>1.0.0</version>
</dependency>

当从根目录执行:

mvn clean verify

Maven Reactor 会分析模块间的项目依赖,并先构建被依赖模块,再构建依赖它的模块:

demo-api
   │
   ▼
demo-app

这与简单按照 <modules> 文件顺序执行不同。模块声明顺序通常应保持清晰,但真正的构建顺序还取决于模块依赖关系。

只构建某个模块及其上游依赖:

mvn -pl demo-app -am verify

含义是:

  • -pl demo-app:选择指定项目
  • -am:同时构建它依赖的 Reactor 项目

只构建指定模块而不构建上游:

mvn -pl demo-app verify

如果 demo-api 尚未安装到本地仓库,后者可能失败。

聚合和继承不是同一个概念:

  • <modules> 表示“从这里聚合构建哪些模块”
  • <parent> 表示“当前 POM 继承谁的配置”

一个 POM 可以是聚合器但不被子模块继承,也可以是父 POM但不负责聚合任何模块。

九、从命令到文件的端到端示例

项目结构:

hello-maven/
├── pom.xml
├── src/
│   ├── main/
│   │   ├── java/com/example/App.java
│   │   └── resources/app.properties
│   └── test/
│       └── java/com/example/AppTest.java

主代码:

package com.example;

import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;

public final class App {
    private App() {
    }

    public static String message() throws IOException {
        Properties properties = new Properties();

        try (InputStream input = App.class.getResourceAsStream("/app.properties")) {
            if (input == null) {
                throw new IOException("app.properties not found");
            }
            properties.load(input);
        }

        return properties.getProperty("message");
    }

    public static void main(String[] args) throws IOException {
        System.out.println(message());
    }
}

资源文件:

message=hello from Maven

如果 POM 已配置 Java 25 编译:

mvn clean verify

构建过程中的关键输入输出是:

src/main/java
    └── compile ──> target/classes/*.class

src/main/resources
    └── resources ──> target/classes/app.properties

src/test/java
    └── test-compile ──> target/test-classes/*.class

target/classes + target/test-classes + test dependencies
    └── test ──> 测试结果

target/classes
    └── package ──> target/hello-maven-1.0.0.jar

运行主类可以使用经过配置的执行插件,或者直接使用 JDK:

java -cp target/classes com.example.App

预期输出:

hello from Maven

这里直接使用 target/classes 运行,而不是运行 JAR,原因是这个示例只展示普通 classpath 项目。若要通过:

java -jar target/hello-maven-1.0.0.jar

运行,则还需要在 JAR 的 manifest 中配置 Main-Class,或者使用能够生成可执行归档的打包插件。普通 Maven JAR 默认不一定包含可执行入口配置。

十、可重复构建:同样输入为什么应得到同样结果

可重复构建(reproducible build) 是指在相同且明确的构建输入下,多次构建应得到等价的输出。这里的“等价”可能是:

  1. 字节级完全相同
  2. 经过规范化后内容相同
  3. 至少依赖解析结果、类文件和功能行为相同

要达到这个目标,必须先定义输入边界。

可以把构建结果抽象为:

输出 = F(源代码, POM, 父 POM, 依赖, 插件, JDK, Maven, 仓库内容, 环境变量, 时间, 时区)

只固定源代码和业务依赖还不够,因为插件、JDK、仓库元数据和环境也会影响输出。

10.1 固定项目和插件版本

以下内容都应避免使用动态版本:

<version>LATEST</version>
<version>RELEASE</version>
<version>1.0.0-SNAPSHOT</version>

尤其不要依赖:

  • LATEST
  • RELEASE
  • 未固定的 SNAPSHOT
  • 未固定的父 POM
  • 未固定的插件版本

应固定:

  • 当前项目版本
  • 父 POM 版本
  • 直接依赖版本
  • BOM 版本
  • 插件版本
  • 插件传递依赖中会影响产物的关键版本
  • Java 和 Maven 版本

Maven 本身并不自动提供类似某些包管理器的通用依赖锁文件机制。dependencyManagement 和 BOM 能集中管理版本,但它们不是对完整解析结果的不可变快照。

10.2 固定仓库内容,而不是只固定 URL

即使 POM 中写了固定版本,仓库内容也可能发生变化,尤其是:

  • SNAPSHOT 被重新发布
  • 私有仓库管理员替换了工件
  • 镜像代理了不同来源
  • 元数据发生变化
  • 校验策略不一致

生产构建通常使用经过审核的内部代理仓库,并保留工件校验、审计和不可变发布策略。

可以检查依赖文件:

mvn dependency:tree

也可以预先下载依赖:

mvn dependency:go-offline

go-offline 不是完美的“离线构建证明”。某些插件可能动态解析额外工件,某些 profile 只有激活后才会引入依赖。最终验证仍应在隔离网络环境中执行:

mvn -o clean verify

10.3 固定 JDK、Maven 和环境

同一 Java 源代码在不同 JDK 中可能因为以下因素得到不同结果:

  • 编译器诊断变化
  • 注解处理器行为变化
  • JAR 工具和归档实现变化
  • 默认字符集变化
  • 时区和本地化设置变化

项目可以通过 Maven Enforcer 约束 Java 和 Maven 版本,也可以使用 Maven Toolchains 选择指定 JDK。Toolchains 的作用是让 Maven 插件使用配置好的 JDK,而不是简单依赖当前 JAVA_HOME

同时应显式设置:

<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>

测试和代码生成还可能受到以下环境影响:

  • user.language
  • user.timezone
  • 操作系统路径分隔符
  • 文件系统排序顺序
  • 当前时间
  • 用户名和工作目录
  • 环境变量

如果生成文件包含时间戳、绝对路径或随机值,即使依赖和 JDK 固定,JAR 也可能仍然不同。

10.4 JAR 为什么可能每次都不同

JAR 本质上是 ZIP 格式归档。归档差异可能来自:

  • 文件条目的时间戳
  • 文件加入归档的顺序
  • 生成的 manifest
  • 编译器生成的调试信息
  • 插件写入的构建时间
  • 资源过滤时注入的变量

因此,“源码相同”不等于“JAR 二进制相同”。

要验证两个构建结果:

sha256sum target/*.jar

如果哈希不同,需要进一步比较:

unzip -l target/app.jar
unzip -p target/app.jar META-INF/MANIFEST.MF

再检查:

  • 文件顺序是否改变
  • 时间戳是否改变
  • manifest 是否包含动态字段
  • class 文件是否由不同 JDK 生成
  • 资源文件是否被过滤

是否能做到字节级重复,取决于所使用 Maven 版本、插件版本及其对可重复归档的支持。不能仅通过设置一个 Maven 属性就假设所有插件都已实现可重复输出。

十一、常用命令的实际含义

11.1 mvn clean package

mvn clean package

结果:

  1. 删除 target
  2. 编译主代码
  3. 编译测试代码
  4. 运行测试
  5. 生成 JAR、WAR 或其他打包产物

它不会把产物安装到本地仓库,也不会发布到远程仓库。

11.2 mvn install

mvn install

它会执行到 install 阶段,并把产物和 POM 安装到本地仓库,例如:

~/.m2/repository/com/example/hello-maven/1.0.0/

本地其他项目随后可以通过坐标引用这个工件。

11.3 mvn deploy

mvn deploy

它会执行到 deploy 阶段,并把 release 或 snapshot 发布到配置的远程仓库。失败风险包括:

  • 权限不足
  • 仓库禁止覆盖 release
  • 版本策略不匹配
  • 远程连接中断
  • POM 中包含错误的发布元数据

发布前应先执行:

mvn clean verify

确认测试和校验通过,再执行发布。

11.4 mvn dependency:tree

mvn dependency:tree

它回答的是:

最终依赖图中有哪些依赖?
哪些是直接依赖?
哪些是传递依赖?
哪些版本被冲突仲裁排除了?

如果运行期报类缺失,应比较:

  • 编译类路径
  • 测试类路径
  • 最终打包内容
  • 实际运行环境类路径

“编译成功”不能证明“运行时一定有这个类”。

十二、常见失败表现与诊断路径

12.1 release version 25 not supported

常见原因是 Maven 实际使用的 JDK 低于 25,或者编译插件版本过旧。

诊断:

mvn -version

重点查看 Maven 使用的 Java home,而不是只查看 shell 中的:

java -version

修复方向:

  1. 设置正确的 JAVA_HOME
  2. 确认 Maven 使用 JDK 25
  3. 使用支持 Java 25 的 Compiler Plugin
  4. 清理后重新构建

12.2 Could not resolve dependencies

按顺序检查:

  1. 坐标是否拼写正确
  2. 版本是否真实存在
  3. 仓库是否包含该 release 或 snapshot
  4. settings 镜像是否覆盖了正确仓库
  5. server.id 是否匹配
  6. 证书、代理和网络是否正常
  7. 本地缓存是否损坏

可以尝试:

mvn -U -X verify

其中:

  • -U 强制检查更新
  • -X 输出调试日志

不要一看到解析失败就删除整个 ~/.m2。先定位具体工件,再删除对应目录,避免重新下载大量正常依赖。

12.3 NoSuchMethodErrorNoClassDefFoundError

这通常不是编译错误,而是运行时类路径与编译时类路径不一致,或者版本仲裁选择了不兼容版本。

诊断步骤:

mvn dependency:tree -Dverbose

然后检查最终包:

jar tf target/app.jar

如果是外部 classpath 运行,还要检查启动命令中的所有 JAR。处理时不要盲目排除依赖,应确认:

  • 哪个库调用了该 API
  • 哪个版本实际被选中
  • 调用方需要哪个版本
  • 另一个库是否与该版本兼容

12.4 “我配置了 pluginManagement,为什么插件没执行?”

因为:

<pluginManagement>
    ...
</pluginManagement>

只负责管理,不负责启用。需要在:

<plugins>
    ...
</plugins>

中声明该插件,或者由 packaging 的默认绑定触发它。

12.5 “我导入了 BOM,为什么依赖没进来?”

BOM 通常放在:

<dependencyManagement>

中,只提供版本管理。仍然需要在:

<dependencies>

中声明实际使用的依赖。

十三、构建配置的分层取舍

一个稳定的 Maven 工程通常把配置分成三层:

项目 POM

放项目本身必须知道的内容:

  • 坐标
  • 直接依赖
  • Java release
  • 必须执行的插件
  • 项目级测试和打包规则

父 POM 或公司级 POM

放多个项目共享的内容:

  • 插件版本
  • 编译编码
  • Enforcer 规则
  • 测试报告
  • 发布约定
  • 公共仓库策略

settings.xml

放机器或环境相关的内容:

  • 仓库镜像
  • 私服认证
  • 代理
  • 本地仓库路径
  • 环境 profile

把密码写进 POM 是错误的分层;把某个项目独有的编译规则写入全公司父 POM,也会增加无关项目的耦合。

十四、一个可验证的构建检查序列

对 Java 25 Maven 项目,可以按以下顺序建立基线:

mvn -version
mvn help:effective-pom
mvn dependency:tree -Dverbose
mvn clean verify
mvn -o clean verify

每一步验证不同问题:

  1. mvn -version
    确认实际 Maven、Java 和 Java home。

  2. help:effective-pom
    确认继承、插件绑定和依赖管理合并后的结果。

  3. dependency:tree -Dverbose
    确认传递依赖和版本冲突。

  4. clean verify
    在干净输出目录上完成编译、测试、集成校验和打包前验证。

  5. -o clean verify
    验证当前本地仓库是否包含构建所需内容,并减少网络因素。

如果最后一步失败,失败本身说明构建依赖尚未被完整缓存,或者某些插件、profile、代码生成步骤仍然隐式依赖网络。

Maven 的生命周期解决“什么时候做什么”,插件解决“具体怎么做”,依赖解析解决“从哪里取得哪些类”,BOM 和 dependencyManagement 解决“多个项目如何统一版本”,仓库解决“工件如何保存和传输”,而可重复构建则要求把这些输入和环境共同固定下来。只有把这些层次区分清楚,Maven POM 中的每一行配置才有可解释、可诊断的因果关系。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。