Spring Boot -- 如何获取已加载的JAR文件流

最近遇到一个需求,在程序运行期间,拿到已加载类对应的jar包,然后上传到另一个地方,本以为利用ClassLoader直接定位到jar的InputStream流直接读取就ok,事实却没有这么简单,我把问题总结为以下几个小点,逐一解决。

如何根据已加载的类定位到jar?

对于已加载的类,可以通过其对应的Class类的getProtectionDomain()方法获取到对应的文件信息,以获取commons-lang3jar包为例,如清单1所示。
清单1: 根据加载类定位到文件

1
2
3
4
5
6
Class<StringUtils> clazz = StringUtils.class;
ProtectionDomain domain = clazz.getProtectionDomain();
// 获取到对应的jar文件
URL jarFile = domain.getCodeSource().getLocation();
// 获取到对应的类加载器
ClassLoader classLoader = domain.getClassLoader();

该代码在不同环境下运行返回又是什么情况呢?

本地IDE运行

在IDEA中直接运行返回如下所示,很明显IDEA在运行时会把maven仓库中对应的jar路径放入classpath下,运行起来后类加载器自动寻找对应的jar,所以定位到了具体的maven目录。
清单2: IDE直接运行输出

1
file:/Users/quding/.m2/repository/org/apache/commons/commons-lang3/3.7/commons-lang3-3.7.jar

打包成jar运行

单纯的打包为一个jar,Java会把其中的依赖第三方jar解压后一起放入到jar中,如下图所示,因此定位到的是我最终打包为的jar文件,而并非第三方jar文件。因此如果是在这种环境下推荐使用指定classpath形式。
清单3: 打包成jar输出

1
file:/Users/quding/workspace/git/jar-mvn1/target/jar-mvn1-1.0-SNAPSHOT.jar

打包成war运行

写了个接口,返回值是一个具体的文件路径,原因也很简单,因为Tomcat在启动一个webapp时会将对应的war解压,然后针对解压后的路径使用一个单独的类加载器进行加载。
清单4: 打包成war包输出

1
2
3
{
"jarFile": "file:/Users/quding/develop/apache-tomcat-8.5.38/webapps/ROOT/WEB-INF/lib/commons-lang3-3.7.jar"
}

打包成fat jar

fat jar是Spring Boot引入的一种新格式,其打包后的结构与war包比较类似,但是可以直接执行并不需要先解压再加载,打包后类似目录如下:

  1. BOOT-INF/classes – 用户代码
  2. BOOT-INF/lib –依赖第三方架包
  3. org/springframewora/boot/loader – Fat jar启动核心,后续会分析。

此时获取对应的jar,输出如清单5所示,可以看到与前面几种不同,此时路径为jar嵌套形式,暂且定义为jar in jar
清单5: 打包成Fat jar输出

1
2
3
{
"jarFile": "jar:file:/Users/quding/workspace/git/read-jar-demo/target/read-jar-demo-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/commons-lang3-3.7.jar!/"
}

其路径可以分为两个部分看,第一部分jar:file:/Users/quding/workspace/git/read-jar-demo/target/read-jar-demo-0.0.1-SNAPSHOT.jar!,表示当前根架包位置,第二部分/BOOT-INF/lib/commons-lang3-3.7.jar!/所需要的jar在根架包中的位置路径。

如何读取jar?

对于非jar in jar形式,其获取到的目录是一个真是的物理文件路径,因此可以直接使用File读取,从而拿到文件流,这里不重点关注。对于jar in jar因为并不是规范的文件路径,因此无法使用File直接读取,那么该怎么读呢?要解决这个问题需要先了解Spring Boot是怎么做的.

Spring Boot启动原理

打开Spring Boot最终产出的jar包,其MANIFEST.MF文件表明项目的启动入口为org.springframework.boot.loader.JarLauncher,该类在spring-boot-loader模块下,运行时由Spring Boot所提供,因此可以通过maven引入provided类型的依赖从而查看到源码。
清单6: Spirng Boot启动模块

1
2
3
4
5
6
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
<version>2.1.3.RELEASE</version>
<scope>provided</scope>
</dependency>

org.springframework.boot.loader.JarLauncher做的第一步是找到自己所在jar的位置,使用方法与上文介绍的一致。
清单7: Spring Boot定位启动jar包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected final Archive createArchive() throws Exception {
ProtectionDomain protectionDomain = getClass().getProtectionDomain();
CodeSource codeSource = protectionDomain.getCodeSource();
// 获取jar位置,对于本案例返回为 jar:file:/Users/quding/workspace/git/read-jar-demo/target/read-jar-demo-0.0.1-SNAPSHOT.jar!/
URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
String path = (location != null) ? location.getSchemeSpecificPart() : null;
if (path == null) {
throw new IllegalStateException("Unable to determine code source archive");
}
File root = new File(path);
if (!root.exists()) {
throw new IllegalStateException(
"Unable to determine code source archive from " + root);
}
return (root.isDirectory() ? new ExplodedArchive(root)
: new JarFileArchive(root));
}

之后通过java.protocol.handler.pkgs参数注册对应的URL协议扩展,该参数格式为[package_path].[protocol].Handler,因此Spring Boot注册的为org.springframework.boot.loader.jar.Handler这个jar协议扩展处理器,其在读取资源时会调用openConnection方法,如清单8所示:
清单8: Spring Boot URL处理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected URLConnection openConnection(URL url) throws IOException {
// 判断资源是否在该jar中,如果在则去jar中获取
if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) {
return JarURLConnection.get(url, this.jarFile);
}
try {
// 如果不在则去对应的根路径jar获取
return JarURLConnection.get(url, getRootJarFileFromUrl(url));
}
catch (Exception ex) {
// 获取失败使用JDK自带的方式获取,作为备份方案
return openFallbackConnection(url, ex);
}
}

由上述逻辑可以发现,当URL为jar:file:/Users/quding/workspace/git/read-jar-demo/target/read-jar-demo-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/commons-lang3-3.7.jar!/形式,最终会调用JarURLConnection.get(url, this.jarFile)方法来获取真正的jar文件,该JarURLConnection并非JDK自带的类,其为class JarURLConnection extends java.net.JarURLConnection,因此在运行时可以安全的向上转型为java.net.JarURLConnection,在其get方法中会对URL进行循环处理,对结果进行嵌套包装,近而解决jar in jar类型的读取问题。
清单9: jar in jar循环读取

1
2
3
4
5
6
7
8
9
10
11
while ((separator = spec.indexOf(SEPARATOR, index)) > 0) {
// 读取对应的资源
JarEntryName entryName = JarEntryName.get(spec.subSequence(index, separator));
JarEntry jarEntry = jarFile.getJarEntry(entryName.toCharSequence());
if (jarEntry == null) {
return JarURLConnection.notFound(jarFile, entryName);
}
// 结果嵌套包装
jarFile = jarFile.getNestedJarFile(jarEntry);
index = separator + SEPARATOR.length();
}

最后会根据得到的URL路径创建对应的类加载器org.springframework.boot.loader.LaunchedURLClassLoader,使用该类加载器进行加载。

利用Handler读取jar

Spring Boot启动原理的关键点是实现了jar in jar协议的处理器org.springframework.boot.loader.jar.Handler,读取的主要功能为Handler中实现的openConnection方法,因此当在项目代码中想要读取jar in jar格式的架包,则可以用该Handler进行资源读取。

1
URL url = new URL(jarFile, "", new org.springframework.boot.loader.jar.Handler());

参考

spring boot应用启动原理分析

Guava -- Bloom Filter原理
造轮子-- Hosts-Switch-Alfred插件