最近在把一个老项目的核心模块从Spring Boot 2.x升级到Spring Boot 3,顺手要给JAR做加密保护时,发现之前一直稳定的xjar完全失效了——加密流程走完了,但加密后的JAR启动直接ClassNotFoundException。顺着调用栈追下去,问题出在xjar对spring-boot-loader的旧钩子在Spring Boot 3下已经不存在了。这篇文章会把魔改xjar支持Spring Boot 3的完整过程拆开讲:为什么失效、两条魔改路线怎么选、具体要动哪些代码、改完怎么验证和排错。适合两类人看:一类是正在用xjar加密Spring Boot 2.x项目、准备升级Spring Boot 3的开发者;另一类是打算自己维护一个xjar魔改分支、给团队做JAR保护工具的工程效率方向读者。
1. 先定位问题:xjar在Spring Boot 3下是"哪里断了"
1.1 xjar的工作原理决定了它的敏感依赖
xjar的加密模型不是把整个JAR压成一个密文包、启动时整体解密,它更像是"保留Spring Boot可执行JAR原本结构,只把需要保护的class加密,然后用一个自定义启动器在类加载之前把密文恢复出来"。我复盘过它的完整流程,大致是这样的:
- 你提供一个原始的可执行JAR,xjar扫描BOOT-INF/classes以及部分lib目录下的class和资源。
- 对需要保护的class文件用对称加密算法(典型是AES)加密,替换掉原始class文件。
- 把META-INF/MANIFEST.MF里的Main-Class改成xjar自己的启动器,原来的Spring Boot启动器变成"被调用的下一步"。
- 运行时,xjar启动器先读取加密配置、拿到口令,然后在内存或临时目录里把class解出来。
- 解出来的class再交给应用原有的类加载链路,最终进入SpringApplication.run。
问题就出在第5步。xjar要接管类加载时机,就必须在启动器里显式引用Spring Boot loader模块的类,而这些类的包名、结构在Spring Boot 3里变了。你直接拿旧版xjar去处理Spring Boot 3的JAR,加密本身往往不出错,但加密后的JAR一运行,JVM一进Main-Class就找不到对应的loader类了。
1.2 Spring Boot 3在启动链路上动了哪些地方
Spring Boot 3不是单纯的版本号升级。我把它对xjar的影响拆成四层,你在排查的时候可以对照着看。
第一层是基础运行环境。Spring Boot 3要求JDK 17起跳,class文件主版本号变成61。如果xjar在加密过程中要用ASM之类的字节码库去改类,ASM版本必须跟得上Java 17,否则会在处理class时报UnsupportedClassFileVersionError。
第二层是javax到jakarta的迁移。Spring Boot 3基于Jakarta EE 9+,原来大量的javax.servlet、javax.annotation等包名都变成了jakarta.*。严格来说,应用本身的这些变化不用xjar负责,因为加解密并不修改类里的包名;但如果xjar自己的启动器、解密器代码里编译期依赖了这些API,那在Spring Boot 3的JAR里,运行时就可能找不到对应的类。
第三层是spring-boot-loader,这是最致命的一层。Spring Boot的可执行JAR之所以能用java -jar直接启动,靠的是loader模块里的JarLauncher、PropertiesLauncher、ExecutableArchiveLauncher这一套。xjar的老适配逻辑基本围绕这些类做钩子。Spring Boot 3.2以后,loader模块经历了一次明显重构,类从org.springframework.boot.loader.搬到了org.springframework.boot.loader.launch.,构造方法、接口签名也有调整。就算你用的是3.0或3.1,loader对嵌套JAR的处理、对ClassLoader的实现也有多处变化,旧钩子容易mismatch。
第四层是AOT和native。Spring Boot 3引入了AOT引擎和GraalVM native image支持。如果只做传统JVM部署,这层影响不明显;但如果团队准备顺带走native编译,或者在构建时用AOT优化,反射调用链、加密类的可见性都会变成新问题。这次魔改如果只盯着启动报错,很容易忽略这层隐患。
1.3 快速判断你的应用会踩到哪一类问题
我建议在动手前先做一个十五分钟的体检,别急着改代码。你把Spring Boot 3应用打包成普通可执行JAR,然后做三件事。
第一,看一下Manifest里的Main-Class。如果写的是org.springframework.boot.loader.launch.JarLauncher,说明Spring Boot版本多半在3.2以上;如果还是org.springframework.boot.loader.JarLauncher,可能是3.0或3.1。这个差别直接决定你要适配的loader API是哪个版本。
第二,反编译你手上用的xjar版本,搜索它引用了哪些org.springframework.boot.loader下的类,列一张清单。这张清单就是后面魔改的工作量表。
第三,跑一个最简单的、什么都没改的加密试验,把启动时的完整堆栈打出来。看第一行报的是ClassNotFoundException还是NoSuchMethodError,前者多半是包名或类路径变了,后者多半是类还在但方法签名变了,两种情况的修法不一样。
我把体检常见现象整理成了对照表,方便排查时快速定位:
| 现象 | 大概率原因 | 主要修法方向 |
|---|---|---|
| 启动报ClassNotFoundException: org.springframework.boot.loader.xxx | loader包路径或类名变化 | 升级/替换loader适配层 |
| 启动报UnsupportedClassFileVersionError | 字节码库不支持Java 17 | 升级ASM等字节码库 |
| 启动报NoClassDefFoundError: javax/servlet/xxx | xjar自身引用javax API | 替换为jakarta依赖 |
| 加密后能启动但热加载异常 | ClassLoader结构被改变 | 调整解密注入点 |
| 加密后Spring Security 6/OAuth2初始化失败 | 反射、条件装配受干扰 | 检查启动器是否提前触发了类加载 |
2. 魔改路线选择:与其追着loader跑,不如把解密前移
2.1 两条路线的优劣对比
动手改之前,我在团队里拉了两个方案评审。一条路线是"兼容旧思路"——把xjar里面所有引用旧loader API的地方,逐个替换成Spring Boot 3的新API,然后重新打包一个私人版xjar。听起来最正统,但实际改起来很痛苦:loader重构后,不只包名变了,构造方式、内部类、返回类型都有变化,光是对齐签名就要反复编译。而且Spring Boot一升级小版本,你很可能又要再跟一遍。
另一条路线是"绕开loader"——不再让xjar去接管JarLauncher,而是让xjar自己的启动器在加载应用类之前完成解密,然后用一个自己构造的类加载器把解密产物和依赖JAR装进来,反射调用应用自己的main。这样spring-boot-loader的兼容性问题基本被绕开,因为启动链从"JVM → JarLauncher → 应用main"变成了"JVM → xjar解密启动器 → 应用main"。代价是,原来Spring Boot自动帮你处理的嵌套JAR读取、类路径URL构造,现在要自己在启动器里处理一部分。
我推荐第二条。原因很实际:xjar的核心价值在"解密"这件事上,而不是在跟loader耦合上。能绕开的复杂度,就不值得去追。
2.2 准备工作和参考材料
我实际动手时的准备清单是这样的:
- JDK 17,没有第二个选择,Spring Boot 3本身就不支持更低的JDK。
- 一份xjar 2.x的源码,GitHub上core-lib/xjar,拉不下来就反编译本地jar也可以。
- 对应当前项目的spring-boot-loader源码,Maven坐标让IDE自动拉取即可。
- 一个用来做实验的最小Spring Boot 3工程,别一上来就魔改公司大项目。
- IDEA自带的反编译和字节码查看功能,够用了。
另外建议把Spring Boot 3官方迁移指南里关于loader、spring.factories、自动配置的部分过一遍。很多问题不是xjar造成的,而是Spring Boot 3本身的规则变化,你不用全部背下来,但遇到奇怪问题的时候知道去哪查,会省很多时间。
2.3 我给你推荐的"接管启动"方案全景
整体方案按这个顺序落地:
- 把xjar自身的javax相关编译依赖替换成jakarta。
- 重写xjar引擎的入口,把Main-Class改成自己的解密启动器。
- 解密器保留原有AES逻辑,但解密输出改为"临时目录+内存缓存"双写,临时目录用于类加载,内存缓存用于资源读取。
- 构造类加载器时,把解密的classes目录和解压后的BOOT-INF/lib全部纳入URL。
- 反射调用应用原来的main。
- 重点验证Spring Boot 3、Spring Security 6等场景。
这套方案把第一章提到的loader兼容问题直接跳过了,接下来的问题就变成:每个步骤里有哪些细节会坑到你。
3. 动手改:依赖、启动入口、类加载、打包,一处都不能漏
3.1 替换javax依赖与清理JDK版本兼容问题
先看xjar项目自己的依赖。老版本里如果有javax.annotation、javax.servlet这类,直接改成jakarta对应坐标。Maven的话大概是这个样子:
<dependency> <groupId>jakarta.annotation</groupId> <artifactId>jakarta.annotation-api</artifactId> <version>2.1.1</version> </dependency>这里有一个容易搞错的点:很多老项目说要支持Spring Boot 3,就到处找javax换成jakarta,结果把业务代码里的javax包名全换了一遍。这是不对的。应用里的包名是否需要换,取决于应用是否真的要兼容Jakarta命名空间;而xjar要换的,只是它自己编译用到的API。xjar不认识也不需要认识你业务代码里的包名,它只是对class字节做加密和还原。你在xjar源码里搜索所有import javax.*的地方,连同pom里的依赖一起替换,但不要做"全项目替换"这种过头操作。
JDK兼容性上,如果你的xjar版本里用了老ASM,在pom里升级:
<dependency> <groupId>org.ow2.asm</groupId> <artifactId>asm</artifactId> <version>9.7</version> </dependency>ASM 9.4以上才开始完整支持Java 17的class文件版本61,9.7的稳定性更好,支持到Java 21。如果你拿不准xjar内部是否有ASM,直接看构建输出的依赖树,或者等启动报UnsupportedClassFileVersionError再回头升级也不迟。
3.2 重写Main-Class启动器,把解密放到SpringApplication之前
这一步是整个魔改的灵魂。老版xjar的思路是把Main-Class指向自己的launcher,然后在launcher中调用Spring Boot的JarLauncher。Spring Boot 3下我改成完全自己接管。主流程用伪代码描述是这样:
public class XJarSecMain { public static void main(String[] args) throws Exception { // 1. 读取xjar加密配置,获取口令 XJarConfig config = XJarConfig.fromArgs(args); // 2. 解密class文件到临时目录,解压lib到临时目录 DecryptResult result = XJarDecryptor.decrypt( XJarSecMain.class.getProtectionDomain().getCodeSource().getLocation(), config ); // 3. 构造类加载器,把解密后的classes、依赖JAR全部加进去 URLClassLoader loader = new URLClassLoader( result.getClasspathUrls(), XJarSecMain.class.getClassLoader() ); // 4. 用新加载器反射调用应用原main Class<?> appMain = Class.forName("com.example.MyApplication", true, loader); Method main = appMain.getMethod("main", String[].class); main.invoke(null, (Object) args); } }这里有几个关键点值得展开。
第一步里,口令可以从参数、环境变量、配置文件三个来源读。注意口令绝对不要出现在日志里。xjar社区默认支持把密钥做成随机序列,但如果公司内部有审计要求,最好在配置阶段就确定一个明确的口令来源规则。
第三步的ClassLoader是重灾区。很多人会问:为什么不直接把解密的class塞给AppClassLoader?原因是,AppClassLoader在JVM启动时就绑定了原始JAR的URL,你在main方法里往里塞东西不是不行,但会破坏Spring Boot对classpath资源的管理规则,也容易造成"同一个类被两个加载器加载"的诡异错误。专门new一个URLClassLoader,把解密的classes和解压的lib JAR都作为URL传进去,应用类之间彼此的引用关系、Spring Boot的依赖管理才能保持正常。
注意一个细节:URLClassLoader的parent应该用你启动器自己所在的类加载器,而不是它的parent。如果你写成XJarSecMain.class.getClassLoader().getParent(),拿到的是PlatformClassLoader,新Loader就只能看到平台类和临时目录里的类,看不到系统类路径下的其他类,Spring Boot会立刻找不到一堆库。这一点不仔细的话,会浪费你半天时间。
第四步的反射调用约等于替代了原JarLauncher做的事。应用自己的main方法的全限定名,最好从原始Manifest里的Start-Class属性读取,而不是写死在加密配置里。这样以后应用改名时,魔改工具不用动。
还有一个容易踩的隐性坑:在启动器自己的main方法里,不要直接import任何应用类或框架类,哪怕只是用来做个类型判断。一旦你提前触发了这些类的加载,Spring Boot后续的条件装配和反射处理就可能出现奇怪的ClassCastException。最稳妥的方式是像我上面写的,全部通过Class.forName和反射触发。
3.3 解密产物与Lib依赖的类加载策略
解密产物怎么处置,直接决定启动成功率和性能。
方案A:全部写入临时目录。稳定,但如果应用依赖很多,启动时会明显变慢,因为把BOOT-INF/lib整个解压出来比较吃IO。好处是调试方便,解压后的目录结构清清楚楚。
方案B:class解密到内存,lib直接用Spring Boot的NestedJarFile机制读取。性能好,但你需要自己实现NestedJarFile的适配,工作量跟"追着loader跑"没区别,不推荐第一次魔改时做。
方案C:混合方案。class文件解密后写临时目录,lib依赖不解密、直接以原始JAR包形式放入临时目录。这对Spring Boot 3应用来说最省事,因为依赖JAR不加密,并不影响"保护自己业务代码"的目标。如果你的安全要求是"连依赖都不能被别人看到",那就要再做一层lib加密,但场景已经不同了,这篇文章先不展开。
我实际用的就是方案C。在URLClassLoader里,classpath按这个顺序构造:
- 解密的classes临时目录。
- BOOT-INF/lib下解压出来的依赖JAR。
- 原始的加密JAR本身,用于读取尚未解密的资源。
这个顺序借鉴了Spring Boot原来的类加载逻辑:classes优先,lib兜底。实测下来对Spring Boot 3的自动配置、starter扫描基本没有影响。
临时目录的管理也要注意。用Files.createTempDirectory生成的目录,默认权限在Linux上通常只有当前用户可读,这点还好;但一定要在JVM退出时清理,否则每次启动都往/tmp下扔一堆解压文件,运行一段时间后运维就会来找你。清理可以用Runtime.getRuntime().addShutdownHook,或者在main执行完成后finally里删,注意如果应用是常驻进程,后者要小心别把正在用的文件删了。
3.4 Maven插件联动与repackage顺序
魔改后的xjar要进团队构建链,就不能还停留在手工命令行。如果你用xjar-maven-plugin,要注意它和spring-boot-maven-plugin的repackage目标的执行顺序。
通常在构建里,spring-boot-maven-plugin的repackage会先把普通JAR变成可执行JAR,xjar插件应该参与的是这之后的事情:先把可执行JAR里需要保护的class加密,再把Main-Class替换成魔改后的启动器。如果你把顺序搞反了,spring-boot的repackage又会把Main-Class改回它自己的JarLauncher,xjar的加密就白做了。
pom里spring-boot插件的绑定是这样:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <executions> <execution> <id>repackage</id> <phase>package</phase> <goals><goal>repackage</goal></goals> </execution> </executions> </plugin>接着xjar魔改插件的执行要绑定在repackage之后的package阶段,输入是${project.build.directory}/xxx.jar,输出是加密后的xxx-sec.jar。具体坐标需要你在魔改分支里重新打包安装到私有仓库,这里就不给一个可能过时的groupId了。
还有一个细节:如果你在团队里用Spring Security 6、OAuth2,这类框架启动时会有大量条件装配和Bean后处理。一旦你在ClassLoader构造阶段提前加载了任何涉及jakarta.servlet的类,后面的Security自动配置就可能出问题。解决办法就是我前面强调的那一条:启动器里不要直接引用任何业务类或框架类,全部交给反射去触发。
4. 验证与排错:从能编译到能上线,还差好几个坑
4.1 典型失败现象与原因对照
我在验证阶段遇到的问题集中在这几类,整理成表方便你对照:
| 现象 | 根因 | 对策 |
|---|---|---|
| 启动后立即报NoClassDefFoundError: jakarta/servlet/Servlet | 启动器提前依赖了Servlet API | 检查自己main方法里的import |
| URLClassLoader构造正常,但Spring Boot不扫描解密后的类 | 临时目录不是"类路径根目录" | 检查URL是dir而不是dir下某个子目录 |
| 自动配置部分丢失,DataSource相关Bean起不来 | 依赖JAR没有加入类加载器 | 确保BOOT-INF/lib全部纳入URL数组 |
| 中文配置文件解密后乱码 | 旧版xjar默认用平台字符集 | 解码时强制UTF-8 |
| Spring Security 6在启动中期抛BeanCurrentlyInCreationException | 类加载顺序被打乱,条件匹配提前 | 启动器尽量少加载类,保持延迟加载 |
4.2 一次完整的启动失败排查链路
举个例子,我最初实验时加密后的JAR一启动就报:
java.lang.ClassNotFoundException: org.springframework.boot.loader.JarLauncher第一次看到这个报错,我下意识以为是spring-boot-loader版本没对齐。后来一查,Spring Boot 3.2项目的Main-Class已经被改成了org.springframework.boot.loader.launch.JarLauncher,而xjar魔改前的代码还是反射调旧类名,自然是找不到。
完整排查链路是这样的:
- 用java -jar xxx-sec.jar拿到完整堆栈,确认报错发生在我自己写的启动器反射调用处。
- 打开原始JAR的META-INF/MANIFEST.MF,看真正的Main-Class应该是什么。
- 比对spring-boot-loader在3.2版本的源码,确认类的新全限定名。
- 修改启动器反射调用的类名,重新打包,报错变成下一个。
- 下一个报错是另一个签名不匹配。此时我决定不继续逐个适配,直接改成"反射调用应用自己的main"路线,一次解决。
这个链条其实说明了一个重要经验:当你在做一个兼容性魔改时,逐类对齐老API是一条低性价比的路。改到一个报错消除、下一个冒出来,循环往复。换一条思路,直接减少对旧API的依赖,往往才是把问题连根拔掉的办法。
我还建议你在验证阶段做一次"解密产物对照"。加密前和加密后的JAR,在解压后对比一下关键class文件,前者应该是明文class,后者应该是密文。如果你发现加密后的JAR里某个class还是明文,很可能是加密配置里没把它包含进去,这种漏网之鱼很多人会忽略。
4.3 对加密强度的理性预期
最后聊聊安全预期,这决定你魔改到什么程度算"完工"。
xjar这类工具的本质,是把class文件从"明文放在可执行JAR里"变成"运行时才还原成明文"。它能非常有效地阻止普通同事、外包拿到JAR后直接反编译看代码。但它不是银弹:启动时解密后的class一定会存在于JVM内存中,用jmap等工具dump堆,或者直接调试JVM获取加载后的字节码,都是能绕过它的手法。如果你要保护的是绝对机密的核心算法,还需要配合代码混淆、关键逻辑放服务端等手段,单独靠JAR加密不够。
另外,把密钥放在参数、环境变量里各有薄弱点。进程列表、shell history、系统环境变量都可能是泄露入口。我通常建议把口令放到只有启动服务用的专用账号才能读的文件里,并在启动脚本里禁止打印参数。这个认知不是劝退,而是帮你设定合理的完成标准:你魔改一个xjar分支,目标应当是"加大反编译难度、阻止随手复制",而不是"让全世界都拿不到明文"。
这次魔改做完之后,我的体会有两点。第一,网上搜xjar配Spring Boot 3,会冒出来各种魔改版、修改版,但真正靠谱的适配思路往往不在下载链接里,而在于你是否理解了启动链路上哪一环断了。第二,如果你也是"只想保护自己的业务代码,不打算动依赖JAR"这个需求,那我上面推荐的"接管启动 + 临时目录 + 自定义ClassLoader"这条路线,应该是最省力、后遗症最少的一条。如果你动手时踩到更多奇怪的坑,欢迎回头来交流,这类问题永远是踩过的人才知道深浅。