资讯详情

资讯详情

jlink+jpackage:从模块化裁剪到跨平台安装包全流程

先说清楚一个容易混淆的点这里的jlink指的是JDK自带的模块化链接工具不是那种硬件调试器。jlink负责把Java应用依赖的模块组装成一个精简的定制化运行时镜像jpackage负责把这个镜像和应用一起打包成平台原生安装包。这两兄弟配合起来可以彻底摆脱目标机器必须预装JRE的尴尬直接交付一个双击就能安装、双击就能运行的桌面应用。这篇文章适合正在做Java桌面应用分发、内部工具交付的工程师也适合想把手里的Java程序打包给完全不懂技术的用户使用的独立开发者。我把日常项目里真正用到的参数、命令、踩坑点都整理在这里照着走一遍基本就能跑通整个打包链路。1. 整体设计与思路拆解1.1 jlink和jpackage在JDK工具链中的定位jlink是JDK 9伴随模块化系统JPMS一起出现的链接器。它的输入是一堆模块输出是一个精简的、独立的运行时目录。普通JDK安装包动辄几百MB里面有大量你根本用不到的功能jlink允许按需裁剪只保留模块描述符中用到的java.*、jdk.*模块和第三方模块。裁剪出来的runtime镜像可以整体拷贝到没有安装Java环境的机器上直接运行这在做客户端交付、现场部署时非常关键。jpackage从JDK 14开始正式可用之前是孵化阶段它的作用是打包应用。做的事情本质上就是把你的jar、依赖、jlink生成的runtime镜像、图标、启动脚本和平台专属配置组装成可安装或可解压的产物。在Windows上生成exe或msi在macOS上生成dmg或pkg在Linux上生成deb或rpm。它的定位和electron-builder这类工具类似但完全基于JDK自带工具链不需要额外插件也不需要引入一堆前端工具链。这两个工具本来就是配套设计的。jlink解决“运行时太大”的问题jpackage解决“如何把运行时和应用干净地装进用户系统”的问题。jpackage在打包时可以通过--runtime-image参数直接指定jlink生成的镜像或者通过--module-path配合模块化应用完成打包最终产物是自包含的。用户机器上没有JDK、没有JRE一样可以安装运行。1.2 为什么要把jlink和jpackage组合起来用早期Java应用的分发体验很差。要么要求目标机器提前装好对应版本的JRE然后用户双击jar包要么把整个JRE目录都塞给用户拷过去占地方启动还慢。后来有了第三方打包方案又通常需要额外学习一套工具。jlink加jpackage的组合好处很实在第一产物体积小。一个简单的Swing应用完整JDK可能两百多MB用jlink裁剪掉用不到的模块后runtime镜像可以压到四五十MB再配合压缩参数还能更小。第二用户无感。安装包自带runtime用户不需要理解Java环境这个概念。第三构建链路统一。不需要引入外国外部的包装工具Maven或Gradle插件也有但直接命令行更透明。从成本角度看这套组合没有任何额外费用JDK里已经带全了。唯一需要花时间的就是理解模块化机制而我下面会一步一步把最常用的命令和参数展开尽量降低上手成本。一个常见的疑问是我能不能不用jlink直接让jpackage自己搞定答案是可以但这样jpackage会默认把你本机完整的JDK运行时打进去生成的安装包体积会重新膨胀到几百MB违背了精简分发的初衷。所以成熟方案基本都是先用jlink裁剪runtime再让jpackage基于这个runtime生成安装包。这也是我在这篇文章里一直强调的组合套路。2. 核心细节解析与实操要点2.1 jlink常用参数与模块化基础jlink的语法其实不复杂但有几个参数决定了最终runtime的质量和能否启动。先看最常用的几个--module-path指定模块搜索路径。模块可以放在目录里也可以是模块化jar。路径分隔符Unix下是冒号Windows下是分号。--add-modules指定要包含的根模块。这是最关键的一个参数模块名写不对后面全白搭。--output输出目录jlink生成的runtime就放这里。--launcher给runtime生成启动命令格式是名称模块名/全类名。--strip-debug去掉调试符号可以明显减体积。--compress压缩runtime里的资源。--no-header-files、--no-man-pages去掉C头文件和man手册页纯应用运行用不到它们。一个典型命令长这样jlink --module-path mods:$JAVA_HOME/jmods \ --add-modules com.example.app \ --launcher appcom.example.app/com.example.app.Main \ --strip-debug --compresszip-6 --no-header-files --no-man-pages \ --output dist/runtime这里的mods目录存放编译好的模块$JAVA_HOME/jmods是JDK自带的模块。加--launcher之后runtime的bin目录下会出现一个可执行脚本或exe执行它就能启动应用。很多人第一次跑会栽在--add-modules上。如果你只写了java.base那java.desktop、java.logging这些都不会被包含Swing应用启动时直接报找不到类。最简单的方法是先用jdeps分析应用依赖再把这几个模块明确写出jdeps --print-module-deps --ignore-missing-deps myapp.jar它会输出类似java.base,java.desktop,java.logging的结果你可以直接把这些模块填到--add-modules后面。注意--ignore-missing-deps很关键否则遇到非模块化的第三方依赖会直接中断分析。jlink生成完毕的runtime目录结构大致是bin启动脚本和可执行文件、lib模块文件、native库、conf配置文件。整个目录可以拷贝走目标机器不需要装Java环境。2.2 jpackage常用参数与安装包类型选择jpackage的参数需要分两类记一类是描述应用的一类是描述安装包的。描述应用的核心参数--input应用文件存放目录你的jar和资源文件都放这里。--name应用显示名称。--app-version版本号必须是三段式比如1.0.0。--main-jar主jar包名字jar必须已经在--input目录里。--main-class主类的全类名。--module如果应用是模块化的直接用模块名/主类全类名指定入口用了这个参数就不再需要--main-jar和--main-class。--runtime-image指向jlink生成的runtime目录。--java-options启动时要传给JVM的额外参数比如-Xmx256m。描述安装包的参数--type安装包类型。调试阶段强烈建议先使用app-image它生成一个未打包的目录结构可以直接运行验证省去了安装包构建的时间。确认没问题后再改成msi、exe、dmg、pkg、deb或rpm。--dest产物输出目录。--icon图标文件Windows只认icomacOS只认icnsLinux可以用png。--win-menu、--win-shortcutWindows安装包是否生成开始菜单项和桌面快捷方式。--install-dir安装目录不指定则使用系统默认位置。组合起来就是一个完整流程。先jlink出runtimejlink --module-path mods:$JAVA_HOME/jmods \ --add-modules com.example.app \ --launcher appcom.example.app/com.example.app.Main \ --strip-debug --compresszip-6 \ --output runtime再jpackage生成免安装目录jpackage --type app-image --input input \ --main-jar myapp.jar --main-class com.example.app.Main \ --runtime-image runtime --dest dist \ --name MyApp --java-options -Xmx256m最后生成Windows安装包jpackage --type msi --input input \ --main-jar myapp.jar --main-class com.example.app.Main \ --runtime-image runtime --dest dist \ --name MyApp --app-version 1.0.0 \ --icon app.ico --win-menu --win-shortcut \ --java-options -Xmx256m关于--module-path和--runtime-image这两个参数很多人会搞混。用--module指定模块入口时要同时配置--module-path指向能解析到该模块的目录而--runtime-image是直接告诉jpackage运行时在哪二者并不冲突但别把--runtime-image指到和--module-path同一个目录。重要提示jpackage不能跨平台生成安装包。Windows上只能生成Windows安装包macOS上只能生成macOS安装包Linux同理。指望在Linux机器上直接出msi是行不通的CI/CD多平台构建时必须分别开构建机。3. 实操过程与核心环节实现3.1 一个完整示例从模块化代码到安装包我准备用一个最小的Swing应用走一遍全流程代码简单但它能把jlink和jpackage的每个环节都串起来。先创建模块化项目目录结构如下src/com.example.app/module-info.java src/com.example.app/com/example/app/Main.javamodule-info.java内容module com.example.app { requires java.desktop; }Main.java内容package com.example.app; import javax.swing.*; public class Main { public static void main(String[] args) { JFrame frame new JFrame(JLink Demo); frame.setSize(400, 300); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); JButton button new JButton(Click); button.addActionListener(e - JOptionPane.showMessageDialog(frame, Hello jpackage!)); frame.getContentPane().add(button); frame.setVisible(true); } }编译模块javac -d mods/com.example.app \ src/com.example.app/module-info.java \ src/com.example.app/com/example/app/Main.java编译后mods/com.example.app目录下应该能看到module-info.class和com/example/app/Main.class。这个目录结构就是模块路径的根jlink可以从这里解析模块。生成runtimejlink --module-path mods:$JAVA_HOME/jmods \ --add-modules com.example.app \ --launcher appcom.example.app/com.example.app.Main \ --strip-debug --compresszip-6 --no-header-files --no-man-pages \ --output runtime这里需要注意--launcher后面的app是启动脚本名com.example.app/com.example.app.Main是模块名加主类全类名。不要写成包名加类名模块名写错了会直接报模块找不到。先不要急着打包先验证runtime是否正常./runtime/bin/app如果窗口能弹出来说明jlink这一步基本没问题。如果运行时报ClassNotFoundException大概率是--add-modules里面漏了模块如果报窗口类找不到检查requires java.desktop是不是写进了module-info。验证通过后把jar打包出来。先用jar命令生成普通jarmkdir -p input jar --create --file input/myapp.jar \ --main-class com.example.app.Main \ -C mods/com.example.app .再把第三方依赖如果有的话放进input目录。这里先不做复杂依赖保持最简。接着生成app-image这是最稳妥的验证方式jpackage --type app-image --input input \ --main-jar myapp.jar \ --main-class com.example.app.Main \ --runtime-image runtime --dest dist \ --name MyApp --java-options -Xmx256m生成完在dist/MyApp下能找到可执行文件Windows平台是MyApp.exe或bin下的启动脚本。直接双击运行看看。如果这一步没问题再生成真正的安装包。Windows上生成msijpackage --type msi --input input \ --main-jar myapp.jar \ --main-class com.example.app.Main \ --runtime-image runtime --dest dist \ --name MyApp --app-version 1.0.0 \ --icon app.ico --win-menu --win-shortcut \ --java-options -Xmx256mmacOS上生成dmgjpackage --type dmg --input input \ --main-jar myapp.jar \ --main-class com.example.app.Main \ --runtime-image runtime --dest dist \ --name MyApp --app-version 1.0.0 \ --icon app.icns --java-options -Xmx256mLinux上生成debjpackage --type deb --input input \ --main-jar myapp.jar \ --main-class com.example.app.Main \ --runtime-image runtime --dest dist \ --name MyApp --app-version 1.0.0 \ --icon app.png --java-options -Xmx256m看到没有核心流程就三段编译模块、jlink裁剪runtime、jpackage打包。最花时间的不是命令本身而是排查参数和模块依赖。3.2 关键构建脚本与参数选择过程直接敲命令适合验证真正要日常用还是得把流程固化到脚本里。我常用的构建脚本思路是这样的#!/bin/bash set -e MODULE_NAMEcom.example.app MAIN_CLASScom.example.app.Main APP_NAMEMyApp VERSION1.0.0 rm -rf mods runtime input dist mkdir -p mods input dist javac -d mods/$MODULE_NAME \ src/$MODULE_NAME/module-info.java \ src/$MODULE_NAME/com/example/app/Main.java jar --create --file input/myapp.jar \ --main-class $MAIN_CLASS -C mods/$MODULE_NAME . jlink --module-path mods:$JAVA_HOME/jmods \ --add-modules $MODULE_NAME \ --launcher app$MODULE_NAME/$MAIN_CLASS \ --strip-debug --compresszip-6 \ --no-header-files --no-man-pages \ --output runtime # 先验证runtime ./runtime/bin/app sleep 3 kill %1 jpackage --type app-image \ --input input --main-jar myapp.jar \ --main-class $MAIN_CLASS \ --runtime-image runtime --dest dist \ --name $APP_NAME --app-version $VERSION \ --java-options -Xmx256m这段脚本有几个细节值得说。第一开头用set -e任何一个环节出错立即退出避免打包到一半才发现编译失败。第二临时启停一下runtime做冒烟测试这个动作在自动化构建里特别有用能过滤掉一半的低级错误。第三app-image之后你再根据平台类型追加安装包生成命令比如Windows继续跑一遍msi。参数选择上-Xmx256m这种内存约束最好写进jpackage因为你是按裁剪后的runtime来规划应用内存的让用户在启动脚本里再改往往不现实。如果应用里有自定义的日志配置、代理配置、字体配置通通可以通过多个--java-options传进去也可以写进--java-options -Dconfig...。还有一点如果你用jdeps分析过依赖在jlink命令里可以用变量管理模块列表MODULES$(jdeps --print-module-deps --ignore-missing-deps input/myapp.jar) jlink --module-path mods:$JAVA_HOME/jmods \ --add-modules $MODULES \ ...这样jlink会按依赖自动包含模块而不是手动写死。前提是你的第三方依赖都已经是模块化形态否则还是得手动调整清单。4. 常见问题与排查技巧实录4.1 典型报错和排查思路我把实际项目里遇到最多的问题整理成一张速查表每个问题都带着解决的思考路径。现象常见原因排查思路jlink报module not found模块路径不对或模块名写错确认--module-path指向的目录下真的存在对应模块目录模块名大小写要严格匹配jpackage报Invalid filename--app-version不是三段式检查版本号必须写成1.0.0这种格式生成的exe双击没反应运行时依赖缺失或JDK版本不匹配打开命令行窗口手动运行exe看控制台输出的异常信息运行时报ClassNotFoundException--add-modules缺模块或第三方jar没有放对位置用jdeps重新生成依赖清单确认jar包在--input目录中Windows打包msi时提示需要WiX未安装WiX Toolset 3.x安装WiX或者改用exe类型报Error: Unable to find main executable主类全类名或者jar路径错误用jar tf myapp.jar查看实际包路径与--main-class对照安装包生成成功但启动后报NoClassDefFoundError运行时阶段裁剪掉了运行期才加载的类检查是否有反射、SPI机制动态加载类必要时补模块或加全局不解散参数第一个问题最隐蔽。jlink的--module-path如果你写的是--module-path mods:$JAVA_HOME/jmods而当前目录没有mods目录它会直接报模块找不到完全不会提示路径有问题。这个报错信息很有误导性排查时先ls mods确认模块真存在。第二个问题也很有代表性。Windows平台生成安装包是一个相对重的过程底层需要WiX工具。新机器上第一次跑经常挂在WiX上报错信息还特别长。如果你暂时不想装WiX直接生成exe类型或者先用app-image验证应用本身没问题是更快的办法。WiX 3.11以上版本基本兼容。还有一个我踩过很多次的坑jlink生成的runtime和jpackage运行时的JDK版本不一致。比如你在JDK 21上生成的runtime但jpackage所在的构建机是JDK 17最终安装包在部分机器上会随机出现找不到类或者JVM崩溃。建议是runtime和jpackage始终出自同一个JDK发行版用java -version确认两边的版本完全一致。4.2 避坑经验与跨平台打包细节跨平台打包是这类工具使用中容易踩坑的重灾区。第一个原则刚刚提过不能跨平台生产安装包。但这不代表你必须在三台机器上手动敲命令可以用CI平台分别跑三份流水线每个平台独立执行jlink和jpackage最终把安装包上传到同一个发布目录。操作系统之间的差异要注意几个点路径分隔符。--module-path在Windows用分号在Unix用冒号。写跨平台脚本时用变量传递整个参数而不是拼接字符串。图标格式。Windows是.icomacOS是.icnsLinux是.png。你拿一个png去生成msi最后得到的图标可能是空白的甚至在打包阶段直接报错。macOS的平台打包还要求图标文件尺寸和格式规范建议直接用官方工具把多尺寸图导成icns。安装目录权限。Windows安装后默认位置是C:\Users\...\AppData\Local\Programs\应用名Linux走deb会装到/opt。如果你的应用需要写配置文件不要试图写在安装目录旁边应该写到系统用户目录。比如用System.getProperty(user.home)拼接路径或者读取环境变量。命令行启动脚本。jlink生成runtime后Windows下会有.bat或.exeUnix下是Shell脚本。在调试阶段请优先用命令行方式启动能看到完整异常堆栈——直接双击exe会把Java异常信息吞掉不利于排查。还有一类坑在模块化与非模块化依赖之间。很多第三方库至今仍然不是模块化jar也就是没有module-info.class。jlink要把它们放进runtime常见做法是找该库的模块化版本现在主流库都有自己用jar工具对其做模块化改造通常还要配合jmod工具或者干脆把这些库作为普通jar放在--input目录由类路径加载不塞进模块层。第三种做法最省事但意味着你的启动类需要通过--main-jar指定而不是--module。实际项目里很多团队采用混合模式业务代码模块化历史第三方依赖走普通类路径。这样jlink照样能裁剪只是裁剪范围不包括非模块化依赖的大小。另外如果应用用了反射、SPI或者线程上下文类加载器裁剪后的runtime很容易出现“开发环境正常、打包后运行崩”的情况。这时候建议在jlink命令里补一个保守参数让系统把运行时需要的服务实现带进去--bind-services它会把使用ServiceLoader加载的服务提供者也包含进来避免漏掉SPI实现类。代价是runtime体积会变大一些但换来的是稳定性。我可以分享一个经验值一个纯Swing应用用--strip-debug和--compresszip-6runtime体积通常在40MB到60MB之间。如果加入JavaFX之类的大模块体积会明显上涨但依然比完整JDK小很多。如果你的runtime超过150MB建议回头看看是不是加进了全部模块或者把--bind-services用到了不必要的范围。说到JavaFXjlink加JavaFX有个额外细节JavaFX的模块目前是独立发布的--module-path里需要显式加上JavaFX的lib目录比如--module-path mods:$JAVA_HOME/jmods:javafx-sdk/lib。不加的话模块完全找不到。这部分涉及的具体配置每个版本都会变建议以当前使用的SDK文档为准。最后一个实用技巧Windows安装包生成时尽量把--win-dir-chooser加上允许用户自定义安装目录。默认安装路径隐晦且容易受权限影响在一些公司环境下会惹出麻烦。--win-dir-choosermacOS上对应的则是--mac-package-name和--mac-package-identifier的设置pacakge identifier通常写成反域名风格比如com.example.mac.myapp这影响后续签名和公证。关于签名和公证我没有在正文前面展开因为很多人是内部分发不需要走这一步。但如果你想对外分发macOS的公证和Windows的代码签名会是很麻烦的后续流程这时候jpackage本身不帮你做签名你需要在打包命令里配置--mac-sign、--mac-signing-key-user-name或--win-dir-chooser对应的签名工具。这些已经是另一个话题了等真正要对外发布时再研究不迟。我个人在实际项目里最常用的组合是先跑一遍jdeps拿到最小模块集合然后jlink生成runtime再app-image本地跑一遍确认无误后才会生成msi或者dmg。这套流程维护久了会发现绝大多数问题都出在模块依赖和JDK版本不匹配上而不是jpackage本身。最后再分享一个小技巧把jlink和jpackage命令写进构建脚本或者Makefile而不是每次手动敲。脚本跑得多了参数自然就稳定了。团队里如果有人要打包新应用直接把脚本里的模块名和入口类改一改就能复用效率比重新查一遍文档高得多。Java应用的分发没有想象中那么麻烦关键是先把jlink和jpackage这条链路走熟后面不管换什么框架、什么平台思路都是一样的。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →