ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot打包实战:从可执行Jar原理到多环境配置

Spring Boot打包实战:从可执行Jar原理到多环境配置 刚接手一个Spring Boot团队项目我第一件要确认的事不是业务代码而是这个项目在命令行环境下能不能打出一个可运行的包并且能用java -jar直接启动。这个习惯是被教训逼出来的——早先在IDEA里点运行一切正常交付时运维拿着一个16KB的jar问我“这玩意儿怎么起不来”我才意识到Spring Boot的打包配置看似简单里面全是细节。打包配置的本质是把代码、依赖、内嵌服务器和配置正确组织进一个可执行产物同时保证不同环境下都能稳定运行。这篇文章我把这些年整理下来的打包经验完整过一遍包括底层原理、pom.xml配置、多环境处理、常见报错定位路径供你直接参考和复用。1. Spring Boot打包的本质可执行JAR的底层机制1.1 为什么普通的mvn package打出来的jar不能直接跑从传统Web项目转过来的开发者最开始都会有这个疑惑以前要打war包放进Tomcat的webapps目录才能跑Boot项目为什么一个java -jar就搞定了关键在于Spring Boot的可执行JAR不是普通JAR。普通JAR用maven-jar-plugin打包结构就是把target/classes里的class文件和资源压缩在一起。如果你的代码依赖了五十个第三方jar包这五十个依赖不会进到jar里运行时必须自己把依赖路径拼出来类似这样java -cp app.jar:lib/commons-lang3.jar:lib/spring-core.jar com.example.MainWindows上还要换成冒号工程稍微大一点光classpath就能写满一整行。传统war包的做法是依赖文件躺在外部容器的lib目录里由容器类加载器统一管理。这两种方式有个共同问题依赖和代码是分离的环境一变就找不到。1.2 Spring Boot的Fat Jar为什么能“自包含”Spring Boot的打包插件做的事情是把应用打包成一个自包含的Fat JAR也叫Uber JAR。打开这个jar看结构demo-0.0.1-SNAPSHOT.jar ├─ BOOT-INF/ │ ├─ classes/ # 你自己的class和resources │ └─ lib/ # 所有第三方依赖jar ├─ META-INF/ │ ├─ MANIFEST.MF │ └─ maven/... └─ org/springframework/boot/loader/ # Spring Boot自定义的类加载器关键在于META-INF/MANIFEST.MF里的两行声明Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: com.example.demo.DemoApplication执行java -jar时JVM读取Main-Class启动JarLauncher这个JarLauncher是Spring Boot提供的它知道怎么从BOOT-INF/lib里把那么多嵌套jar依次加载进类路径再调用Start-Class指定的真正启动类。打个比方传统打包方式等于你带了个行李箱里面只装了电脑主机显示器、键盘、鼠标得到出差地再找Boot的可执行jar则是把主机、显示器、键盘、鼠标全装进一个箱子到哪儿只要插上电源JDK就能开机。这个设计的直接收益是部署复杂度显著下降。一个容易忽略的点Fat jar之所以叫“嵌套jar”是因为里面的依赖jar嵌套在BOOT-INF/lib下它不满足普通java -cp的解析规则。如果你试图这样启动java -cp demo.jar com.example.demo.DemoApplication系统会抛ClassNotFoundException。很多人打包失败后把锅甩给配置其实根子是启动方式不对。1.3 War、Jar和可执行Jar怎么选放一张对比表方便直观感受对比维度普通JarSpring Boot可执行Jar传统War包内部结构class文件资源BOOT-INF/classes BOOT-INF/libWEB-INF/classes WEB-INF/lib依赖处理不打包需外部classpath全部打进jar内放进WEB-INF/lib启动方式java -cp手动指定依赖java -jar直接启动部署到Servlet容器环境依赖JDKJDKJDK 外部容器部署效率低最高中等适用场景内部工具、脚本微服务、云原生政企/传统运维环境多数Spring Boot项目用可执行jar就够了。但有些特殊场景必须用war比如运维强制要求部署到已有的应用服务器由统一管理平台做生命周期管理。后面第6章专门讲这个改造流程。2. 打包前的准备先解决JDK版本、依赖源和构建工具三件隐患2.1 JDK版本把好关避免交付现场Class版本错乱“springboot版本太高”是很多新人会撞上的问题。Spring Boot 2.x时代最低支持JDK 82.7.x是2.x的最后一个版本Spring Boot 3.0开始强制要求JDK 17以上。如果你本地用JDK 17开发生产服务器还是JDK 8用高版本JDK编译出来的class丢过去启动时JVM会报Exception in thread main java.lang.UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 52.0解决办法两个维度同时做。一是统一运行环境让服务器的JDK版本和开发机一致这个在项目早期就要敲定文档里写明JDK版本。二是配置编译目标版本。这里我强烈建议用maven.compiler.release而不是只设置source/target因为source/target只改class文件的语法版本编译器仍然会链接本机JDK的API如果代码里不小心用了高版本JDK才有的方法编译期不会报错到低版本运行环境才炸。release限制后编译器只能使用对应JDK版本的APIproperties java.version8/java.version maven.compiler.release8/maven.compiler.release /propertiesSpring Boot的parent POM会自动读取java.version属性所以多数时候声明一个java.version就够。我最推荐的流程是先在pom里固定版本然后在打包机上用java -version和mvn -version双重确认最后用javap -verbose target/classes/xxx.class | grep major扫一眼class版本。三保险基本不会出错。2.2 Maven依赖源settings.xml和私服是打包机的隐形开关我遇到过这样一个问题本机能mvn package打包机上怎么打都报Could not resolve dependencies。折腾了半天发现打包机~/.m2/settings.xml里没有配置公司私服的mirrorMaven默认只去中央仓库拉包而项目里有依赖只存在于公司私服。规范做法是维护好settings.xmlmirrors mirror idnexus/id mirrorOf*/mirrorOf urlhttps://repo.example.com/repository/maven-public//url /mirror /mirrorsmirrorOf配成*意思是所有仓库请求都走这个镜像。如果公司内网和公网隔离或者要离线打包还可以配合-o参数让Maven以离线模式工作。团队多人同时构建时配置一致的settings.xml能省下大量排错时间。另一个小技巧mvn dependency:tree可以快速看清项目到底依赖了哪些传递依赖排查依赖冲突时几乎是第一手工具。2.3 Maven还是Gradle这门功课的选择思路讨论Spring Boot打包绕不开构建工具选型。Maven用pom.xml声明依赖依赖管理稳定IDE工具链成熟Gradle用build.gradle编写构建脚本语法更灵活增量构建快。Spring Boot对两者都是一等公民支持Maven对应spring-boot-maven-pluginGradle对应bootJar/bootWar任务。我的判断标准看团队主语言和已有基础设施。团队以Java为主、已有大量Maven工程继续用Maven团队里已经有Gradle工程或者构建脚本需要写复杂逻辑比如动态任务、多模块变体打包Gradle更合适。两者没有绝对的优劣但同一个团队不建议混用维护成本会成倍上升。本篇配置示例以Maven为主核心思路放到Gradle一样能对应上。3. pom.xml打包配置逐项拆解一份能直接抄走的配置3.1 最核心的是spring-boot-maven-plugin这个插件如果你的项目是用Spring Initializr生成的默认会带这个插件build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build项目继承了spring-boot-starter-parent插件版本不用写在pom里parent统一管理。这个插件最关键的goal是repackage执行mvn package时Maven生命周期走到package阶段先由maven-jar-plugin打出一个普通jar然后spring-boot-maven-plugin对jar做“重新打包”把原本的jar挪成xxx.jar.original重新生成带BOOT-INF和JarLauncher的可执行jar。用IDEA创建Spring Boot项目时勾选Spring Web等依赖插件默认就有了如果是老工程改造忘了加这个插件打包出的jar就是普通jar运行时报“no main manifest attribute”——这是最常见的打包失败原因。3.2 一个可以复制落地的完整打包配置下面这份基础配置覆盖了常见需求我是直接拿它当模板用的build finalName${project.artifactId}/finalName plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration mainClasscom.example.demo.DemoApplication/mainClass /configuration executions execution goals goalrepackage/goal /goals /execution /executions /plugin plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId configuration skipTestsfalse/skipTests /configuration /plugin /plugins /build几个配置点说明finalName指定最终产物的名字默认产物是artifactId-version.jar比如demo-0.0.1-SNAPSHOT.jar配成${project.artifactId}就是demo.jar。mainClass当项目里有多个类带main方法时手动指定Spring Boot的启动类避免插件自动探测失败或探测到错误的类。executions里的repackage多数情况下可以省略因为插件默认绑定到package生命周期但显式写出来review时能一眼看出意图也避免某些版本不绑定goal时行为不一致。maven-surefire-plugin负责执行测试。项目里测试代码较多时跑mvn package全部执行会影响打包速度可以在命令行加-DskipTests跳过执行但保留编译测试代码-Dmaven.test.skiptrue则连测试代码编译也跳过。两者我推荐前者因为测试代码编译不过还是应该被发现的。3.3 resources目录里的资源怎么控默认条件下Maven会把src/main/resources下的内容拷贝到classes目录最终进入产物。你在resources里放了application.yml、logback.xml就是要让它进jar的。但有一个场景容易踩坑把一些敏感文件比如生产环境的证书私钥放在resources下结果一起打进了可执行jar拿到包的人解压就能看到。我的建议是敏感文件一律不要进resources改用外部配置目录在生产服务器上挂载。如果必须由Maven管理可以用excludes排除resources resource directorysrc/main/resources/directory excludes excludekeys/**/exclude exclude*.p12/exclude /excludes /resource /resources3.4 依赖冲突有一张dependency tree心里就有底了Spring Boot项目大量使用starterstarter本身又传递依赖一堆组件。版本由parent的dependencyManagement统一管理偶尔还是会出现本地仓库里某个依赖的传递版本和预期不一致的情况。遇到ClassNotFound或者方法签名不对NoSuchMethodError时排查路径是这样mvn dependency:tree -Dverbose dep-tree.txt在输出里搜出问题的类属于哪个jar、由哪条路径引入然后在pom里用exclusions排除掉有问题的传递依赖再显式引入正确版本dependency groupIdcom.example/groupId artifactIdsome-starter/artifactId exclusions exclusion groupIdcommons-logging/groupId artifactIdcommons-logging/artifactId /exclusion /exclusions /dependency经验之谈“本地IDEA里能跑打包后启动就报错”的诡异情况八成和依赖解析范围有关。IDEA运行时通过IDEA的classpath解析依赖打包时用Maven解析规则两者在传递依赖处理上偶尔有细微差异。先打依赖树再定位不要靠猜。4. 多环境与配置外置不要为每个环境单独打一次包4.1 开发、测试、生产环境的配置一个profile搞定一个工程要部署到dev、test、prod三套环境数据库地址、Redis、日志级别肯定不一样。如果靠改代码再打包迟早会出事。正确姿势是profile机制。Spring Boot本身支持多配置文件application.yml # 公共配置 application-dev.yml # 开发环境 application-test.yml # 测试环境 application-prod.yml # 生产环境默认激活方式可以在application.yml里写spring: profiles: active: dev不过这个默认激活值会跟着打包产物走。更灵活的做法是启动时用参数覆盖java -jar demo.jar --spring.profiles.activetest在打包阶段还可以用Maven profile配合做更复杂的处理。比如在pom里定义三个profileprofiles profile iddev/id properties profile.activedev/profile.active /properties /profile profile idtest/id properties profile.activetest/profile.active /properties /profile profile idprod/id properties profile.activeprod/profile.active /properties /profile /profiles然后在application.yml里通过占位符引用spring: profiles: active: profile.active这里有个Maven过滤的细节profile.active这种格式是Spring Boot专门为Maven resource filtering设计的能避免和Spring的${...}占位符冲突。打包时执行mvn clean package -PprodMaven会把profile.active替换成prod产物里的默认激活环境就是生产。这种方式适合“配置里有少量内容依赖打包环境”的场景但敏感信息比如密码不建议写死进jar。4.2 配置外置做成不用重新打包的部署包Spring Boot外部化配置的优先级里jar包同级的config/目录优先级高于jar包内的application.yml环境变量和命令行参数又能再覆盖它。实际部署时我一般这样处理mkdir -p /opt/app/config cp application-prod.yml /opt/app/config/ java -jar /opt/app/demo.jar --spring.profiles.activeprod这样打包产物在多个环境之间是无差别的部署人员只替换外部的配置文件。如果需要额外指定配置目录还可以这样java -jar demo.jar --spring.config.additional-location/opt/app/custom-config/配置文件外部化是多环境部署最省心的一种形态也是我目前最推荐的方式。4.3 资源过滤的坑别让Maven把Spring里的${}吃掉在Spring的application.yml里写${ENV_NAME}用于读环境变量或自定义配置占位符如果你同时在pom里对resources目录开启了filteringtrueMaven会在打包阶段试图把自己的property占位符也替换掉结果原本的${MY_ENV}可能被替换成空或直接报一个找不到对应属性的警告。Spring Boot团队其实已经考虑到这个问题parent POM默认对application.yml等文件关闭了filtering但如果你手动开启了resources的filtering请小心。几种规避方案一是不要对application.yml开filtering用上面说的profile.active。二是如果确实想让Maven替换某些属性选一个系统环境变量格式来约定写成${env.MY_ENV}让Maven和Spring都解析同一个来源但维护时要格外留意两边规则是否一致。三是彻底走外部化配置不用任何占位符配置全部外置由部署时注入。5. 打包实操链路命令行、IDEA和CI三条路径怎么协同5.1 命令行打包的标准动作日常开发我基本只用一个命令mvn clean package这个命令先执行clean清理掉target残留再走compile、test、package全流程。测试太慢就跳过测试执行mvn clean package -DskipTests配合环境选择mvn clean package -DskipTests -Pprod需要指定私有settings文件时mvn clean package --settings ./ci-settings.xml打完包后到target目录下确认产物然后做启动验证java -jar demo.jar --spring.profiles.activetest看到控制台输出“Started DemoApplication in x.xxx seconds”再访问对应端口确认健康检查。不要省这一步很多部署事故就是在“打包成功”和“能运行”之间发生的。5.2 IDEA里打包和命令行打包到底有没有区别不少初学者习惯点IDEA右侧Maven面板里的Lifecycle下的package执行效果和命令行mvn package基本等价。但需要注意IDEA所用的JDK和Maven不一定是你命令行里配置的那一套。如果你在IDEA的Settings里指定了不同的Maven home或settings.xml文件产物可能有细微差别。我建议在项目级文档里把构建命令固定下来IDEA主要用于查看依赖和快速调试正式产物一律用CI命令行构建。这样能避免“我本地能打能跑服务器上就是不行”的经典纠纷。5.3 容器镜像构建Dockerfile里如何落地Spring Boot项目在云原生环境里最常见的交付物是Docker镜像。多阶段构建可以做到既保留服务器上的构建产物又生成精简的运行镜像FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn clean package -DskipTests FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuilder /build/target/demo.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]mvn dependency:go-offline先把依赖全部拉取到容器里的本地仓库之后再COPY src执行package时构建速度会快很多。值得注意的细节是运行时镜像只要JRE不需要JDK因为Spring Boot可执行jar已经完整自包含。6. 典型故障排查四条从实战里捡出来的定位路径6.1 产物只有15KB启动报no main manifest attribute症状打包成功但产物很小java -jar报no main manifest attribute, in demo.jar原因pom里没有引入spring-boot-maven-plugin或者插件没有绑定repackage goal。Maven默认用maven-jar-plugin打jar它不会生成JarLauncher相关的Main-Class。15KB的jar里只有源码编译结果没有任何依赖。解决检查pom的build/plugins节点确认有spring-boot-maven-plugin并把mainClass指向启动类。如果项目用的不是spring-boot-starter-parent需要给插件显式指定版本并引入spring-boot-dependencies的BOM做依赖管理。排查这个问题时先看jar大小再决定方向通常能省一半时间。6.2 端口被占用和context-path引发的访问问题症状jar包大小正常但启动日志里持续出现“Port 8080 was already in use”或者Web Server启动失败。排查链路先确认是否有旧进程占用端口。Linux下用lsof -i:8080或netstat -tunlp | grep 8080找到PID处理掉。如果端口确实被别的服务占用合理做法是用启动参数改端口java -jar demo.jar --server.port8081如果应用本身启动成功但接口访问不到排查看是否开启了context-pathserver: servlet: context-path: /demo这种情况下访问路径是http://ip:8080/demo/...不是根路径。很多“部署失败”其实是漏了context-path。6.3 NoClassDefFoundError与NoSuchMethodError的依赖冲突症状本地IDEA运行正常jar部署后启动报某个类找不到或方法找不到。排查链路这类问题本质是运行时类路径里的jar和你预期的不一样。用mvn dependency:tree检查冲突再解压生成的jar到BOOT-INF/lib下确认实际生效的jar版本jar tf demo.jar | grep jackson-databind如果两个jar包里同时存在同一个类属于Class冲突排除掉不需要的那条依赖路径。跨版本方法签名变化导致的NoSuchMethodError直接搜依赖树里是否存在多个版本的同一个库锁定后用exclusions收敛版本。这个问题的根子在于starter的传递依赖不可见养成打完包就检查lib目录的习惯能少踩很多坑。6.4 部署到外部应用服务器如宝兰德的war改造流程这是不少政企项目里的场景不允许使用Spring Boot内嵌Tomcat要求把应用以war包形式部署到统一的应用服务器由中间件团队统一管理运行环境。改造思路清晰按几步来第一步pom.xml把packaging改成warpackagingwar/packaging第二步启动类继承SpringBootServletInitializer并重写configure方法SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(DemoApplication.class); } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }第三步把内嵌Tomcat的依赖标记为provided打包时不要把它打进war包dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId scopeprovided/scope /dependency第四步重新打包把war部署到应用服务器的部署目录。外部容器部署后Servlet容器会从war包的WEB-INF/classes和WEB-INF/lib里加载类启动入口变成SpringBootServletInitializer的实现类。这里有个坑要留意改成war之后这个war在外部容器里能跑但如果你直接java -jar demo.war因为内嵌Tomcat是provided依赖没打进去就无法以自带容器启动。如果希望同一个war既能java -jar独立运行又能部署到外部容器可以在插件配置里使用layoutWAR/layout并保留内嵌Tomcat依赖但这样war体积更大两个场景共用一套JarLauncher逻辑实际项目中我一般二选一不搞“万能war”。我在实际项目里见过不少团队在这个问题上反复横跳。如果你确认要走外部容器路线最省事的方式是项目从一开始就按war结构创建开发调试的时候还是可以用SpringApplication.run跑main方法部署时再用war包交付两套模式互不干扰。另外外部容器部署时如果出现类加载器相关的ClassCastException优先检查外部容器lib目录下有没有和war里重复的框架jar这是容器类加载器父委托机制带来的经典冲突。
返回列表