YAOTU INSIGHTS

SpringBoot Maven项目POM插件用法整理与构建排障速查

SpringBoot Maven项目POM插件用法整理与构建排障速查
看到“SpringBoot Maven 项目 pom 中的 plugin 插件用法整理”这个标题你可能会想pom 里那几行 XML 有什么好整理的但只有真正追过构建问题的人才会明白凡是 java -jar 启动报错、测试用例诡异归零、资源文件被随意替换、模块间依赖引入一堆垃圾 jar最后翻出来的根因基本都落在 plugin 的配置上。这篇文章就来系统过一遍 SpringBoot 项目中必须掌握的 Maven 插件包括它们各自负责的生命周期阶段、关键配置项、我在实际项目中踩过的坑以及遇到构建异常时怎么快速定位。准备接手 SpringBoot 项目但还没吃透构建流程的开发者以及遇到打包问题想查根因的同行都可以照着这份整理来对照排查。1. 为什么要单独整理 Maven 插件的用法1.1 Maven 构建本质上就是一段插件执行流水线Maven 本身只负责解析 pom、管理依赖坐标真正干活的都是插件。你执行mvn clean package背后其实是一连串插件 goal 按顺序执行maven-clean-plugin 删掉 target 目录maven-resources-plugin 把主资源文件拷贝到 classes 目录maven-compiler-plugin 编译主代码maven-surefire-plugin 跑单元测试maven-jar-plugin 打出一个普通 jar最后 spring-boot-maven-plugin 再把普通 jar 重打成可执行 fat jar。任何一个环节的参数没配对都会在某个特定阶段冒出诡异现象。所以整理插件配置本质上是在梳理你对构建流程的掌控度。很多开发者只在 pom 里见过buildplugins.../plugins/build这一层并不知道某个插件默认绑定在哪个 phase也不清楚哪几个执行目标是被隐式触发的。结果遇到问题就只能到搜索引擎里碰运气运气好抄到一段配置贴上去运气不好接着踩坑。1.2 不整理的话通常会出现哪几类问题我把这些年帮别人排查过的构建问题做了一下归纳发现高频问题非常集中打包后的 jar 执行java -jar提示“没有主清单属性”但源码和依赖看起来都没问题。测试类写了几十个用例运行结果是 0 testsCI 照样变绿质量形同虚设。配置文件里的${xxx}占位符被 Maven 资源插件替换成了莫名奇妙的字符串。多模块工程里子模块总是拿不到父 pom 里已配置好的插件版本。依赖冲突诡异启动时 ClassNotFound 或 NoSuchMethodError但 IDE 里编译一切正常。这些问题表面上各有各的现象追到根上全是插件配置或插件机制理解不到位。把它们一次性梳理清楚比遇到一次搜一次要省时间得多。1.3 我的整理思路我整理插件配置的做法分三步。第一步先看这个工程用了哪个父 pom、哪些插件版本是被继承管住的第二步逐个插件问自己三个问题它绑定在哪个生命周期阶段、它的哪个 goal 被触发、配置里哪些参数是可覆盖的第三步把容易出错的地方做成自己的速查表遇到问题先查表再翻文档。这样整理过一遍之后很多构建问题一眼就能定位到底是不是插件的问题而不是靠猜。2. SpringBoot 项目里必须吃透的核心插件2.1 spring-boot-maven-plugin唯一不可替代的主角先说这个灵魂插件。spring-boot-maven-plugin 是 Spring Boot 官方提供的 Maven 插件它在 SpringBoot 项目里最重要的任务是 repackage也就是重打包。Spring Boot 应用打包需要生成可执行的 fat jar里面要包含所有依赖 jar 和内置启动器普通 maven-jar-plugin 是打不出这种结构的。repackage 这个 goal 默认绑定在 package 阶段。执行时它会把 maven-jar-plugin 打出来的普通 jar 重新加工成 Spring Boot 可执行包加工的关键动作就是改 MANIFEST.MF。普通 jar 的Main-Class通常是你自己写的启动类或者根本没有重打包之后Main-Class会被改成 Spring Boot 的启动加载器类Spring Boot 3.x 里是org.springframework.boot.loader.launch.JarLauncher而你自己的启动类被挪到Start-Class属性里。java -jar执行时实际跑的是 JarLauncher再由它去加载嵌套在BOOT-INF/lib下的依赖和BOOT-INF/classes下的工程代码。写这个插件的时候我建议至少要掌握这三个配置点plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId executions execution goals goalrepackage/goal /goals /execution /executions configuration mainClasscom.example.sample.Application/mainClass /configuration /plugin第一个是mainClass。如果你只有一个带SpringBootApplication注解的类且它就在主源码里这个参数可以不写插件会自动扫描唯一启动类。但如果工程里有多个启动类或者启动类所在模块结构特殊插件会报错找不到 main class或者打出来的包启动后加载的是错误入口。这种情况老老实实把mainClass显式写上。第二个是classifier。这是我在多模块工程里强烈建议使用的参数。给它设一个值比如exec打包后会生成两个 jar一个是没有 Spring Boot 启动器结构的普通 jar供其他模块引用和依赖一个是带 classifier 后缀的可执行 jar如xxx-exec.jar。如果你不做这个区分repackage 默认会把普通 jar 直接替换成 fat jar其他模块依赖这个模块时会意外拉入一堆重复依赖严重时还会出现类加载冲突。第三个是executable和embeddedLauncher。Linux 部署场景下设置executable为 true 后生成的 jar 可以直接像脚本一样执行配合软链可以做简单服务管理。不过官方文档也写了真正生产环境还是建议用 systemd 这类完整方案这个参数更多是便于本机验证和容器镜像生成脚本里使用。这个插件还有其他目标像是 build-info、run、start、stop。build-info 会把构建时间、版本号等信息生成到META-INF/build-info.properties配上 Actuator 的 info 端点可以快速确认线上跑的是哪个构建。run 目标适合本地直接跑应用但大多数时候用 IDE 或mvn spring-boot:run就够了。2.2 maven-compiler-plugin编译版本经常在这里翻车maven-compiler-plugin 默认绑定在 compile 阶段但真正坑人的不是它什么时候执行而是参数设置。很多老项目还在用这种写法plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration source1.8/source target1.8/target /configuration /plugin在 JDK 8 时代这么干没问题但从 JDK 9 开始就不推荐了。更准确的做法是用release参数它会同时约束源版本、目标版本和 API 版本。只配 source 和 target 的话你拿 JDK 17 编译时如果 source 写得低javac 会直接给你报错提示 source/target 版本值已不可用。在 JDK 17 上很多人看到过这样一段Error: Source option 5 is no longer supported. Use 8 or later.出现这个报错几乎都是因为没配置release而 Maven 默认值在某些旧版本里默认是 1.5。所以现在新建 SpringBoot 工程我建议用下面这段plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration release17/release encodingUTF-8/encoding parameterstrue/parameters /configuration /pluginparameters这个参数有点冷门但值得注意。它相当于编译时加上-parameters把方法参数名保留到字节码里。Spring Boot 里大量使用反射解析配置绑定比如ConfigurationProperties如果没有开启这个参数某些场景下会出现属性绑定不上的诡异问题。如果你看到 IDE 提示“Parameter names not available for argument ...”多半就是这里没开。顺带补充一句Spring Boot 的 spring-boot-starter-parent 里已经通过java.version属性接管了编译版本你只需要在 properties 里写java.version17/java.version大多数时候不显式配置 compiler 插件也可以。但如果你用的不是这个父 pom就得自己检查编译参数。2.3 maven-resources-plugin占位符和编码的坑maven-resources-plugin 负责把 resources 目录下的文件复制到 classes 目录。默认情况下它只是复制不做任何内容替换。一旦你开了资源过滤问题就来了。资源过滤说白了就是在复制时把文件里的${xxx}占位符替换成 Maven 属性值。比如你在application.yml里写url: ${db.url}如果 resources 插件过滤开启而 pom 里恰好没有db.url这个属性这个占位符就会被替换成一个空字符串或者保留原样反正不是你想要的效果。更常见的坑是Spring 配置里原本就有${random.uuid}、${foo.bar}这类 Spring 占位符结果被 Maven 过滤一搅和应用启动时发现配置值压根不在预期位置。Spring Boot 爸爸 pom 里实际已经做了处理它把资源过滤的默认分隔符换成了..也就是说你在 src/main/resources 下的文件里只有写成db.url才会被替换${...}会安全保留给 Spring 容器解析。这是官方设计过的兼容方案但是很多人在自己写的 pom 里又手动开了默认过滤导致行为变得不可控。我的建议是除非你有明确的多环境构建需求否则不要随便开资源过滤。真需要处理注意两点一是用..风格二是把二进制文件排除在过滤之外。证书、密钥库这些文件一旦被过滤文件内容就损坏了典型的坑是.p12、.jks、.cer后缀的签名文件被替换后应用启动时直接报认证失败。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-resources-plugin/artifactId configuration encodingUTF-8/encoding nonFilteredFileExtensions nonFilteredFileExtensionp12/nonFilteredFileExtension nonFilteredFileExtensionjks/nonFilteredFileExtension nonFilteredFileExtensioncer/nonFilteredFileExtension /nonFilteredFileExtensions /configuration /plugin另外再强调一下encoding参数。如果 pom 里没有显式配置Windows 环境下默认编码受系统影响资源文件里一旦有中文注释或非 ASCII 字符打包后可能出现乱码。project.build.sourceEncoding这个属性最好在 properties 里固化下来别依赖系统默认值。2.4 maven-surefire-plugin测试统计为 0 的根源maven-surefire-plugin 负责在 test 阶段执行测试。Spring Boot 项目里你写了几十个Test方法如果mvn test输出了Tests run: 0先不要怀疑测试代码有问题十有八九是 surefire 的匹配规则没对上。surefire 默认只认下面这几类命名**/Test*.java、**/*Test.java、**/*Tests.java、**/*TestCase.java。如果你给测试类起了个很随意的名字比如UserCheck.java、CheckSomething.java它默认不会跑。这是 Maven 的习惯约定不是 Spring Boot 特有的但很多刚上手的人会在这里卡住。第二个常见问题是 JUnit 5 版本匹配。JUnit 5 需要 surefire 2.22.0 以上的版本才能直接识别老版本会以 JUnit 4 的方式初始化结果就是“No tests were executed”。如果你不是在用 Spring Boot 官方父 pom自己在 pom 里管理的插件版本要注意这个对应关系。surefire 还涉及两个非常容易混淆的跳过参数-DskipTests编译测试类但不执行测试。相当于打包前快速验证test-classes 目录还能看到编译产物。-Dmaven.test.skiptrue跳过测试代码编译和测试执行速度更快。CI 里做快速构建的时候用-Dmaven.test.skiptrue能省不少时间但不要默认全局打开否则测试保护形同虚设。我自己一般会在本地开发联调时不跑测试但 CI 流水线里严格执行mvn test。如果想开并行测试surefire 侧可以配parallel和threadCount但注意 JUnit 5 还需要在src/test/resources/junit-platform.properties里开启并行开关否则配了也不生效。老实说并行测试的收益往往需要结合测试隔离情况来评估单元测试之间没共享状态时效果最好集成测试里容易互相干扰我一般不推荐一上来就开。2.5 maven-jar-plugin主清单属性从哪来很多人分不清 maven-jar-plugin 和 spring-boot-maven-plugin 的职责。前者是单纯的普通 jar 打包器后者负责把普通 jar 再加工成可执行 jar。你如果单独用 jar 插件默认生成的 MANIFEST.MF 里是没有Main-Class的除非你显式加上plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId configuration archive manifest mainClasscom.example.sample.Application/mainClass /manifest /archive /configuration /plugin但这里有个协调问题一旦你同时配置了 jar 插件的主类又使用了 spring-boot-maven-plugin 的 repackage最终生效的还是 repackage 修改后的主类。所以如果你发现 jar 里 MANIFEST.MF 的Main-Class被改成了自己不认识的 Spring Boot loader 类别惊讶那是正常的可执行包结构。报“没有主清单属性”的常见场景是 spring-boot-maven-plugin 没有成功执行 repackage。可能是你只引入了插件但没有配置 executions也可能是某个 profile 下 repackage 被跳过。查这个问题时先打开 jar 看META-INF/MANIFEST.MF内容再判断是哪个插件环节断了。还有个容易忽略的点如果你希望打出的普通 jar 里附带源码、方便别的模块在 IDE 里点进去看实现可以配合 maven-source-plugin。这个放到后面的发布辅助插件里一起说。3. 辅助性插件如何配置更稳妥3.1 maven-enforcer-plugin统一构建环境maven-enforcer-plugin 是我在团队项目里必加的插件。它的作用是在构建开始前校验环境规则不满足直接 fail。最典型的场景是统一 JDK 版本和 Maven 版本。举个例子我有一次在某个合作项目里本机是 JDK 17另一个同事是 JDK 11结果他拉下来代码用 IDE 跑没问题一到命令行构建就报编译错误。后来在父 pom 里加了 enforcer 规则构建版本不一致的在最开始就快速失败问题早暴露省得后面排查。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-enforcer-plugin/artifactId executions execution idenforce-versions/id goals goalenforce/goal /goals configuration rules requireJavaVersion version[17,)/version /requireJavaVersion requireMavenVersion version[3.8,)/version /requireMavenVersion /rules /configuration /execution /executions /pluginenforcer 还可以配置 bannedDependencies禁止某些有安全风险或版本混乱的依赖被引入。这个在高频改动的大型项目里非常有用比靠人工 review 防冲突靠谱得多。但别把规则定太死否则会因为误杀正常依赖而遭到团队一致抗议最好只禁真正有问题的坐标。3.2 maven-dependency-plugin依赖治理三板斧这个插件不绑定默认生命周期它更多是命令行排查工具。我最常用的三个目标是mvn dependency:tree输出依赖树看某个依赖是怎么被传递引入的版本仲裁结果是什么。mvndependency:analyze分析声明依赖和实际使用的依赖找出未声明的使用和已声明但未使用的依赖。mvn dependency:copy-dependencies把所有依赖复制到指定目录适合做离线交付包。用 tree 查依赖冲突是基本功。比如你怀疑某个 jar 被多个版本引入加上-Dverbose可以看到详细的仲裁路径。analyze 的目标需要注意Spring Boot 项目里大量反射和自动装配会让它产生误报比如某个类在代码里没直接 import但运行时通过反射加载analyze 可能认为它未使用。所以 analyze 结果只能作为参考最终要靠上下文判断。copy-dependencies 在有些场景下很好使比如需要把应用依赖完整导出到另一台内网机器上部署或者排查 BOOT-INF/lib 目录里某个依赖缺失的原因。它的输出目录默认是target/dependency你也可以用outputDirectory参数改成别的路径。3.3 发布仓库所需的插件组合如果工程要发布到私有仓库常见组合是 deploy 插件加 source 插件加 javadoc 插件。maven-deploy-plugin 本身在 deploy 阶段执行你通常不用专门写它但要注意一点默认 deploy 的是 finalName 对应的主产物如果 spring-boot repackage 把 jar 替换了部署到仓库的可能是 fat jar。这不是绝对的错但在多模块间互相依赖时其他模块依赖一个 fat jar 会连带引入大量无关依赖。解决方式有两种一是给 spring-boot-maven-plugin 配 classifier让普通 jar 与可执行 jar 共存依赖方自然拿到普通 jar二是在 deploy 相关配置里明确要部署的 artifact 类型。maven-source-plugin 的用法很简单在 executions 里绑定 package 阶段执行 jar-no-fork goalplugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-source-plugin/artifactId executions execution idattach-sources/id goals goaljar-no-fork/goal /goals /execution /executions /plugin有了 source jar其他同事在 IDE 里依赖这个模块时可以直接看到源码不用自己反编译这对团队协作的体验提升非常明显。javadoc 插件一般只在公开 API 库的发布流程里才需要内部工程可以跳过否则 javadoc 校验不过会拖慢整个构建。另外记得在 deploy 插件里合理使用 skip 参数。如果你只想把某个模块 install 到本地不想推到远端仓库可以用-Dmaven.deploy.skiptrue。在 parent 里统一控制哪些模块该发布、哪些不该发布比在子模块里各写各的配置更不容易出乱子。3.4 把 Git 提交信息写进构建产物这个不是 SpringBoot 强制要求但做版本追溯时会非常有用。git-commit-id-plugin 可以在构建时把当前分支、最近提交 hash、提交时间等信息写进META-INF/git.properties或者自定义属性文件。Spring Boot Actuator 的 info 端点在开启了 git 信息后可以自动读取并暴露这些数据。plugin groupIdpl.project13.maven/groupId artifactIdgit-commit-id-plugin/artifactId configuration generateGitPropertiesFiletrue/generateGitPropertiesFile generateGitPropertiesFilename${project.build.outputDirectory}/git.properties/generateGitPropertiesFilename /configuration /plugin我实际使用中发现最有用的是定位线上问题查出一个 bug先看当前运行的 jar 对应的提交哈希再对照代码提交记录能快速确认是不是这个版本引入的回归。尤其在多人频繁发版的项目里这比问同事“你刚才发的哪个包”要靠谱得多。不过需要注意如果构建环境没有 .git 目录这个插件的执行可能会失败要在配置里做好异常处理或跳过策略。4. 极易误用的 pluginManagement 与插件继承4.1 pluginManagement 和 plugins 不是一码事这是 pom 里最容易被混淆的概念之一。很多人在父 pom 的 pluginManagement 里配好了插件版本和默认参数然后在子模块里写plugin.../plugin却不写版本结果构建报错“plugin not found”回头质疑为什么父 pom 的配置不生效。pluginManagement 的语义是“声明管理”它不会主动给当前 pom 的构建流程添加插件。它只做两件事约束插件版本提供默认配置。真正让插件加入构建流程的是buildplugins里的显式声明。如果子模块的 plugins 里没有声明某个插件哪怕父 pom 的 pluginManagement 写了三万行它也不会执行。拿 spring-boot-maven-plugin 举例spring-boot-starter-parent 在它的 pluginManagement 里已经声明了插件版本所以你在子模块里只需要写plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin这个声明本身是在告诉 Maven这个插件我要用版本你从父 pom 的 pluginManagement 里拿。如果你连 plugins 这个声明都省了repackage 就不会执行因为插件根本没参与构建。4.2 为什么子模块总是继承不到父 pom 的插件继承不到通常有三种情况。第一种就是上面说的父 pom 只配了 pluginManagement子模块没写 plugins 声明。第二种是子模块自己写了父 pom 的 parent 坐标但被覆盖了比如某个 module 的 parent 不是统一父 pom而是另一个中间 pom导致配置链路断了。第三种是子模块的 plugins 里配置覆盖得太彻底比如父 pom 给某个插件配了 executions子模块重新声明了一个新的 executions而 Maven 对 executions 的处理是整体替换而不是按 id 合并父 pom 里那个 execution 就丢了。第三种情况最隐蔽。比如父 pom 给 jacoco 插件配了 prepare-agent 的 execution子模块为了提高覆盖率又自己写了一个 jacoco execution结果父 pom 的配置就被覆盖没了。解决方式是把公共 execution 定义在父 pom 的 pluginManagement 里然后子模块尽量只补充差异化配置不要轻易重写整个 executions 块。4.3 版本号到底写不写我的建议是统一在父 pom 或公司内部的 parent pom 里管理所有插件版本子模块里的插件声明不要写版本。这样做的好处是升级插件版本时只改一处构建行为全局一致。项目里如果有几十个微服务工程每个工程都手写了一遍插件版本号维护起来就是灾难。但要注意继承 spring-boot-starter-parent 不等于所有插件版本都由它管。它确实帮你锁定了很多常用插件的版本比如 compiler、surefire、resources、jar 等但如果你自己引入一些非 Spring Boot 家族插件比如上面提到的 git-commit-id-plugin、enforcer 插件版本还是得自己维护。这时可以利用 properties 或者一个自定义 parent pom 来集中管理。判断一个插件到底需不需要写版本最简单的方法是用mvn help:effective-pom查看最终生效的完整 pom看当前构建实际使用了哪个插件版本。5. 常见问题排查速查表5.1 高频问题实测记录整理了这些年碰到的高频构建问题按“现象-原因-解法”列成一张表直接对着排查现象可能原因解决方法java -jar报“没有主清单属性”repackage 未执行或 jar 插件覆盖了 manifest确认插件有 repackage execution检查 MANIFEST.MF 内容Source option 5 is no longer supported编译插件缺 release 参数默认值过低配置release17/release或在父 pom properties 里维护 java.version测试输出Tests run: 0测试类命名不符合 surefire 默认规则或 JUnit5 版本匹配不对改类名或升级 surefire 到 2.22.0配置文件占位符被替换resources 过滤开启${...}被当 Maven 属性处理改用...分隔符或关闭过滤证书文件损坏资源过滤把二进制文件改了在 resources 插件里配置 nonFilteredFileExtensions子模块插件不生效父 pom 用的是 pluginManagement子模块没声明在子模块 plugins 里显式声明插件可不写版本依赖冲突 NoSuchMethodError依赖树里有多个版本仲裁到旧版本用 dependency:tree 定位enforcer 配置 bannedDependencies模块被依赖时引入 fat jarspring-boot repackage 替换了普通 jar配classifierexec/classifier区分两种 jar这张表我基本是贴在团队内部文档里的。每次有人问构建问题先让他对着表自查一遍大部分都能解决省了很多重复沟通的时间。5.2 排查构建问题时的三个命令遇到任何构建异常我的习惯是先跑三个命令再说第一个是mvn help:effective-pom。它能输出当前工程经过 parent 继承、属性替换、profile 激活等处理后的最终 pom。这个命令可以看出的东西太多了某个插件最终版本是多少、某个 executions 到底有没有、某个配置项最终值是什么。第二个是mvn dependency:tree -Dverbose。构建报错里如果涉及类加载、版本冲突这棵树基本能告诉你哪个依赖是被谁带进来的、为什么仲裁到这个版本。第三个是mvn -X clean package或mvn debug。这里的 X 是 debug 级别的日志输出能看到每个插件执行的具体 goal、耗时、参数。排查插件没生效的时候这个命令比任何文档都直观。这三个命令执行完90% 以上的构建问题都能缩小到一个明确的插件环节。5.3 我的处理流程建议再总结一套我自己的处理流程给遇到构建问题就慌的同行参考。第一步先复现问题并确认是什么阶段失败的。clean、compile、test、package 哪个阶段失败日志里通常会明确显示正在执行哪个插件。第二步看构建产物的实际内容。比如打出来的 jar直接解压看META-INF/MANIFEST.MF、BOOT-INF/lib、target/classes这些关键位置很多时候答案就在产物里。第三步结合 effective-pom 确认当前生效的插件配置。这一步能看到很多“我以为配了实际没生效”的情况。第四步根据现象对照速查表定位原因改配置后重新构建。注意改配置时每次只改一个变量不要一次改好几个参数否则验证不充分。最后把这个坑和解法记录下来。哪怕是记录在一个本地笔记里下次遇到同样问题也能秒解决。构建问题最怕的不是复杂而是每次都要重新从头查一遍。我在实际项目中体会很深的一点是插件本身并不难难在对 Maven 构建机制的整体理解。只要你能在脑海里把“生命周期阶段-插件-执行目标-配置参数”这四层关系串起来绝大多数构建问题都能靠推理解决。最后再分享一个小技巧遇到不确定的插件配置时先输出 effective-pom再对着实际 jar 的内容验证这个方法胜过一大半搜索引擎答案。