SpringBoot内嵌JAR免解压加载原理
起因
由于项目环境基于Felix(一个apache的开源OSGi实现框架),Felix框架在处理包含内嵌JAR包的构件时,需要将内嵌JAR解压缩到本地cache目录,才能访问这些内嵌JAR资源。由于内嵌JAR数量庞大,会额外占用约500M左右的重复磁盘空间。
回想到Springboot在打包时,可以同样将项目所有依赖打包到单独的一个JAR中,使用java -jar test.jar
这种形式直接运行。这为项目的分发带来了极大的方便。
一个SpringBoot单一JAR文件的目录结构和OSGi内嵌JAR结构相仿:
这其中,BOOT-INF/classes、BOOT-INF/lib/*.jar
是我们应用的classs-path的内容。我们观察到SpringBoot在运行期间,并不需要将lib下的jar包解压缩到本地磁盘目录,就可以直接访问内嵌JAR中的classs,这是怎么做到的呢?
探究
由于Java对class的加载是随需加载的,即:应用程序启动期间,不会也没有必要把JAR包内的所有class字节内容读取到内存中;而是随着程序的运行,当需要用到某一个class的功能时,才从class-path上搜寻该class的字节码,再加载到内存中。
我们知道,从文件存储格式上看,JAR文件本质上就是Zip文件格式(只是JAR约定META-INF/MANIFEST.MF文件必须是压缩包的第一个entry)。
那么,Zip文件为什么可以实现对Entry的随机读取呢?这取决于Zip文件的存储结构。Zip文件是由一个个Entry数据顺序堆叠起来的,在Zip文件最后,存储了所有Entry的目录信息,其中标识了每一个Entry在Zip文件中的偏移位置。由于Zip文件是对每一个Entry单独压缩(每一个Entry可以选择是否压缩、以及压缩模式),而不是对所有Entry一起压缩--这很关键!所以,通过Entry目录可以定位到任何一个Entry,从而实现Entry数据的随机读取。
我们再看JDK的Zip相关API。JDK是通过自身java.util.zip.ZipFile这个类来实现对JAR中的class或资源的随机读取的。这个类需要一个本地File做构造函数参数,同时不支持内嵌JAR包中class资源的读取。
SpringBoot的实现
spring-boot-loader
是SpringBoot的引导程序,当你使用Maven-Install打包一个SpringBoot单一JAR包时,SpringBootLoader的代码被拷贝到JAR中。
org.springframework.boot
spring-boot-loader
2.6.6
SpringBoot单一JAR包MANIFEST内容:
Manifest-Version: 1.0
Implementation-Title: SpringBootDemo
Spring-Boot-Version: 2.0.6.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: springboot2.DemoApp
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
我们看到,SpringBootLoader:
- 接管了SpringBoot程序的启动入口(Main-Class)
- 对JDK中java.util.zip.ZipFile实现了扩展,以便支持内嵌JAR中class的随机访问
- 提供了SpringBoot-ClassLoader负责BOOT-INF下classes、lib的资源加载(Spring-Boot-Classes、Spring-Boot-Lib内容构成了应用真正的class-path)
使用SpringBootLoader的Zip扩展来随机读取内嵌JAR的资源
我们打包一个SpringBootJar,假如名字叫:SpringBootDemo-1.0-boot.jar
,其中,在BOOT-INF/lib
目录下存在一个内嵌JAR:logback-core-1.2.3.jar
,我们来尝试读取这个内嵌JAR中的class文件:ch.qos.logback.core.Appender.class
示例代码如下:
import org.springframework.boot.loader.jar.JarFile;
public class Demo {
public static void main(String[] args) throws Exception {
JarFile rootJar = new JarFile(new File("SpringBootDemo-1.0-boot.jar"));
String innerName = "BOOT-INF/lib/logback-core-1.2.3.jar";
String className = "ch.qos.logback.core.Appender";
ZipEntry entry = rootJar.getEntry(innerName);
JarFile innerJar = rootJar.getNestedJarFile(entry);
String classEntryName = className.replace('.', '/') + ".class";
entry = innerJar.getEntry(classEntryName);
InputStream in = innerJar.getInputStream(entry);
byte[] bs = new byte[(int) entry.getSize()];
for (int i = 0; i < bs.length;) {
i += in.read(bs, i, bs.length - i);
}
in.close();
System.out.println("entry name: " + entry.getName());
System.out.println("entry time: " + entry.getTime());
System.out.println("class File first byte: 0x" + Integer.toHexString(bs[0] & 0xFF));
rootJar.close();
}
}
执行结果如下:我们看到已成功读取Appender.class文件内容。
entry name: ch/qos/logback/core/Appender.class
entry time: 1490962798000
class File first byte: ca
OSGi构件内嵌JAR的读取尝试
然而,当我们尝试用同样的方法,去读取一个OSGi构件内嵌JAR资源时候,出现了错误:
Unable to open nested entry 'lib/cdi-api-1.0.jar'. It has been compressed and nested jar files must be stored without compression. Please check the mechanism used to create your executable jar file
at org.springframework.boot.loader.jar.JarFile.createJarFileFromFileEntry(JarFile.java:332)
至此,SpringBootLoader在不解压内嵌JAR包时就可以读取其内容的关键就在于:内嵌JAR包必须以STORED
方式存储!
其实,想一下也容易明白,只有内嵌JAR使用STORED
模式存储时(即:非压缩,另一个模式是:DEFLATED
),其内嵌JAR的子资源才能通过地址偏移在不使用压缩算法的情况下进行定位。一旦使用了压缩算法,就必须完整的将内嵌JAR包进行解压缩才能获取内嵌子资源的位置,这需要在内存中持有整个解压缩的数据,显然是不可接受的。
解决方案
明白了SpringBootLoader读取内嵌JAR资源的关键(内嵌JAR以STORED
模式存储),那么对应的OSGi构件内嵌JAR资源的读取方案也就有了。
OSGi构件是通过maven-bundle-plugin
来进行打包的,需要对其进行修改、或者在其后再增加一个新的插件,实现对内嵌JAR包的存储模式的修改。关键代码示例如下;
public static void main(String[] args) throws Exception {
File innerjar = new File("test.jar");
byte[] bs = Files.readAllBytes(innerjar.toPath());
ZipOutputStream jarout = new ZipOutputStream(new FileOutputStream("fat.jar"));
Manifest manifest = new Manifest();
manifest.getMainAttributes().putValue("Manifest-Version", "1.0");
manifest.getMainAttributes().putValue("Bundle-ClassPath", ".,lib/test.jar");
ZipEntry entry = new ZipEntry(JarFile.MANIFEST_NAME);
jarout.putNextEntry(entry);
manifest.write(jarout);
jarout.closeEntry();
entry = new ZipEntry("lib/test.jar");
entry.setMethod(ZipEntry.STORED);
entry.setSize(bs.length);
entry.setCrc(0x6baaa6bL);
jarout.putNextEntry(entry);
jarout.write(bs);
jarout.closeEntry();
jarout.close();
}