资讯动态

Spring Boot项目用jpackage打包成exe/msi安装包完整指南

发布时间:2026/10/16 9:51:44 来源:尧图企业网站定制
1. 聊聊天天被追着要“一个能双击运行的东西”的那种痛你发出去的 jar 包对开发来说只是一条命令的事对收到它的人可能就是一场小型灾难。我见过太多开发同事把 Spring Boot 的 fat jar 丢给前端、测试或现场同事然后补一句“本地装个 JDK 17运行 java -jar xxx.jar 就行”结果对面电脑上没有 JDK装上之后版本不对环境变量没配好命令行窗口一闪而过端口被占用控制台输出一片乱码……所有问题最终还是回流到开发这边等于自己给自己挖坑。所以现在我在内部工具和给普通用户使用的服务上基本都改成用 jpackage 打包成 exe 或 msi 安装包。jpackage 是 JDK 14 引入、JDK 16 转正的一个原生打包工具到 JDK 17 已经很成熟了它能直接利用当前 JDK 生成一个包含最小运行环境的原生启动器。对用户来说双击 exe 安装、点开就能用不用装任何额外东西对内部分发来说msi 可以走组策略和批量安装比手动发 jar 靠谱得多。这篇文章会把 Spring Boot 项目从调整项目结构、整理依赖目录、到生成 app-image、exe、msi 的完整过程拆开讲顺便把我在实际操作中踩过的坑也一并整理出来。适合谁看适合那些“服务写好了但不知道怎么体面地交给别人跑”的 Spring Boot 开发者也适合团队内部有运维工具、管理后台、演示系统需要分发的同学。你不一定需要很懂 Windows 打包原理只要会 Maven、会用命令行按着流程走就能出安装包。2. 动手前先做两个小调整打包成功率直接翻倍2.1 让启动类可被标准 classpath 加载绕开 fat jar 的嵌套结构Spring Boot 项目默认打出来的可执行 jar 是一个“fat jar”它把项目自身的 class 放在 BOOT-INF/classes 下把依赖 jar 放在 BOOT-INF/lib 下启动时靠 org.springframework.boot.loader.JarLauncher 这个特殊类加载器去解析嵌套 jar。这是一个非常聪明且方便 java -jar 直接运行的方案但对 jpackage 来说这个结构是个大坑。jpackage 生成的启动器本质上是把 input 目录里的 jar 都放到运行时 classpath 里然后调用你指定的 main 类。它不认识 BOOT-INF 这一套嵌套目录规则。你如果直接指定业务启动类它会在第一次加载依赖时抛出 ClassNotFoundException如果指定 JarLauncher等于在原生启动器里面再套一层 Boot 的加载器日志、进程行为、参数传递都绕了一圈排查问题非常难受。所以我的做法是把 Spring Boot 项目打成一个“瘦 jar 外置依赖”的形态。用 maven-jar-plugin 生成一个正常的、不包含依赖的应用 jar再用 maven-dependency-plugin 把第三方依赖复制到分发目录。业务 class 在 jar 根目录下依赖 jar 就在旁边jpackage 启动器默认就会把整个 app 目录下的 jar 都纳入 classpath加载逻辑简单直接成功率接近百分之百。实现方式是在 pom.xml 里给 spring-boot-maven-plugin 的 repackage 加一个 classifierexec这样项目会同时生成两个 jar一个瘦 jar 供 jpackage 使用一个带 exec 后缀的 fat jar 保留给 java -jar 场景。如果没有这个配置mvn package 会把原 jar 覆盖成 fat jar届时你拿到的 target/xxx.jar 已经不是瘦 jar 了。plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId executions execution idrepackage/id goals goalrepackage/goal /goals configuration classifierexec/classifier /configuration /execution /executions /plugin瘦 jar 的 Main-Class 不一定非要写进 manifest 里因为后面 jpackage 可以通过 --main-class 参数显式指定启动类。但为了让构建信息更完整我也建议在 maven-jar-plugin 里顺手写上启动类plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId configuration archive manifest mainClasscom.example.DemoApplication/mainClass /manifest /archive /configuration /plugin这里面的 com.example.DemoApplication 要替换成你自己的启动类。别小看这个动作很多人在 jpackage 阶段折腾半天找不到主类根本原因就是从一开始就把 fat jar 当作唯一产物。2.2 别把工作目录、端口和日志路径写成“假设”Spring Boot 平时在 IDE 里开发的时候工作目录就是项目根目录配置文件随手就能读到日志也默认写进当前目录。这个习惯一旦带到安装包里马上就会出问题。用户把程序安装到 C:\Program Files 这类受保护目录后程序的工作目录不一定是你以为的那个目录你也无法保证程序有权限在安装目录下写文件。所以我建议在动手打包之前先把应用的运行环境假设调整为“工作目录未知、当前目录可能不可写”。最稳妥的方案是这样的端口不要依赖默认的 8080。因为这台机器上很可能已经跑着别的服务我会在打包时通过 jpackage 的 --java-options 参数注入 -Dserver.port18080Spring Boot 的配置优先级会读取它。日志文件不要写到当前目录而是写到用户目录下例如 ${user.home}/demo-service/logs。这样安装到任何位置都不会因为权限问题导致日志写不进去。如果有外部配置文件放到 ${user.home}/demo-service/config/ 下而不是让用户去安装目录里找。安装包里的文件在升级或卸载时会被处理用户改过的配置很可能被覆盖放到用户目录才能保证持久化。如果你的应用确实需要在安装目录附近读取某些资源那在 exe 里通过 java.class.path 或 user.dir 去定位也很容易踩坑。这里面最推荐的姿势是应用启动后主动创建 user.home 下自己的配置目录和日志目录把“环境准备好”这件事交给代码而不是寄希望于安装目录的权限和路径固定。3. jpackage 参数拆解从构建目录到 exe / msi3.1 整理一个干净的 input 目录而不是硬塞整个 targetjpackage 的 --input 参数指定的是“应用内容目录”这个目录里的所有内容会原样进入安装包。很多新手第一次操作直接把 target 目录丢给 jpackage结果把一堆 test-classes、maven-status、临时文件也打进去了不仅体积膨胀还可能碰到莫名其妙的资源覆盖问题。干净的 input 目录只要三样东西主 jar、依赖 jar、以及应用运行时需要的额外资源文件。我一般习惯先建立一个 target/dist 目录然后用一条命令把依赖复制进去mvn clean package New-Item -ItemType Directory -Force -Path target/dist | Out-Null Copy-Item -Force target/demo-1.0.jar target/dist/demo-app.jar mvn dependency:copy-dependencies -DoutputDirectorytarget/dist这里 target/demo-1.0.jar 是瘦 jar把它重命名为 demo-app.jar 放进 target/dist。mvn dependency:copy-dependencies 会把所有第三方依赖 jar 也铺到 target/dist 根目录。之所以不放到子目录是因为 jpackage 生成的启动器会把 app 目录下的 jar 都放进 classpath扁平结构是最不容易出错的。如果你的资源文件比较零散可以在这一步把它们一并复制进去。要注意的是dependency:copy-dependencies 默认也会把 scope 为 test 的依赖复制进来所以打包前最好确认项目的依赖范围没问题。如果依赖数量特别多dist 目录里会有一大堆 jar这是正常的不用管jpackage 会自动在安装阶段折叠进程序目录。依赖外置之后瘦 jar 本身通常很小真正占体积的是 Spring Boot、Tomcat 这些依赖和 jpackage 自带的一套运行时 JRE。后面生成安装包时你会明显感受到体积比 fat jar 大不少这属于正常现象。3.2 生成绿色版 app-image 和标准 exe 安装包的命令区别jpackage 的好习惯是从 app-image 开始验证。所谓 app-image就是一个免安装的解压目录里面已经包含一个可执行的 exe 和配套的 runtime。这个目录可以直接拷给别人用但我们主要用它做快速验证因为构建速度比生成安装包快得多出了问题也好定位。生成 app-image 的命令长这样jpackage --type app-image --name DemoService --input target/dist --main-jar demo-app.jar --main-class com.example.DemoApplication --dest dist/app-image --java-options -Xmx512m --java-options -Dfile.encodingUTF-8 --java-options -Dserver.port18080每条命令写清楚之后再解释几个关键参数。--name 是应用名会直接影响 exe 文件名和安装后的目录名--main-jar 是相对于 --input 目录的主 jar 路径所以这里直接写 demo-app.jar--main-class 是启动类的全限定名--java-options 可以重复传多个每个都会成为最终启动命令里的 JVM 参数。如果你加一个 --win-console 参数启动程序的时候会带控制台窗口Spring Boot 的日志会直接打在控制台上开发调试非常方便。发布给普通用户时再去掉这个参数避免弹出一个看不懂的黑框。app-image 验证通过后再生成真正的 exe 安装包。这里的 exe 和上面的 app.exe 不是一回事一个是安装引导程序一个是应用本体。生成 exe 安装包的命令jpackage --type exe --name DemoService --app-version 1.0.0 --vendor 某团队 --input target/dist --main-jar demo-app.jar --main-class com.example.DemoApplication --dest dist/installer --java-options -Xmx512m --java-options -Dserver.port18080 --win-shortcut --win-menu --win-menu-group 内部工具 --win-dir-chooser--win-shortcut 会在安装时创建桌面快捷方式--win-menu 会在开始菜单创建快捷方式--win-menu-group 指定开始菜单里的分组名--win-dir-chooser 允许用户在安装时自定义安装目录。这些参数按需取舍内部分发时我会全部加上方便不熟悉命令行的同事使用。3.3 生成 msi 时绕不开的第三方组件与注意事项如果你需要生成 msi 安装包命令形式类似只是把 --type 改成 msijpackage --type msi --name DemoService --app-version 1.0.0 --vendor 某团队 --input target/dist --main-jar demo-app.jar --main-class com.example.DemoApplication --dest dist/installer --java-options -Xmx512m --java-options -Dserver.port18080 --win-shortcut --win-menu --win-menu-group 内部工具有一点必须提前说明jpackage 生成 msi 依赖第三方打包组件 WiX Toolset。首次执行时如果提示找不到相关工具就需要先安装它还得确保 jpackage 能发现它的安装路径。某次我在一台干净的构建机上跑 msi 命令报了找不到打包组件的错误排查了半天最后发现只是这个工具没装。普通 exe 安装包不依赖它但 msi 格式的生成绕不开这一步。选择 exe 还是 msi其实取决于你的分发场景。exe 安装向导对普通用户更友好双击一路下一步即可体验接近多数商业软件。msi 的好处在于支持无人值守安装、适合大规模批量分发也可以被 Windows 系统的安装和卸载面板更规范地管理。对内网批量下发、IT 管理员统一维护的场景msi 更合适如果只是发给几个同事用exe 足够。4. 完整实操把一个 Spring Boot 小服务打成 exe4.1 模拟项目结构和构建脚本为了不涉及真实项目内容我假设现在有一个内部小工具叫 demo-service功能很简单就是提供一个 HTTP 健康检查接口。项目是一个标准的 Spring Boot 应用启动类叫 DemoApplication暴露端口我们计划固定为 18080。这个规模虽然小但 jpackage 打包过程中遇到的坑和正式项目完全一样。项目结构大概是这样demo-service/ ├── pom.xml └── src/main/ ├── java/com/example/DemoApplication.java └── resources/application.ymlpom.xml 里除了 Spring Boot 依赖还配置了前面提到的 maven-jar-plugin 启动类以及 spring-boot-maven-plugin 的 classifierexec。这样执行 mvn clean package 后target 里会有一个瘦 jar 和一个 fat jar。application.yml 里不强调绝对路径我只把服务名写上端口等运行参数通过 jpackage 的 --java-options 注入日志路径配置指向 ${user.home}/demo-service/logs。这一步看起来平淡无奇但它决定了后面所有打包动作的稳定性。很多人喜欢在 application.yml 里加各种与本机路径相关的配置打包到新机器上就失效问题往往出在这里。4.2 用 jpackage 生成安装包的完整命令与参数说明我建议把构建打包全流程写成一个 PowerShell 脚本保存为 build.ps1这样每个版本发布时每次执行同一个文件不容易手抖漏参数。脚本内容大致如下$ErrorActionPreference Stop # 1. 清理并构建 mvn clean package # 2. 准备 jpackage 输入目录 New-Item -ItemType Directory -Force -Path target/dist | Out-Null Copy-Item -Force target/demo-1.0.jar target/dist/demo-app.jar mvn dependency:copy-dependencies -DoutputDirectorytarget/dist # 3. 先出 app-image 验证 jpackage --type app-image --name DemoService --input target/dist --main-jar demo-app.jar --main-class com.example.DemoApplication --dest dist/app-image --java-options -Xmx512m --java-options -Dfile.encodingUTF-8 --java-options -Dserver.port18080 --win-console Write-Host app-image generated. Press any key to continue installer build... pause # 4. 生成 exe 安装包 jpackage --type exe --name DemoService --app-version 1.0.0 --vendor 某团队 --input target/dist --main-jar demo-app.jar --main-class com.example.DemoApplication --dest dist/installer --java-options -Xmx512m --java-options -Dfile.encodingUTF-8 --java-options -Dserver.port18080 --win-shortcut --win-menu --win-menu-group 内部工具 --win-dir-chooser脚本里的 --win-console 只加在 app-image 阶段生成安装包时我刻意去掉了。这样测试时能看到完整控制台输出发布后就是干净的后台进程。如果你希望发布出来的程序也带一个控制台窗口方便维护者看日志可以在 exe 阶段也加上 --win-console但普通用户会觉得很奇怪。执行完之后dist/installer 目录下会多一个 DemoService-1.0.0.exe这就是可以发给别人的安装程序。整个流程跑完后我习惯把 target/dist 和 dist/app-image 清理掉再交付避免把中间产物一并发给用户。4.3 安装后验证双击运行、端口检查、日志与停机安装包生成之后一定要在一台“干净”的机器或虚拟机上验证一遍不要在开发机上想当然。安装完成后程序被放到用户选择的安装目录桌面和开始菜单会出现快捷方式。双击快捷方式时因为我们去掉了控制台窗口界面上看不出任何东西但服务已经在后台起来了。验证步骤很简单打开浏览器访问 http://127.0.0.1:18080/api/health如果返回正常 JSON说明服务成功启动。再去用户目录下的 demo-service/logs 里看最新日志文件确认启动过程没有异常。很多人喜欢只依赖浏览器能访问来判断程序没问题但我会建议顺手看一眼日志文件因为端口占用、配置加载失败这类问题有时接口看起来是通的数据却不对劲。这里还有一个容易被忽略的问题程序没有控制台窗口用户会疑惑“这玩意儿到底跑没跑”。所以在代码里最好加一个简单的启动成功标记比如日志里输出一行“DemoService started at port 18080”或者写一个 pid 文件到日志目录。否则用户双击后毫无反馈很容易重复打开多个进程导致端口冲突。要停止程序时不需要什么特殊命令任务管理器里找到 DemoService.exe 结束进程即可。如果要做得更专业后续可以考虑注册成 Windows 服务让它在系统启动时自动运行崩溃后也能自动拉起但这已经超出 jpackage 本身的范围属于部署层面的进一步增强。5. 实测遇到的坑和排查手册5.1 双击 exe 闪退怎么办先看日志再谈优化闪退是打包后最常见的现象新手第一反应往往是“再双击一次看看”然后继续闪退毫无头绪。正确的排查姿势是先把 --win-console 打开用 app-image 目录里的 exe 直接启动让控制台窗口保留住错误信息。Spring Boot 的启动报错、端口冲突、配置文件找不到都会打在控制台里。如果没有控制台就去用户目录下找日志文件因为代码里已经配置了 logging.file.name日志不会凭空消失。如果日志也看不到那就要检查是不是打包版本和运行环境不匹配。比如用 JDK 17 打包却在只有 JRE 8 的机器上强行运行当然会出问题。jpackage 理论上已经捆绑了运行时不会依赖外部 JDK但如果打包过程中用了 --runtime-image 指定了不完整的运行镜像应用启动到一半闪退也是常见的。5.2 报 “Unable to access jarfile” 与 main class 找不到这个问题十有八九是参数写错了。--main-jar 的值是相对于 --input 的路径不是随便写一个名字就完事。如果 input 目录是 target/dist--main-jar 写成 target/dist/demo-app.jar 反而会报错因为 jpackage 会尝试在 input 目录里找这个相对路径结果找不到。正确写法就是 demo-app.jar。main class 找不到常见原因有两个。一个是启动类全限定名写错比如把 DemoApplication 写成了 demoApplication另一个是用了 fat jar 且没有指定正确的 classpath导致业务类虽然存在但依赖根本加载不了。判断是不是 fat jar可以用解压工具看看 jar 里有没有 BOOT-INF 目录有的话就是 fat jar。这时不要硬用回到 2.1 里的瘦 jar 依赖外置方案问题立刻消失。5.3 msi 构建失败第三方打包组件缺失第一次执行 jpackage --type msi 时如果日志提示找不到打包组件第一反应不要怀疑是自己的参数写错了。先确认机器上是否已经安装第三方打包组件。安装完成后重新执行命令即可。如果依然失败检查 jpackage 能否探测到安装路径必要时手动把安装目录加入环境变量或者重新安装后重启终端。我个人的建议是不需要 msi 的场景就不要碰 msi。内部工具用 exe 安装包足够了有批量分发和静默安装需求时再引入 msi。这样既减少构建机的依赖复杂度也少一个变量。5.4 体积太大、内存占用高、启动慢三个可调方向生成出来的 exe 安装包动辄一两百 MB这是所有 JVM 系应用的宿命因为内置了一整套运行环境。体积敏感的场合可以用 jlink 先生成精简运行时镜像再通过 --runtime-image 指定给 jpackage。但这里必须提醒一句Spring Boot 用到反射、动态代理、SPI 机制的地方非常多模块化裁剪很容易裁掉需要的东西导致启动时报 NoClassDefFoundError。每个项目都要完整回归一遍不要盲目追求极致体积。内存占用方面--java-options 里设置合理的 -Xmx。内部小工具没必要默认给最大堆内存我一般设 512m足够日常使用。如果想要启动更快可以加 -XX:TieredStopAtLevel1它会让 JIT 不做深层优化换来更短的启动时间。配合 Spring Boot 本身相对常规的启动速度实测小服务能在两三秒内起来。下面这个表格可以当成排查速查手册现象可能原因处理方法双击闪退运行环境不完整 / 配置路径错误加 --win-console 测试看控制台日志界面没反应没有控制台用户不知是否启动设置日志文件和启动成功日志端口占用8080 等默认端口被其他进程占用通过 --java-options 注入固定端口写文件失败安装目录没有写权限日志和配置改到 user.home 下打包体积过大捆绑完整运行环境尝试 jlink 精简运行镜像并回归测试msi 构建失败第三方打包组件未安装安装对应打包组件后重试6. 让安装包更好用的几个细节6.1 图标、版本号、版权信息和安装目录默认生成的 exe 图标就是 Java 的咖啡杯正式对外或对内分发时有点拿不出手。jpackage 支持 --icon 参数指定一个 .ico 文件如果你只有 .png需要先转换成 .ico 再传进去。版本号用 --app-version 指定比如 1.0.0升级时版本号变高Windows 才能识别为升级而不是并行安装两个版本。--vendor 参数会显示在安装程序的发布者信息里别留空。这些细节看起来不起眼但体感差距非常大。用户安装时看到“某团队”而不是一个陌生的发布者对内部工具的信任度会高很多。配合 --win-dir-chooser 让用户自己选安装目录体验已经接近正常的商业软件了。6.2 把配置目录放在安装包之外避免更新后丢配置安装包的天然问题是升级时会覆盖安装目录里的文件。如果你把 application.yml 放在安装目录下用户改过的配置在下次更新时可能就没了。我一般会引导应用把配置放在 ${user.home}/demo-service/config 下程序第一次启动时先用代码创建目录再把默认配置文件复制过去。这样升级只覆盖程序本体用户的个性化配置一直在。实现逻辑不复杂启动类 main 方法执行前先检查用户配置目录是否存在不存在就创建并把 classpath 里的 default-config.yml 复制过去。配合 jpackage 的 --java-options 传入 -Dspring.config.additional-locationfile:${user.home}/demo-service/config/Spring Boot 就能优先读取这个外部目录。这个设计一旦做进去后续发布版本基本不会因为配置问题被用户找上门。6.3 想支持自动发布把打包动作写进 CI 流水线手工跑 build.ps1 只是第一步如果一个月要发好几次版本肯定要把它接进自己搭的 CI 流水线。构建机上配置好 JDK、Maven、第三方打包组件流水线每次触发时执行 mvn clean package然后跑 jpackage 命令把 dist/installer 下的安装包作为构建产物归档。这样发布流程变成“代码合并 - 流水线出包 - 把安装包上传到内部共享位置”人工干预基本为零。一个容易忽略的问题是构建机的 Windows 用户账户和路径稳定性。cid 流水线最好用单独的构建目录不要放在 C:\Program Files 这类受保护路径下否则可能出现权限导致的产物生成失败。整个过程跑顺之后发一个安装包的时间能压缩到几分钟这比天天手动跑命令然后到处传 jar 体面太多了。最后我想说打包这件事本身不难难的是“不要想当然”。我见过太多人把 jar 丢给对方之前完全没验证过对方的运行环境也见过很多人在 jpackage 打包失败时反复重试同一个 fat jar从没想过是项目产物形态的问题。我的经验是先做瘦 jar 依赖外置先用 app-image 验证最后再考虑 exe 和 msi每一次调整都盯着日志和实际运行结果而不是只看“安装成功”四个字。这个顺序能帮你避开绝大多数打包相关的坑。等你把这条链路跑顺了再回头看“别再发 jar 了”这件事真的不是一句口号而是省下大量沟通和返工时间的踏实路径。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑