多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Spring Boot打包参数全解析:spring-boot-maven-plugin从入门到踩坑

Spring Boot打包参数全解析:spring-boot-maven-plugin从入门到踩坑 先说一个场景你费了半天劲把Spring Boot项目写好本地跑得溜溜的mvn clean install也是BUILD SUCCESS结果上服务器一执行java -jar xxx.jar直接给你来一句no main manifest attribute, in xxx.jar或者更惨后台日志里一堆ClassNotFoundException。这种问题十有八九不是代码的问题而是spring-boot-maven-plugin的参数配置和打包机制没吃透。Spring Boot项目打包不是Maven默认行为能搞定的必须靠这个插件在package阶段把普通jar改造成可执行jar而这中间每一步都有对应的参数在起作用。这篇文章就把spring-boot-maven-plugin的参数配置从头到尾拆开讲一遍顺带把那些只在踩坑时才能发现的细节一并交代清楚。内容适合刚接触Spring Boot打包的入门者也适合那些已经能打出可执行jar、但想彻底搞明白每个config标签到底在控制什么的人。1. 为什么非用这个插件不可从mvn package与java -jar的脱节说起1.1 默认jar包为什么跑不起来很多第一次用Spring Boot的人会有一个困惑Maven自带的各种打包插件不是挺多的吗把classes目录一压缩不就完事了吗其实这里有个非常容易被忽略的差异mvn package打出来的jar本质上只是一个归档文件里面装的是项目编译后的class文件和resources资源。这个jar里既没有Main-Class这个清单属性也不会把项目依赖的其他jar一起塞进来它默认只负责把当前项目的代码打包根本不关心你这个应用运行时还需要哪些第三方的包。你可以把这种默认jar想象成一个搬家用的纸箱里面东西装得整整齐齐但箱子上没写先开哪一件、怎么组装更重要的是搬家公司没跟着来箱子到了新家里你一样都组装不起来。可Spring Boot的启动方式是java -jar这要求jar里不仅要有启动主类还要把所有的依赖都从Maven仓库复制到那个jar文件内部形成一个自包含的应用。光靠Maven自带的jar插件是完成不了这个任务的。1.2 repackage目标到底对jar做了什么spring-boot-maven-plugin能解决上面所有问题的核心是它的repackage目标。这个目标默认绑定在package阶段也就是说当你执行mvn package时Maven先由默认的maven-jar-plugin打出一个普通版本的jar随后spring-boot-maven-plugin拿到这个jar对它进行二次加工生成一个真正的可执行jar。这个二次加工并不是简单地把文件往里塞而是重新组织整个jar的内部结构。加工之后的可执行jar里你会看到三个关键部分BOOT-INF/classes存放你的项目编译后的class文件和资源文件BOOT-INF/lib存放项目所有依赖的jar一个都不少META-INF/MANIFEST.MF里面同时写了Main-Class和Start-Class两个关键属性。Main-Class指向Spring Boot自带的JarLauncher由它来负责创建自定义的类加载器并读取BOOT-INF/lib里的依赖Start-Class才是你自己写的那个带main方法的启动类。普通jar和可执行jar的结构对比看下面这张表就很直观对比项默认jarmaven-jar-plugin可执行jarspring-boot-maven-plugin repackage后项目代码位置jar根目录下按包路径存放BOOT-INF/classes下按包路径存放依赖jar不包含全部拷贝到BOOT-INF/libMANIFEST.MF中的Main-Class不指定或无指向org.springframework.boot.loader.JarLauncherMANIFEST.MF中的Start-Class无指向开发者自己的启动类能否java -jar运行不能能这里还有一个很多老手都可能忽略的细节repackage不会删除原来的普通jar而是把它改名为xxx.jar.original。所以你在target目录里看到有.original这个文件就说明repackage确实执行过了要是没有这个文件那构建过程一定有哪里出了问题。这个细节在排错时特别好用后面我会再展开。2. 参数清单逐个拆解先从mainClass和classifier说起repackage目标能用的参数其实不少先放一个总览表后面再对重点参数逐一说明参数名称作用默认值mainClass指定启动类自动探测探不到会报错classifier给可执行jar加分类名空layout定义jar内部布局根据packaging自动识别includes/excludes指定哪些依赖进入BOOT-INF/lib默认全部打入requiresUnpack标记需要在运行时解压加载的依赖空excludeGroupIds排除指定groupId的依赖org.springframework.bootexcludes排除指定的某个依赖空excludeDevtools打包时是否排除spring-boot-devtoolstrueincludeSystemScope是否打包system scope的依赖falseskip是否跳过repackage执行falsejvmArguments应用启动时要附加的JVM参数空systemPropertyVariables应用启动时要传入的系统属性空environmentVariables应用启动时要传入的环境变量空arguments传给main方法的启动参数空workingDirectoryrun目标执行时的工作目录当前目录addResourcesrun目标是否动态加入项目资源目录true2.1 mainClass什么时候必须显式配置很多项目的启动类只有一个而且类名是标准的Application此时spring-boot-maven-plugin会在构建时通过扫描target/classes目录去自动定位启动类不需要你写任何配置。但下面这几种情况它就会猜错甚至直接报错项目里存在多个带有SpringBootApplication注解的类。比如多模块工程里公共模块也放了一个测试用的启动类插件扫描时会检测到多个候选于是构建中断提示你通过mainClass参数指定。启动类不在当前模块下而是继承了别的模块的基类。插件识别启动类的逻辑是找含public static void main方法并且标了注解的类继承过来的不会通过注解扫描直接发现。你用的repackage布局是WAR想让容器启动时拿到的入口不同。这时候就需要在插件配置里显式指定plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration mainClasscom.example.mall.MallApplication/mainClass /configuration /plugin这里有个小经验如果项目用到了spring-boot-starter-parent作为parent它内部已经通过${start-class}这个属性帮你维护了mainClass。你只需要在父pom里设置propertiesstart-classcom.example.mall.MallApplication/start-class/properties插件会自动读取这个属性不需要在插件configuration里重复写这样也更方便子模块覆盖。2.2 classifier当普通jar和可执行jar需要共存时默认情况下repackage是把原本的xxx.jar替换成可执行jar原来的jar改成xxx.jar.original。如果你依赖别的模块打包出的jar而那个模块恰好也用了Spring Boot插件就会遇到一个坑你引用它时Maven拿到的其实是那个可执行jar但可执行jar的结构不能作为依赖使用。更典型的场景是你既想把可执行jar发到服务器直接运行又想把普通jar发给下游团队作为库依赖这时候就必须用classifier。设置classifier后Maven构建时原始jar保持不变插件另外生成一个名为xxx-exec.jar的可执行jarconfiguration classifierexec/classifier /configuration这样target目录下你会同时看到xxx.jar和xxx-exec.jar前者供依赖方使用后者供运维部署。对多模块相互依赖的工程来说这几乎是一个刚需配置。2.3 includes/excludes控制哪些依赖进入最终的包插件默认会把所有compile和runtimescope的依赖全部打进BOOT-INF/lib这对绝大多数项目是正确行为。但有些特殊情况比如某个依赖是通过systemPath引入的本地jar或者你压根不想让某个依赖出现在最终包里就需要配置includes或excludes。excludes写起来相对直观configuration excludes exclude groupIdcom.example/groupId artifactIdunnecessary-lib/artifactId /exclude /excludes /configuration特别注意includeSystemScope这个参数默认是false。如果你的pom里有scopesystem/scope的依赖那默认情况下它不会被打进可执行jar而本地可以跑、服务器上就报ClassNotFoundException。处理方式是确认这个依赖确实需要然后设置includeSystemScopetrue/includeSystemScope。大多数应用不应该使用system scope这里只是提醒别把这两个问题混在一起。2.4 requiresUnpack遇到原生动态库时的解法Spring Boot的可执行jar默认是把所有依赖jar以嵌套jar的形式放在BOOT-INF/lib里运行时由JarLauncher动态读取。对绝大多数纯Java依赖来说这样没问题但有些依赖包含.so、.dll这类原生动态库或者依赖的是脚本解释器、需要在运行时把jar解压到临时目录才能加载直接嵌套在可执行jar里就会加载失败。典型的例子是JNA、SWT这类库。解决办法是用requiresUnpack参数告诉插件这些依赖在运行时不能被嵌套加载必须先在启动时解压到临时目录然后再由类加载器加载configuration requiresUnpack dependency groupIdnet.java.dev.jna/groupId artifactIdjna/artifactId /dependency /requiresUnpack /configuration我之前接手过一个用JNA做硬件调用的项目本地IDE里怎么跑都正常打包后放到服务器就报Unable to load native library。排查半天才意识到是JNA在可执行jar里没被解压。加上这段配置重新打包问题立刻消失。3. 打包生命周期里最容易翻车的三处联动3.1 phase绑定逻辑为什么改版本号要重打一次repackage默认绑定package阶段这导致一个很多人踩过的坑你只是想改个版本号单独执行mvn version:set或者只跑maven-jar-plugin的某个目标以为直接执行mvn install就万事大吉结果新jar没做repackage处理拿到的还是普通jar。其实这种情况多半是因为Maven生命周期的执行顺序被你自己干扰了。Spring Boot项目我建议始终保持一条完整链路mvn clean package或mvn clean install。因为clean清掉旧产物后package阶段会重新执行repackage而你不执行clean时理论上Maven也会重新生成jar但配合一些增量编译插件偶尔会有旧jar残留导致结果异常。另外如果确实需要自定义绑定阶段可以在executions里显式声明phase比如下面的写法可以把repackage提前到prepare-packageexecutions execution goals goalrepackage/goal /goals phaseprepare-package/phase /execution /executions绝大多数项目不需要这样做但如果你在package阶段之后还有别的插件要基于可执行jar做处理就有必要手动调整这个绑定顺序。3.2 jvmArguments与environmentVariables启动参数的注入时机开发阶段你可能习惯在IDEA或命令行里手动加-Xmx之类的参数但通过插件启动应用时这些参数不一定生效。run目标和repackage目标里都有jvmArguments、systemPropertyVariables、environmentVariables三个参数它们负责把JVM参数、系统属性和环境变量在应用启动时注入进去。比较常见的用法configuration jvmArguments -Xmx1024m -Dfile.encodingUTF-8 /jvmArguments environmentVariables SPRING_PROFILES_ACTIVEprod/SPRING_PROFILES_ACTIVE /environmentVariables /configuration注意区别systemPropertyVariables最终体现为-D参数键值直接传给JVMenvironmentVariables则直接写入进程环境变量。比如设置SPRING_PROFILES_ACTIVE用环境变量方式更贴近服务器运维场景而设置spring.profiles.active这种配置项的覆盖则用系统属性更顺手。这两个别搞混了否则你以为传了参数启动日志里压根没生效。还要提醒一点jvmArguments里的参数是拼成一个字符串传给子进程的所以包含空格的值必须用引号转义。如果参数里本身有空格或引号很容易踩到shell解析的坑建议能不用就不用优先级让给环境变量。3.3 跳过repackage的三种姿势有时候你确实不想生成可执行jar。比如某个模块只是工具包不需要独立部署或者你在跑单元测试、做静态分析时想节省打包时间。跳过repackage的方式有三种优先级从高到低分别是命令行mvn package -Dspring-boot.repackage.skiptruepom参数configurationskiptrue/skip/configuration父pom里把该插件的skip属性统一设置子模块按需覆盖。这里要说一下我踩过的坑当时我把skip写到父pom的pluginManagement里子模块又继承了parent结果所有子模块全部不生成可执行jar且因为构建不报错排查了很久才意识到是父pom把它统一设成了true。组里的建议是不要在pluginManagement里设置skip的默认值要跳过直接在具体模块的plugin配置里写这样每个模块的行为一目了然。4. layertools分层参数配置的一次进阶实践4.1 为什么需要分层Docker镜像构建时间背后的热力学如果只是本地java -jar跑一跑上面的内容已经够用了。但现在是容器化部署的时代我强烈建议你把注意力放到Spring Boot 2.3版本之后加入的layertools功能上来。这个功能通过repackage目标的layertools配置控制本质上是把可执行jar里的内容按层次拆分让Docker镜像构建时可以利用缓存只有你自己的代码变化时才重新构建对应层而依赖层不需要每次重传。没有分层的传统Dockerfile通常长这样FROM openjdk:8-jre COPY target/app.jar /app.jar ENTRYPOINT [java, -jar, /app.jar]这种写法的痛处在于只要jar变了整层cache就失效哪怕你只改了一行代码几百MB的依赖全都要重新COPY一遍。分层的思路等同于搬家时分门别类打包先搬必需品依赖再搬个人的兴趣小物件应用代码这样下次搬家只需重新打包个人物件。4.2 用layertools提取分层并配合多阶段构建使用之前先在插件配置里开启分层提取configuration layoutJAR/layout layertools includetrue/include /layertools /configuration然后执行java -Djarmodelayertools -jar app.jar extract这个命令会在当前目录下生成dependencies、spring-boot-loader、snapshot-dependencies、application这几个目录分别对应依赖jar、Spring Boot加载器、快照版本依赖和应用代码。配合多阶段Dockerfile就能实现依赖层复用FROM maven:3.8.6-eclipse-temurin-8 AS builder WORKDIR /build COPY pom.xml . COPY src ./src RUN mvn clean package -DskipTests FROM openjdk:8-jre WORKDIR /app COPY --frombuilder /build/target/app.jar app.jar RUN java -Djarmodelayertools -jar app.jar extract COPY --frombuilder /build/target/dependencies/ ./ COPY --frombuilder /build/target/snapshot-dependencies/ ./ COPY --frombuilder /build/target/spring-boot-loader/ ./ COPY --frombuilder /build/target/application/ ./ ENTRYPOINT [java, org.springframework.boot.loader.JarLauncher]这样改代码后重新构建Docker只需要重新COPY application层速度快得不是一点半点。之前给一个依赖特别多的老项目做过这个改造镜像体积没变但构建时间从每次接近一分钟降到了几秒以内。4.3 自定义分层把谁放进缓存由你决定有些项目依赖特别多且结构固定默认的四个层还不太够用你还可以提供自定义的分层配置文件。默认情况下插件会按依赖的来源snapshot还是非snapshot分类如果你还想让某些大型依赖独立成层可以配置spring-boot-layered.xml类似这样layers xmlnshttps://spring.io/schema/boot/layers application into layerapplication include**/target/**/include /into /application dependencies into layerdependencies include**/runtime/**/include /into into layerinternal-dependencies includecom.example.internal:*:*/include /into /dependencies /layers自定义分层的价值在于把那些体积大、几乎不变的公司内部jar放到独立层避免它们和应用代码挤在一起反复失效。我当时是把一个几十MB的报表引擎单独隔离成一层后续只改业务代码时那层缓存基本百分百命中。5. 排查实录换台机器就起不来的几种典型问题5.1 构建日志里看不到Replacing main artifact第一次接触这个插件时我遇到过一个最迷惑的现象本地构建正常服务器上也确实执行了java -jar但启动日志显示的应用版本还是旧版本。后来发现是CI服务器上缓存了旧的可执行jar构建机的Maven配置里没有绑定Spring Boot插件的执行导致repackage压根没跑只是重新拷贝了一个旧jar上去。排查链路建议是这样以后再遇到明明重新构建了部署后还是旧代码的问题执行mvn clean package打开target目录看有没有xxx.jar.original。有说明repackage执行过没有先怀疑插件配置。查看构建日志里有没有Replacing main artifact with repackaged archive这行。没有就去查pom里的executions是否遗漏或者skip被谁写成了true。如果jar包大小只有几十KB那基本可以断定没有打进任何依赖直接检查插件是否生效。如果jar包有两三百MB但java -jar还是报no main manifest attribute用unzip -p xxx.jar META-INF/MANIFEST.MF看Main-Class和Start-Class两个属性是否齐全。5.2 打包后配置文件找不到或配置不生效Spring Boot的配置加载规则是约定大于配置application.properties应放在src/main/resources下这样repackage后会进入到BOOT-INF/classes运行时SpringApplication自然能找到。但有些人习惯把配置文件放在项目根目录或外部目录然后想着java -jar时通过--spring.config.location去指定。这确实可行但要注意路径写法在Windows和Linux下完全不同反斜杠问题坑过我不是一次两次。另一个常见情况是配置文件里用的占位符${...}在jar包里没有正常解析。这种问题的根源通常不是插件而是你在pom里开启了Maven的resource filtering导致编译时就把properties里的变量替换了。排查思路是解压jar看看BOOT-INF/classes/application.properties里面到底是原样还是被替换后的值。5.3 开发环境连着跑得好好的换环境就少了依赖这类问题最容易出现在依赖从开发到打包的某个环节被静默丢弃。我遇到过一个特别隐晦的案例项目里用到了spring-boot-admin做监控本地代码直接引用了spring-boot-admin-server的类但那个依赖的scope写错了设成了provided。provided scope的依赖不会进入可执行jar于是本地IDEA运行正常打包后服务器上直接报ClassNotFoundException。这里给新手提个醒Spring Boot开发时用的依赖并不代表打包时就会被打进去。依赖scope是一个决定性因素Maven依赖scope是否进入可执行jar典型场景compile会业务依赖runtime会JDBC驱动等运行期才用的库provided不会Servlet API、编译期注解等test不会单元测试框架system默认不会需includeSystemScope本地jar排查这种问题最快的办法是解压可执行jarjar tf app.jar | grep spring-boot-admin看关键类是否在列。不在就说明打包时被scope或excludes过滤掉了。5.4 Maven版本、JDK版本与插件版本三者的匹配关系最后一个容易被忽视的坑是版本匹配。Spring Boot的插件版本跟着Spring Boot父版本走而它依赖的Maven和JDK版本是有要求的。比如Spring Boot 2.3.x系列对Maven 3.3、JDK 8的兼容性比较好Spring Boot 2.6.x建议Maven 3.5JDK 8到18都能跑Spring Boot 3.x则要求JDK 17。如果你的JDK版本是全新的20、21却还在用Spring Boot 2.2的老插件repackage阶段可能会出现各种诡异的类加载异常。另外Maven仓库配置也对打包成败影响很大。国内网络环境下如果你还没配阿里云镜像仓库建议先在settings.xml里配置好mirror否则依赖下载不全插件自身的jar没拉下来构建过程会卡在下载阶段。配置方式如下mirrors mirror idaliyunmaven/id mirrorOfcentral/mirrorOf urlhttps://maven.aliyun.com/repository/public/url /mirror /mirrors我当时一个同事的电脑一直报插件找不到换了镜像后立刻好了所以环境层面的问题优先级其实很高不要一上来就怀疑代码。5.5 关于IDEA社区版开发和命令行构建的差异常有人在群里问IDEA社区版能不能开发Spring Boot我的回答是当然可以社区版虽然没有Spring Initializr和Spring Boot Dashboard这些便捷入口但项目的本质还是Maven工程。你只要去start.spring.io生成项目然后在IDEA里以Maven项目方式打开右侧Maven工具面板会自动加载依赖。日常普通开发没有任何问题。但如果你习惯用mvn spring-boot:run启动请注意社区版不会帮你自动识别这个命令的执行环境你得先在IDEA的配置里确认Maven路径和JDK版本是否和命令行一致。否则会出现命令行启动正常、IDEA里启动报错这种两个环境不一致的问题。这个和spring-boot-maven-plugin本身关系不大但排查启动问题时优先级反而更高。最后再分享一个我常用的验证技巧不管pom里配置了多少参数构建完成后我第一件事永远是看target目录里有没有xxx.jar.original和xxx-exec.jar如果你配了classifier然后执行一句jar tf target/xxx.jar | head -50确认BOOT-INF结构存在。这两步加起来不过几十秒但能省下大量在服务器上反复试错的时间。Spring Boot打包这趟水说深不深说浅也不浅把repackage的机制和本文提到的参数都吃透至少能覆盖你日常开发和CI部署中九成以上的场景。剩下的那些疑难杂症多半都能从依赖scope和插件是否绑定执行这两个方向找到突破口。
返回列表