你有没有遇到过这种场景:把别人项目里的 pom 片段拷到自己工程里,加上一个 plugin,保存、刷新、执行 mvn package,然后发现目标行为完全没有变化。或者是更隐蔽的,打包之后的 jar 换台机器就启动不了,报 No main manifest attribute,你对着清单文件看半天,看不出哪个 mainClass 写错了。
这类问题十有八九都出在同一个地方——pom.xml 里 plugin 的用法没有梳理清楚。
SpringBoot 项目的构建链并不复杂,但 Maven 把“生命周期、插件、绑定、执行顺序”这些概念藏在背后,日常开发中大多数人只会复制粘贴,于是配置经常是在碰运气。这篇就来做一个系统整理:高频率用到的 plugin 到底做了什么,它们为什么能工作,以及怎么根据 SpringBoot 项目的实际打包需求去组合配置。内容面向的是已经写过SpringBoot,但对 pom 里的插件体系还处于一知半解状态的开发者。
1. Maven 生命周期与 plugin 的绑定逻辑:配置前先搞明白谁在干活
很多教程会说“Maven有三种生命周期”,然后就搬出完整清单,看完更晕。我不打算重复那套理论,我直接告诉你一个最底层的判断:Maven执行一条命令时,并不是每条命令都是“一个插件干一件事”,而是先把整个生命周期往前推到指定阶段,再把每个阶段上绑定的插件逐个执行。
1.1 为什么你加了 plugin 却没有效果
最常见的误解是:我在 pom 里加了 spring-boot-maven-plugin,所以执行 mvn compile 时它也应该参与编译。实际上,compile 这个 phase 只推送到 default 生命周期的 compile 点,spring-boot 插件的 repackage goal 绑定在 package 点,根本没有机会执行。
也就是说:一个 plugin 是否生效,首先取决于它的 goal 被绑定到了哪个 phase,以及你执行的 mvn 命令有没有走到那个 phase。把 goal 想象成一个闹钟,phase 就是时间点。闹钟调到下午三点,但你上午就离开了房间,它响不响对你没有意义。你看到各种文档里的 <execution> 配置,本质就是在说:把插件的某个 goal 绑到某个 phase 的哪个时间点。
Maven 生命周期里,最有存在感的是这三组:
clean:用来删除上次构建产生的 target 目录,包含 pre-clean、clean、post-clean。default:从 validate 到 deploy,中间包含了 compile、test、package、install 等常用 phase。几乎所有日常构建动作都由它承载。site:生成项目站点文档,实际业务项目里用得很少,这里不展开。
1.2 Maven 默认绑定已经帮你完成了大多数工作
很多人不知道一件事:即便你的 pom 里一个 plugin 都不写,Maven 依然能编译和打包。这是因为 Maven 自身带了一份“默认绑定清单”。例如:
| 当命令走到这个 phase | 默认会触发的插件动作 |
|---|---|
| process-resources | maven-resources-plugin 复制主资源文件到 target/classes |
| compile | maven-compiler-plugin 编译 src/main/java |
| process-test-resources | maven-resources-plugin 复制测试资源 |
| test-compile | maven-compiler-plugin 编译 src/test/java |
| test | maven-surefire-plugin 执行测试 |
| package | maven-jar-plugin 生成 jar |
| install | maven-install-plugin 把产物放入本地仓库 |
| deploy | maven-deploy-plugin 把产物发布到远程仓库 |
所以在 SpringBoot 项目里,你看到很多 pom 的 plugins 段写了一大堆,实际上核心要做的只是在默认流程上“调整参数”或“插入额外动作”。理解这一点之后,你再看 pom 里的 plugin 配置就不会觉得杂乱:它只是在一个已经能工作的流水线上定制几个环节。
1.3 execution、goal、phase 三个关键词必须分清
我在评审代码时,经常看到有人把这三个词混着用,比如“我给这个插件加了 execution,它就是执行了”,其实说得不准确。plugin 里最小的动作单位是 goal;phase 是 lifecycle 上的位置;execution 是 configuration 里用来把 goal 钉到某个 phase 上的声明。
一个典型的配置长这样:
xml复制<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<id>copy-libs</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/libs</outputDirectory>
</configuration>
</execution>
</executions>
</plugin>
这里的语义是:把 dependency 插件的 copy-dependencies goal 绑到 package 阶段。执行到 package 时,Maven 会把这个模块的所有依赖 jar 复制到 target/libs 下。如果你没配 phase,也没配 execution,那么只有当你手动执行 mvn dependency:copy-dependencies 时这个插件才会干活。
为什么这个逻辑常被忽略?因为有些插件自带默认 execution。最典型的就是 spring-boot-maven-plugin,它内部声明了 repackage 这样一个 default execution,一旦你把它列进 plugins,它就会自动参与 package 阶段,不需要你额外写 execution。很多教程没讲清这一点,导致一些新手以为“必须写 execution 插件才能打包”,另一些人则把这段配置抄来抄去,最后连自己在配置什么都说不清。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 高频 plugin 逐个拆解:只贴配置容易,知道每个配置在防什么问题更难
SpringBoot 项目的 pom 里,真正高频出现的 plugin 也就四五个。我不主张把所有插件都列一遍,只挑日常构建里最常用、也最容易配错的几个。
2.1 maven-compiler-plugin:Java 版本一致性在这里落地
SpringBoot 项目最常见的报错之一是 无效的目标发行版,或者本地 IDEA 编译跑得好好的,一进 Maven 命令行就报错。问题基本都出在 compiler 插件上。
compiler 插件在 SpringBoot 的默认构建链路里已经存在。它读取的配置项主要是 source、target 和 encoding。你可以在 properties 里这样统一版本:
xml复制<properties>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
注意,这三项是有协作关系的。Spring Boot 的 parent pom 会自动读取 java.version,然后帮你把 compiler 插件的 source 和 target 设成对应版本。也就是说,很多 SpringBoot 项目里,明明没有显式写 maven-compiler-plugin,但还是能按正确 JDK 版本编译,原因就在 parent 的 pluginManagement 里被接管了。
但如果你用的是非 spring-boot-starter-parent 的父工程,或者没有继承任何 parent,那必须自己保证 source、target 设置正确。还有一个容易忽略的点:source 和 target 只影响字节码版本,不阻止你使用更高版本 JDK 的 API。如果想彻底防止代码里误用高版本 API,我建议用 <release>:
xml复制<configuration>
<release>17</release>
</configuration>
release 约等于设置 source、target,并且额外限制了 JDK API 的访问,副作用也更小。多数新项目我都建议直接用 release 而不是 source/target 分离配置。
2.2 spring-boot-maven-plugin:整条链路上最特殊的一个
这是本文的核心。spring-boot-maven-plugin 解决的是 SpringBoot 项目“怎么被打成一个能直接 java -jar 启动的包”的问题。
普通 Maven jar 插件只会把你项目的 class 和 resources 按目录结构打进 jar。它有一个致命问题:项目依赖的第三方 jar 并不会被塞进去。所以如果只用默认 jar 插件打包,会出现这种情况:
bash复制java -jar demo.jar
No main manifest attribute, in demo.jar
demo.jar 里确实有 Main-Class,但问题不是这个。少了依赖和启动器,就算 manifest 完整也起不来。spring-boot-maven-plugin 的 repackage goal 把原本 jar 插件生成的普通 jar 重新组织:业务 class 放到 BOOT-INF/classes/,第三方依赖放到 BOOT-INF/lib//,同时创建一个专门的 org.springframework.boot.loader.JarLauncher 作为真正的 main 类。这样 java -jar 启动时,由启动器自己构建类加载器并加载内部依赖。
这个插件属于那种“声明即生效”的典型:
xml复制<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
如果你继承的是 spring-boot-starter-parent,版本都不用写,打包阶段会自动执行 repackage。多数项目不需要再画蛇添足写 <executions>。
如果你没有继承 starter parent,就必须明确指定插件版本,并且手动执行 repackage 绑定。另一个正确习惯是,如果你需要显式指出启动类,可以用它的 configuration:
xml复制<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.example.demo.DemoApplication</mainClass>
</configuration>
</plugin>
这段配置在大部分项目里能省则省,因为 parent 会自动从项目的 start-class 属性里帮你找到主类。但当你用工程模板或者多模块组织时,偶尔会出现两个启动类候选,Maven 会给出含糊的报错。这里显式写明是最高效的解决办法。
2.3 maven-surefire-plugin:测试到底跑没跑,得看插件配置
先明确:测试动作的默认执行者是 maven-surefire-plugin。你执行 mvn package 时,如果代码里有 test 目录,Maven 会先跑测试再打 jar。想让测试快速跳过,有两条命令,但很多人误用:
-DskipTests:只跳过测试执行,测试代码仍会编译。-Dmaven.test.skip=true:跳过测试编译,也跳过执行。
区别很实际。如果你希望每次都先确认测试代码能编译通过,再决定是否跳过,那你应该用 skipTests。如果项目测试代码本身状态不好,只是临时想快速打包,那用 maven.test.skip=true 能避开编译层面的失败。
surefire 在 SpringBoot 2.5+ 项目中还有一个潜规则:它默认会选择 JUnit Platform 来跑测试。如果项目里只加了 JUnit 4 依赖,没引入 junit-vintage-engine,那么测试会出现“明明写了方法,却没有执行”的诡异现象。排查时,不要只盯 surefire 版本,先检查依赖树上有没有对应的测试引擎。
2.4 辅助类 plugin:依赖复制、资源过滤与质量工具
主流程之外,我常配的有几个辅助插件。
maven-dependency-plugin 最常见的两个用途:一是 mvn dependency:tree 看依赖树,二是把依赖复制到指定目录,比如离线部署时使用:
bash复制mvn dependency:copy-dependencies -DoutputDirectory=target/lib
maven-resources-plugin 偶尔需要自定义。默认情况下,SpringBoot 会把 src/main/resources 下文件照抄到 classpath。如果用 $ 或 @ 作为资源文件里的占位符,并且和 Spring 的 @Value 写法冲突,可以通过这个插件的 useDefaultDelimiters 控制分隔符。很多新人在 properties 里写了 @profile.active@ 没被替换,多是 resources 插件的过滤没有打开。
maven-enforcer-plugin 在团队协作项目里值得加,比如强制所有人使用 JDK 17、Maven 3.8+,避免“我本地能过,别人跑不了”的问题。配置是从上到下执行的规则声明,功能并不复杂,但对构建环境的一致性卡得很稳。
xml复制<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireJavaVersion>
<version>17</version>
</requireJavaVersion>
<requireMavenVersion>
<version>3.8.0</version>
</requireMavenVersion>
</rules>
</configuration>
</execution>
</executions>
</plugin>
3. 打包方式决定了 plugin 怎么组合:可执行 Jar、内部 Jar 与 Docker 镜像
SpringBoot 项目怎么配 plugin 不是一成不变的,关键看产物给谁用。我通常按三种场景选择,这里说清楚每一种的配置逻辑。
3.1 打一个能 java -jar 直接启动的包
这是最常见的单体服务形态。核心配置只有一条:在启动模块里声明 spring-boot-maven-plugin。先执行 mvn clean package,然后检查产物。判断这个 jar 是否被正确 repackage,可以用压缩工具打开 jar,查看目录结构:
bash复制unzip -l target/demo.jar | head -30
如果看到 BOOT-INF/classes 和 org/springframework/boot/loader/,说明可执行 jar 结构完整。如果只有业务 class 和 META-INF,说明没有执行 repackage,运行 java -jar 大概率会报错。
有一点容易引起困惑:执行 package 之后,target 目录下会出现两个文件,一个叫 demo.jar,一个叫 demo.jar.original。后者是 spring-boot 插件在重构之前留下的原始 jar 备份,不要部署它。如果有人把 .original 当正确产物上传,启动必然失败。
SpringBoot 2.3 之后,Spring 官方推荐使用分层 jar 配合 Docker 镜像的缓存优化。分层和可执行 jar 并不冲突,正常使用 spring-boot-maven-plugin 打出的包默认就包含分层结构,用 java -Djarmode=tools -jar 可以查看内部层。真正上线时把 jar 放进容器,用一条 COPY 即可,不需要额外复杂处理。
3.2 打一个只能被其他模块引用的内部 library jar
如果这个 SpringBoot 工程包含多个 module,其中一些只作为共享 library,比如 common 模块、rpc 客户端、DTO 模块,那么这一层不应该配置 spring-boot-maven-plugin。
为什么?因为一旦 repackage 对 library 模块生效,它会被改成 BOOT-INF 布局。真正的可执行模块去依赖这个 library 时,类路径上找不到对应 class,运行期会出现各种 ClassNotFoundException。
我见过一个比较典型的失误:某套多模块项目里,父 pom 把 spring-boot-maven-plugin 写在了常规 plugins 里,所有子模块继承后就都被 repackage 了。结果启动类模块能跑,但依赖它的另一个服务在编译期正常,运行期却找不到 service 实现。排查到最后,发现那个被依赖的 jar 是 SpringBoot fat jar,根本不是普通 jar。
正确的做法是:如果父 pom 需要管理这个插件,把它放进 <pluginManagement>,由真正需要打包的 module 自己声明。而在 library 模块的 pom 里完全不声明这个插件,让它继续使用 maven-jar-plugin 生成普通 jar。
内部 library 场景还有一种情况:某模块既要打进单体服务,同时自己又是一个独立可测试客户端,这种情况建议通过 Maven profile 来切换,比如:
xml复制<profiles>
<profile>
<id>executable</id>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</profile>
</profiles>
日常 mvn package 产出普通 jar,执行 mvn package -Pexecutable 才产出可执行 jar。
3.3 用 spring-boot:build-image 还是 Dockerfile
SpringBoot 项目在容器化部署时,有两条主流路线。
一是直接用 spring-boot 插件内置的 build-image goal,不需要 Dockerfile。它默认构建出一个包含操作系统层、JVM 层的镜像,能把应用直接打包进去。执行命令很简单:
bash复制mvn spring-boot:build-image
构建时会要求本地能访问 Docker daemon 或配置好 buildpack 远程服务。优点是不需要你维护 Dockerfile,构建环境一致性由 buildpack 保证;缺点是自定义基础镜像、安装系统工具会让你绕一些弯。
二是常规做法:先 mvn clean package 打出可执行 jar,再写一份 Dockerfile。这是当前大多数团队最可控的方式。一个最简版本:
dockerfile复制FROM eclipse-temurin:17-jre
WORKDIR /app
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Java 服务跑在容器里,不要再用 -Xmx 写死堆大小,我一般建议加一句 -XX:MaxRAMPercentage=75.0,让 JVM 根据容器内存自动调整堆空间。
这两种方式里 plugin 的角色不同:build-image 直接依赖 spring-boot 插件;第二种方式下,docker 构建通常交给 CI 平台执行,pom 里只需要保证 maven 能产出正确的可执行 jar 即可。
4. 一套能直接搬走的 pom 结构:plugin 版本、执行绑定与多模块组织
前几节讲的是单个插件,这一节给出一套我自己常用的结构,方便你在实际工程里把思路落地。
4.1 一个父工程 + 两个子模块的示例结构
假设项目有三个模块:
- 父工程 parent:统一管理依赖版本和 pluginManagement
- app 模块:SpringBoot 启动入口
- common 模块:共享代码库,不独立启动
父 pom 的核心特征是把插件配置放在 pluginManagement,而不是直接放在 plugins:
xml复制<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.2.2</version>
</plugin>
</plugins>
</pluginManagement>
</build>
app 模块的 pom 里声明使用它:
xml复制<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
common 模块什么都不加,所以打包时走普通 jar 逻辑。这个分离是最常见、也最不容易出乱子的组织方式。
这里要强调 pluginManagement 和 plugins 的区别,实在太多人在这上面踩坑:pluginManagement 不会让插件真正执行,它只是给当前 pom 和继承它的子 pom 统一提供“版本号与默认配置”。真正让插件在生命周期里跑起来的,是 <build><plugins> 里的声明。如果一个插件只在 pluginManagement 里出现,当前项目执行 mvn package 时根本不会碰它。
4.2 不要把 plugin 版本和依赖版本混在一起管理
Maven 里 <dependencyManagement> 管理的是项目依赖版本,<pluginManagement> 管理的是插件版本和默认配置,两者是两条独立线路。
Spring Boot 的 parent pom 之所以看起来“万能”,是因为它同时内置了大量 dependencyManagement 和 pluginManagement。你引入一个 spring-boot-starter-web,可以不写版本,是因为 starter 被 dependencyManagement 接管了;你引入 spring-boot-maven-plugin,也可以不写版本,是因为 pluginManagement 接管了插件版本。
一旦你自己搭建公司级父 pom,不想继承 spring-boot-starter-parent,那么这两块都要分别维护。只用一个 dependencyManagement 去管理 spring boot 依赖版本,并不能让一个 maven 插件自动获得版本。
4.3 查看 effective-pom:不再盲写配置
很多配置报错,不用猜,直接看最终生效的 pom 更准。执行:
bash复制mvn help:effective-pom
它会输出当前模块经过父子合并之后的最终 pom,包含父 pom 继承来的所有 pluginManagement 和 plugins。如果你不确定自己配置是否有冲突,先跑一下这个命令,然后搜索对应插件 id,看它的最终 version 和 execution 到底合并成了什么。这个命令比网上搜索任何“为什么没生效”都靠谱。
我自己排查插件问题时,第一反应基本都是先输出 effective-pom,看目标插件的版本、executions、configuration 是否被 parent 覆盖或清空。很多配置“没生效”不是插件不执行,而是你的子模块配置被父 pom 的空配置覆盖了,Maven 的规则是:子 pom 会覆盖父 pom 中 identification 相同的 pluginManagement/plugins 配置,而不是简单叠加。
5. plugin 相关问题的完整排查路径:从报错现象到根因
最后一部分,把我在开发、评审、上线过程中经常遇到的 plugin 问题整理出来。每一条都写清楚从现象到结论的思路,希望能帮你举一反三。
5.1 报错:No main manifest attribute
现象是最直白的:
bash复制java -jar demo.jar
no main manifest attribute, in demo.jar
典型排查路径分三步:
第一步,检查目标文件是真可执行 jar,还是普通 jar。用压缩工具打开,看有没有 BOOT-INF/。没有就说明 repackage 没执行。
第二步,找到当前模块对应的 SpringBoot 入口类是否写在了正确的 <properties> 或 parent 的 start-class 里。如果模块没有继承 spring-boot parent,或者没有手动显式配置 mainClass,插件会找不到启动类。
第三步,确认是不是父 pom 把 spring-boot-maven-plugin 放在了 pluginManagement 里,而当前模块却没有在 plugins 里声明它。这种情况在自定义 parent 的多模块工程里最常发生。修复方法就是像前文那样,在真正启动模块的 plugins 里加一段声明。
5.2 报错:构建时测试被“无形”跳过
有几天我发现 CI 日志里总是显示 Tests are skipped,但本机执行明明有测试跑。反复查看配置才发现,某次调试时把以下代码加进了 pom:
xml复制<configuration>
<skipTests>true</skipTests>
</configuration>
它来自 surefire 插件配置。这个配置会让所有本地执行 mvn package 的人都跳过测试,极其隐蔽。测试跳过类问题,第一排查手段是控制变量:先查环境变量里有没有 MAVEN_OPTS 包含 -Dmaven.test.skip,再查 pom 里的 surefire 配置和 properties 中的 maven.test.skip,最后看 CI 脚本传入的命令行参数。这个坑并不难找,但要按这个顺序找,否则很容易翻半天。
5.3 报错:同一 phase 被多个 plugin 反复处理,打出的 jar 体积异常
有一次同事打了个 jar,体积比预期大很多,还经常出现依赖覆盖产生的类冲突。反编译检查发现同一个类出现在了 BOOT-INF/lib 里,又出现在外层的业务 class 中。
这不是 SpringBoot 的问题,是 pom 里同时配了 maven-assembly-plugin 和 spring-boot-maven-plugin,两个插件都想在 package 阶段做杂糅操作。
Maven 的 phase 不是锁,多个插件的 goal 可以绑定到同一个 phase,执行顺序按照 pom 声明顺序。assembly 插件会把依赖打进另一个 fat jar,spring-boot 再对其做 repackage,结果就是重复打包、结构错乱。解决思路是:一个可执行模块,选一种 fat jar 方案就好。SpringBoot 官方方案就是 spring-boot-maven-plugin,没必要再把 assembly 拉进来。如果你确实有自定义包结构的需求,可以通过 assembly 生成普通 zip/tar 归档,但仍不要让它去覆盖 SpringBoot 的主 jar。
5.4 报错:本地构建能过,别人构建就报错
这类问题的还原路径是最磨人的。现象往往千奇百怪,常见的两个根因藏在 plugin 版本和 JDK 版本里。
如果本地使用 JDK 17,而 CI 或同事机器默认 JDK 8,没有显式配置 compiler release,就会出现“本地过,别人挂”。这不是代码 bug,是构建环境不一致。解决办法不是去争论谁的机器版本对,而是让 project 的 pom 通过 enforcer 插件和 properties 把 JDK 版本钉死。
另一个根因是公司私有仓库没有同步某个较新的插件版本。表现为报错中写着某个 <artifactId> 找不到,同时本地仓库 repository/org/apache/maven/plugins 里也看不到对应版本目录。此时不要去改一堆镜像配置,先确认你本地能用的那个仓库里是否确实存在该 groupId、artifactId、version 的三坐标组合。这类问题的本质是依赖产物同步不全,而不是配置文件语法错误。
5.5 一个关于多模块开发环境的提醒
如果项目是聚合工程,A 模块依赖 B 模块,而你在 B 模块里改了代码,直接执行 mvn -pl B -am package ——要求 A 拿到新代码,必须保证 B 已经 mvn install 进本地仓库。换句话说,Maven reactor 编译时,模块间引用还是会解析到本地仓库里的旧版本 jar,而不是直接用 B 的 target 目录。这个问题虽然不是 plugin 配置直接造成,但 plugin install/deploy 的职责边界就在这里:install 负责把产物放到本地仓库,deploy 负责发到远程仓库。如果共享模块变更后你只执行 package,别的服务引用它时就会觉得“代码没生效”。
我在实际项目里看到太多人分不清“打包成功”和“本地依赖更新成功”是两回事,遇到这类问题建议先跑 mvn install 而不是 mvn package。
写到这里,我回看这些年接触过的 SpringBoot Maven 项目,出现频率最高的构建问题大多不是代码问题,而是对 plugin 的定位理解出了偏差。只要把“生命周期负责推进、phase 只是时间点、goal 由 plugin 提供、execution 负责绑定”这个模型建立起来,再看到 pom 里的任何插件配置,你都可以自己分析它什么时候会执行、为什么执行。
如果工作中也遇到过 plugin 没生效、打包结构不对、测试被莫名跳过的情况,希望这篇整理能帮你少走一些弯路。最后留一个操作建议:在你自己的项目里执行一次 mvn help:effective-pom,把输出文件和源 pom 对比一遍,这个过程会让你把父子 pom 合并规则记牢,比我在这里罗列所有细节都更有效。
