你在终端里敲下mvn clean install,回车,看着日志哗哗刷屏,最后等来一个 BUILD SUCCESS。但如果你被问一句:“这条命令到底让 Maven 做了多少事?”多半会愣一下。我刚开始用 Maven 那两年也是“会念咒不会解咒”,直到有次排查一个诡异的构建问题,才被迫把三大生命周期和插件绑定机制从头啃了一遍。啃完之后回头看,所谓构建逻辑,其实就是一条标好顺序的流水线;而拨开云雾的关键,是看清这条流水线上每个节点到底在干什么。这篇文章不绕弯子,直接从 Maven 的三大生命周期(clean、default、site)和它们背后的构建逻辑拆起,适合所有被 Maven 命令搞晕的新人,也适合那些天天用 Maven 却从没想过“package 之前到底发生了什么”的老油条。
1. 一条 mvn 命令背后:命令是按钮,阶段才是流程
很多人第一次接触 Maven 时,很容易把mvn package理解成“执行一个打包任务”。这个理解不算全错,但它会让你对构建过程产生严重误判。Maven 本质上不是一个按任务执行的工具,而是一个按阶段推进的框架。你输入的命令不是“动作”,而是“你要让生命周期走到哪里”。
1.1 你不是在执行某个任务,而是在推进生命周期
mvn package的真实含义是:把 default 生命周期从第一个阶段 validate 开始,一直推进到 package 阶段。命令行里出现的 compile、test、package、install,并不是插件任务名,而是“目标阶段”。Maven 在启动时会先解析命令,组装出一份构建计划;凡是目标阶段之前的阶段都会被遍历一次,每到一个阶段,就把绑在该阶段上的所有插件目标拉起来干活。
一个很容易忽略的事实是:你执行mvn compile时,Maven 并不会直接跳到编译这一步。它照样会先从 validate、initialize、generate-sources、process-sources、generate-resources、process-resources 一路走下来,然后才到达 compile。只是前面这些阶段大多没有绑定默认插件,日志里看不到什么动静,所以你可能一直没察觉它们存在。
这就像电饭煲的“煮饭”按钮:按下它,机器不是只加热一下,而是从注水、预热、升温、保温走完一整套流程。Maven 的“按钮”和“流程”就是这个关系。想验证也很简单,在项目里跑一句mvn compile -X,日志会多到让你怀疑人生,但里面确实把每个阶段和每个插件目标打印得清清楚楚。
1.2 阶段模型为什么比自定义脚本更耐用
早期很多项目喜欢用一串 shell 命令或 Ant 脚本完成构建:先javac,再copy,再jar,顺序写死在脚本里。这样做不是不行,但维护成本很高。顺序靠人肉保证,一旦某个项目多一个步骤、少一个步骤,脚本就得跟着改;不同项目的脚本风格天差地别,换个人接手就是灾难。
Maven 的阶段模型把“流程顺序”内置到生命周期里:项目只需要按约定提供源码和配置,Maven 保证任何人在任何机器上执行mvn package都得到同一套行为。插件则像可插拔的工人,挂到固定工位上。新增能力不用改流程,只要把插件绑定到某个阶段;生命周期作为稳定契约,插件作为契约上的服务提供者。这就是“阶段”比“命令脚本”更耐用的根本原因。
2. 三大生命周期分工:clean 负责清扫,default 负责生产,site 负责宣传
既然叫“三大生命周期”,首先要明确:它们不是同一条路上的三段,而是三条相互独立的流水线。clean、default、site 各管各的,互不干扰。平时最常见的mvn clean install,其实是同时跑了两条流水线:clean 清理完旧产物,default 重新构建并安装。理解了这个前提,后面所有命令组合都清晰了。
2.1 clean 生命周期:清理工作的三个阶段
clean 生命周期只做一件事:清理构建产物。它包含三个阶段:pre-clean、clean、post-clean。绝大多数项目只用到了中间的 clean 阶段,它默认绑定maven-clean-plugin的 clean 目标,动作是删除整个 target 目录。pre-clean 和 post-clean 留给少数的定制场景,比如清理前记录构建信息、清理后删除外部临时文件。但它们的意义在于:即使是清理这件事,Maven 也保持了“阶段 + 插件”的统一模型。
很多人会踩一个疑问:执行mvn compile不会清理 target,所以上次构建留下的旧 class、旧资源都还在。如果某次构建产生了脏东西,你可能需要先执行mvn clean。这也是为什么持续集成流水线里普遍使用 clean 开头——不是强迫症,是避免旧产物混进新构建。
2.2 default 生命周期:从 validate 到 deploy 的 23 个阶段
default 生命周期是 Maven 的核心,也是标题里“构建逻辑”的主体。它的完整阶段序列是:
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
一共 23 个阶段。你不需要把它们全部背下来,但至少要能在脑子里画出几个关键拐点:process-resources(资源复制)、compile(主代码编译)、test(单元测试)、package(打包)、install(装进本地仓库)、deploy(上传远程仓库)。下面这张表把常用阶段和它们的作用对应起来。
| 阶段 | 边界动作 | 产物或作用 |
|---|---|---|
| validate | 校验 pom 配置是否合法 | 配置错误尽早暴露,避免后期白跑 |
| process-resources | 复制 src/main/resources 下的文件 | 文件进入 target/classes,等待打包 |
| compile | 编译 src/main/java | .java 变成 .class,落在 target/classes |
| process-test-resources | 复制测试资源 | 测试文件进入 target/test-classes |
| test-compile | 编译测试代码 | 测试 .class 进入 target/test-classes |
| test | 执行单元测试 | 测试报告生成,失败会中断构建 |
| package | 按 packaging 类型打包 | jar、war 出现在 target 目录 |
| verify | 运行集成测试与质量检查 | 构建结果验证,常见于发布前闸口 |
| install | 安装到本地仓库 | jar 出现在 ~/.m2/repository 对应坐标目录 |
| deploy | 部署到远程仓库 | 私服或中央仓库出现本版本产物 |
注意一个容易混淆的点:mvn package只走到 package 阶段,不会执行 verify、install、deploy;mvn install会从 validate 一直走到 install;mvn deploy才把产物真正推到远程。
2.3 site 生命周期:文档也是构建产物
site 生命周期包含 pre-site、site、post-site、site-deploy 四个阶段,负责生成和发布项目站点:依赖树、插件报告、javadoc、团队信息等,最后用 site-deploy 把站点发布到远程服务器。很多人日常开发用不到它,但它和 clean、default 遵循同一套逻辑,所以理解成本很低。组合命令mvn clean site就是先清掉旧目录,再生成新文档,机制上没有新东西。
3. 插件绑定是生命周期的灵魂:阶段是骨架,目标才是动作
如果生命周期只是流水线上的空站点,那谁来完成具体动作?答案就是插件目标(goal)。阶段负责“什么时候执行”,插件目标负责“执行什么”。理解这层关系,才算真正看懂了 Maven 的构建逻辑。
3.1 先分清 phase 和 goal:compiler:compile 不是阶段
很多人看到日志里的compiler:compile,以为这就是“阶段的名字”。其实compiler:compile是maven-compiler-plugin插件的 compile 目标,完整坐标写法是groupId:artifactId:version:goal,比如org.apache.maven.plugins:maven-compiler-plugin:3.13.0:compile。简写compiler:compile只是省掉了版本号。
关键点在:如果你清空所有插件绑定,Maven 生命周期照样会走一遍,但什么实际动作都不会发生。阶段只是“到点了”,谁来干活取决于绑定。这也是为什么生命周期稳定、插件可以无限扩展:你想在编译前生成代码、想在打包后加密 class、想在测试前启动容器,都能通过插件绑到对应阶段,不需要改动生命周期本身。
3.2 默认绑定速查:jar、war、pom 三种打包对应关系
Maven 在超级 POM 里已经为常见 packaging 类型预设了默认绑定。这是“零配置可直接构建”的基石。下面是三种常见 packaging 的关键对应关系。
| packaging | 关键阶段 | 默认绑定的插件目标 |
|---|---|---|
| jar | process-resources | resources:resources |
| jar | compile | compiler:compile |
| jar | test | surefire:test |
| jar | package | jar:jar |
| jar | install | install:install |
| jar | deploy | deploy:deploy |
| war | package | war:war |
| pom | install | install:install |
这些默认绑定解释了为什么打包出来的是 jar 而不是其他格式:只要你在 pom 里设置<packaging>war</packaging>,package 阶段绑定的插件目标就会换成 war 插件,其余阶段的默认绑定不变。构建逻辑整体一致,变化的只是“同一个工位上换了个工人”。
3.3 手写 execution:让自定义插件在指定阶段出场
默认绑定只解决通用场景,真正灵活的是自定义 execution。下面这段 pom 配置把 maven-antrun-plugin 的 run 目标挂到了 generate-sources 阶段。
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-antrun-plugin</artifactId> <version>3.1.0</version> <executions> <execution> <id>generate-config</id> <phase>generate-sources</phase> <goals> <goal>run</goal> </goals> <configuration> <target> <echo message="generate-sources 阶段执行自定义任务" /> </target> </configuration> </execution> </executions> </plugin> </plugins> </build>execution 的 id 可以随便起,但要有语义;phase 指定触发时机;goals 指定要执行的插件目标。同一个插件可以在多个 execution 里绑定不同阶段。我见过很多新人把 phase 写错,比如把代码生成挂到 verify 之后,导致生成的类根本赶不上本次编译——这就是“绑定错误,类蒸发”的典型案例,第六章会展开讲。
4. 一个 jar 的完整旅程:从 mvn clean install 看构建流水线
这一节把前面的概念拼起来,看一次完整的mvn clean install到底对项目做了什么。我以最常见的 jar 工程为例,按阶段推进顺序拆成几步,每一步都对应 target 目录里可见的变化。
4.1 一次 clean install 的内部步骤切片
- clean:maven-clean-plugin 删除整个 target 目录,保证从零开始。
- validate 到 process-resources:校验 pom,复制 src/main/resources 下的所有文件到 target/classes。这一步之后,target/classes 里已经有资源文件,但还没有 class。
- compile:maven-compiler-plugin 编译 src/main/java 下的 Java 源码,把 .class 文件也写进 target/classes。此时 main 代码的产物已经完整了。
- process-test-resources 到 test-compile:复制测试资源、编译测试代码,产物落在 target/test-classes。
- test:surefire 插件执行单元测试,跑的是 target/test-classes 里的测试类。测试失败时构建直接中断,不会继续打包。
- package:maven-jar-plugin 把 target/classes 的内容压缩成 jar,放到 target 目录。
- install:maven-install-plugin 把 jar 复制到本地仓库,并生成对应的 pom、校验和文件。路径一般是 ~/.m2/repository/{groupId}/{artifactId}/{version}/。
跑完后你可以同时检查 target 目录和本地仓库目录,两边都会出现产物。这一步验证比单纯看日志更直观。
4.2 resources 的复制节点:为什么改完配置可以直接用
很多人改完 src/main/resources 下的配置文件,重新执行mvn package,发现配置生效了,就会下意识觉得“Maven 重新生成了整个 target”。其实 Maven 只是在 process-resources 阶段把源目录文件重新复制了一次,覆盖了 target/classes 里的旧文件。因为 process-resources 位于 compile 之前,所以改动会在打包时自动带进 jar。
但要记住:这个复制操作是“覆盖/新增”语义,不是“同步目录”。如果你从 src/main/resources 里删除了某个文件,target/classes 下对应的旧文件不会自动消失。这也是第六章“旧资源残留”问题的根源。所以正式发布前用 clean 开头,不只是习惯问题,而是正确性保障。
4.3 install 与 deploy 的分界:本地仓库不是远程仓库
这里必须把概念掰开:install 只是把构建产物放进本机的 ~/.m2/repository,给本机其他项目引用;deploy 才是把产物上传到远程仓库,让别人也能拉取。很多小团队把 install 当成“发布”,结果 CI 机器换了、缓存清了,别的机器就拉不到包,问题往往出在 install 和 deploy 没分清。
配置远程发布需要 pom 里的 distributionManagement 节点,指向公司私服或代理仓库。日常开发中,多模块本地构建用 install 完全够用;但正式发布、跨机器分发,必须走 deploy。把这条边界画清楚,CI 流水线才不容易埋雷。
5. 多模块构建时序:reactor 如何决定谁先编译
多模块工程是生命周期逻辑最容易“翻车”的地方。很多团队用 Maven 聚合多模块项目,但发现构建顺序完全看不懂:明明 modules 里 A 写在前面,日志里却是 B 先编译。这不是 Maven 抽风,而是反应堆(reactor)在起作用。
5.1 reactor 排序规则:依赖关系优先于声明顺序
在父 pom 里声明 module 后,执行mvn install时,Maven 会分析各模块之间的依赖关系,生成一个 reactor,按拓扑顺序排序:如果模块 A 依赖模块 B,B 就必须先构建;如果两个模块没有依赖关系,才按声明顺序构建。所以日志里的模块顺序和 modules 列表不一致,是正常现象。
这种“依赖优先”的设计保证了整个 reactor 内每个模块在被引用前已经构建完成,不会出现 A 构建时找不到 B 的情况。对持续集成来说这是好事:一条命令就能按正确顺序构建整个工程。
5.2 单独构建子模块为什么容易“找不到类”
reactor 只在“父目录一次整体构建”的上下文里存在。如果你进入某个子模块单独执行mvn package,Maven 只能去本地仓库寻找兄弟模块的 jar。假设工程有 common 和 biz 两个模块,biz 依赖 common,而 common 从来没有 install 过,那么在 biz 目录里直接 package,一定会报“找不到 common 的依赖”之类的错误。
即使 common 曾经 install 过,你改了 common 代码但没重新 install,单独构建 biz 时用的还是本地仓库里的旧版本。这就是多模块项目最常见的“改了下游模块,上游不生效”问题。解决方式有三种:
- 尽量在父目录整体构建,reactor 内可以直接引用兄弟模块的构建产物;
- 确实要单独构建时,先对依赖模块执行 mvn install;
- 使用
mvn -pl biz -am install,只构建 biz 以及 biz 依赖的模块,-am 表示 also make,会把这些依赖模块一起拉入 reactor。
我个人的习惯是:多模块工程改动频繁时,直接在根目录跑mvn -pl 目标模块 -am package,既快又能保证依赖最新。这个命令比无脑全量构建省时间,也比单独进子模块构建靠谱。
6. 踩过的生命周期坑:跳过测试、残留资源、绑定错阶段
生命周期概念清楚了,接下来是实战中真正会咬人的细节。以下三个坑我在不同项目里都亲眼见过,每个都能让构建行为变得“神出鬼没”。
6.1 两种跳过测试的参数,语义天差地别
很多同学跳过测试只记一个“skip”,但 Maven 至少有两个常用参数,行为完全不同。
| 参数 | 测试代码是否编译 | 测试是否执行 | 典型影响 |
|---|---|---|---|
| -DskipTests | 是 | 否 | 测试代码编译报错,构建照样失败 |
| -Dmaven.test.skip=true | 否 | 否 | 测试代码完全不参与构建,编译错误被隐藏 |
我遇到过一个真实场景:测试代码里引用了别的模块还没发布的新接口,同事执行mvn package -DskipTests,结果一直报编译错误。原因是 -DskipTests 虽然不跑测试,但测试类还是要编译的。换成-Dmaven.test.skip=true确实不再报错,但也把测试代码的编译问题全部掩盖了,非常危险。我的建议是:临时本地验证用 -DskipTests 可以理解,但要清楚它的边界;正式流水线不要轻易用跳过参数,尤其是别依赖 -Dmaven.test.skip=true 来“绕过问题”。
6.2 旧资源残留:复制类插件的覆盖逻辑
前面提到 process-resources 是“覆盖式复制”,不是“清理式同步”。这个细节会造成一个特别隐蔽的线上事故:假设 src/main/resources 里曾经有个 old-config.yml,构建时它被复制到了 target/classes;后来你从源码目录删掉了这个文件,但没执行 clean,直接 package,旧文件仍然存在并被装进 jar 和容器镜像。你本地的目标目录看着“没什么问题”,线上却带上了已经废弃的配置,两边表现不一致,排查时极其痛苦。
避免这个问题没有太多技巧,就是发布构建必须从 clean 开始,把 target 清干净再重建。在 CI 流水线里,mvn clean package几乎是硬性规范。如果你用 IDE 里的 Maven 面板,也要注意有些 IDE 借口增量构建不会自动 clean,手动点一下 clean 再跑,比疑神疑鬼半天强得多。
6.3 生成代码的插件绑错阶段,类就“蒸发”了
很多项目依赖代码生成器:接口定义生成 Java 类、数据库表生成实体、协议文件生成 DTO。这类插件一定要绑定在“被消费之前”的阶段,最常见的是 generate-sources 或 process-sources。如果你把生成逻辑绑到了 verify 甚至 package 阶段,生成的 .java 文件虽然出现了,但本轮 compile 早就跑完了,类不会参与编译,最终 jar 里自然什么都没有。看起来就是“插件执行了,代码却莫名其妙不见了”。
我在 pom 里写生成代码插件时通常会先问三个问题:插件动作是什么?产出的东西什么时候被用?必须在哪个阶段之前发生?然后倒推绑定节点。最省事的做法是统一挂到 generate-sources,因为它的位置天然在 compile 之前,向下兼容性最好。
6.4 遇到生命周期相关怪问题的排查习惯
最后分享一个我排查构建问题的固定流程。先跑mvn help:effective-pom,把 pom 继承、依赖管理、插件绑定全部摊开;再跑mvn help:describe -Dcmd=package -Ddetail,让 Maven 告诉你 package 到底会触发哪些 phase、哪些插件目标。这两个命令比翻文档快得多,尤其在接手一个配置复杂的旧项目时。
拿到 effective-pom 后,我只会盯四条线:命令对应的目标阶段、目标阶段前所有阶段的绑定情况、每个绑定插件的具体配置、插件目标是否产生预期产物。这四条线捋完,十有八九能找到问题根源。构建逻辑再花哨,本质上都是“命令 -> 阶段 -> 插件目标 -> 顺序”这条链。
最后说一个我自己的经验:现在拿到新项目,我第一件事不是看业务代码,而是先跑一遍mvn help:effective-pom,把 pom 的最终形态装进脑子里。再复杂的构建,只要“命令到阶段、阶段到目标、目标到顺序”这条链是清晰的,就不会慌。Maven 的三大生命周期说到底就是三条标好顺序的流水线,你按的是按钮,真正干活的是站在每个工位上的插件。把按钮和工人都认清楚,眼前那层雾自然就散了。