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 加一个 classifier=exec,这样项目会同时生成两个 jar:一个瘦 jar 供 jpackage 使用,一个带 exec 后缀的 fat jar 保留给 java -jar 场景。如果没有这个配置,mvn package 会把原 jar 覆盖成 fat jar,届时你拿到的 target/xxx.jar 已经不是瘦 jar 了。
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <id>repackage</id> <goals> <goal>repackage</goal> </goals> <configuration> <classifier>exec</classifier> </configuration> </execution> </executions> </plugin>瘦 jar 的 Main-Class 不一定非要写进 manifest 里,因为后面 jpackage 可以通过 --main-class 参数显式指定启动类。但为了让构建信息更完整,我也建议在 maven-jar-plugin 里顺手写上启动类:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifest> <mainClass>com.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.port=18080,Spring Boot 的配置优先级会读取它。
- 日志文件不要写到当前目录,而是写到用户目录下,例如 ${user.home}/demo-service/logs。这样安装到任何位置都不会因为权限问题导致日志写不进去。
- 如果有外部配置文件,放到 ${user.home}/demo-service/config/ 下,而不是让用户去安装目录里找。安装包里的文件在升级或卸载时会被处理,用户改过的配置很可能被覆盖,放到用户目录才能保证持久化。
如果你的应用确实需要在安装目录附近读取某些资源,那在 exe 里通过 java.class.path 或 user.dir 去定位也很容易踩坑。这里面最推荐的姿势是:应用启动后,主动创建 user.home 下自己的配置目录和日志目录,把“环境准备好”这件事交给代码,而不是寄希望于安装目录的权限和路径固定。
3. jpackage 参数拆解:从构建目录到 exe / msi
3.1 整理一个干净的 input 目录,而不是硬塞整个 target
jpackage 的 --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 -DoutputDirectory=target/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.encoding=UTF-8" ` --java-options "-Dserver.port=18080"每条命令写清楚之后,再解释几个关键参数。--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.port=18080" ` --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 改成 msi:
jpackage --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.port=18080" ` --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 小服务打成 exe
4.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 的 classifier=exec。这样执行 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 -DoutputDirectory=target/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.encoding=UTF-8" ` --java-options "-Dserver.port=18080" ` --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.encoding=UTF-8" ` --java-options "-Dserver.port=18080" ` --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:TieredStopAtLevel=1,它会让 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-location=file:${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 了”这件事,真的不是一句口号,而是省下大量沟通和返工时间的踏实路径。