先说一个场景:你费了半天劲把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的结构对比,看下面这张表就很直观:
| 对比项 | 默认jar(maven-jar-plugin) | 可执行jar(spring-boot-maven-plugin repackage后) |
|---|---|---|
| 项目代码位置 | jar根目录下按包路径存放 | BOOT-INF/classes下按包路径存放 |
| 依赖jar | 不包含 | 全部拷贝到BOOT-INF/lib |
| MANIFEST.MF中的Main-Class | 不指定或无 | 指向org.springframework.boot.loader.JarLauncher |
| MANIFEST.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.boot |
| excludes | 排除指定的某个依赖 | 空 |
| excludeDevtools | 打包时是否排除spring-boot-devtools | true |
| includeSystemScope | 是否打包system scope的依赖 | false |
| skip | 是否跳过repackage执行 | false |
| jvmArguments | 应用启动时要附加的JVM参数 | 空 |
| systemPropertyVariables | 应用启动时要传入的系统属性 | 空 |
| environmentVariables | 应用启动时要传入的环境变量 | 空 |
| arguments | 传给main方法的启动参数 | 空 |
| workingDirectory | run目标执行时的工作目录 | 当前目录 |
| addResources | run目标是否动态加入项目资源目录 | true |
2.1 mainClass:什么时候必须显式配置
很多项目的启动类只有一个,而且类名是标准的Application,此时spring-boot-maven-plugin会在构建时通过扫描target/classes目录去自动定位启动类,不需要你写任何配置。但下面这几种情况,它就会猜错甚至直接报错:
- 项目里存在多个带有
@SpringBootApplication注解的类。比如多模块工程里,公共模块也放了一个测试用的启动类,插件扫描时会检测到多个候选,于是构建中断,提示你通过mainClass参数指定。 - 启动类不在当前模块下,而是继承了别的模块的基类。插件识别启动类的逻辑是找含
public static void main方法并且标了注解的类,继承过来的不会通过注解扫描直接发现。 - 你用的repackage布局是WAR,想让容器启动时拿到的入口不同。
这时候就需要在插件配置里显式指定:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <mainClass>com.example.mall.MallApplication</mainClass> </configuration> </plugin>这里有个小经验:如果项目用到了spring-boot-starter-parent作为parent,它内部已经通过${start-class}这个属性帮你维护了mainClass。你只需要在父pom里设置<properties><start-class>com.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的可执行jar:
<configuration> <classifier>exec</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> <groupId>com.example</groupId> <artifactId>unnecessary-lib</artifactId> </exclude> </excludes> </configuration>特别注意includeSystemScope这个参数,默认是false。如果你的pom里有<scope>system</scope>的依赖,那默认情况下它不会被打进可执行jar,而本地可以跑、服务器上就报ClassNotFoundException。处理方式是确认这个依赖确实需要,然后设置<includeSystemScope>true</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> <groupId>net.java.dev.jna</groupId> <artifactId>jna</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-package:
<executions> <execution> <goals> <goal>repackage</goal> </goals> <phase>prepare-package</phase> </execution> </executions>绝大多数项目不需要这样做,但如果你在package阶段之后还有别的插件要基于可执行jar做处理,就有必要手动调整这个绑定顺序。
3.2 jvmArguments与environmentVariables:启动参数的注入时机
开发阶段你可能习惯在IDEA或命令行里手动加-Xmx之类的参数,但通过插件启动应用时,这些参数不一定生效。run目标和repackage目标里都有jvmArguments、systemPropertyVariables、environmentVariables三个参数,它们负责把JVM参数、系统属性和环境变量在应用启动时注入进去。
比较常见的用法:
<configuration> <jvmArguments> -Xmx1024m -Dfile.encoding=UTF-8 </jvmArguments> <environmentVariables> <SPRING_PROFILES_ACTIVE>prod</SPRING_PROFILES_ACTIVE> </environmentVariables> </configuration>注意区别:systemPropertyVariables最终体现为-D参数,键值直接传给JVM;environmentVariables则直接写入进程环境变量。比如设置SPRING_PROFILES_ACTIVE用环境变量方式更贴近服务器运维场景,而设置spring.profiles.active这种配置项的覆盖则用系统属性更顺手。这两个别搞混了,否则你以为传了参数,启动日志里压根没生效。
还要提醒一点:jvmArguments里的参数是拼成一个字符串传给子进程的,所以包含空格的值必须用引号转义。如果参数里本身有空格或引号,很容易踩到shell解析的坑,建议能不用就不用,优先级让给环境变量。
3.3 跳过repackage的三种姿势
有时候你确实不想生成可执行jar。比如某个模块只是工具包,不需要独立部署;或者你在跑单元测试、做静态分析时想节省打包时间。跳过repackage的方式有三种,优先级从高到低分别是:
- 命令行:
mvn package -Dspring-boot.repackage.skip=true - pom参数:
<configuration><skip>true</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> <layout>JAR</layout> <layertools> <include>true</include> </layertools> </configuration>然后执行:
java -Djarmode=layertools -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 --from=builder /build/target/app.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract COPY --from=builder /build/target/dependencies/ ./ COPY --from=builder /build/target/snapshot-dependencies/ ./ COPY --from=builder /build/target/spring-boot-loader/ ./ COPY --from=builder /build/target/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]这样改代码后重新构建,Docker只需要重新COPY application层,速度快得不是一点半点。之前给一个依赖特别多的老项目做过这个改造,镜像体积没变,但构建时间从每次接近一分钟降到了几秒以内。
4.3 自定义分层:把谁放进缓存由你决定
有些项目依赖特别多且结构固定,默认的四个层还不太够用,你还可以提供自定义的分层配置文件。默认情况下插件会按依赖的来源(snapshot还是非snapshot)分类,如果你还想让某些大型依赖独立成层,可以配置spring-boot-layered.xml,类似这样:
<layers xmlns="https://spring.io/schema/boot/layers"> <application> <into layer="application"> <include>**/target/**</include> </into> </application> <dependencies> <into layer="dependencies"> <include>**/runtime/**</include> </into> <into layer="internal-dependencies"> <include>com.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 |
排查这种问题最快的办法是解压可执行jar:
jar 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.5+,JDK 8到18都能跑;Spring Boot 3.x则要求JDK 17+。如果你的JDK版本是全新的20、21,却还在用Spring Boot 2.2的老插件,repackage阶段可能会出现各种诡异的类加载异常。
另外,Maven仓库配置也对打包成败影响很大。国内网络环境下,如果你还没配阿里云镜像仓库,建议先在settings.xml里配置好mirror,否则依赖下载不全,插件自身的jar没拉下来,构建过程会卡在下载阶段。配置方式如下:
<mirrors> <mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <url>https://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"和"插件是否绑定执行"这两个方向找到突破口。