JohnShen's Blog.

使用自定义ClassLoader加载SpringBoot打包文件中嵌套jar驱动

字数统计: 1.1k阅读时长: 4 min
2019/09/11 Share

需求:针对某些DB不同版本的冲突问题,采用自定义类加载器的方式自行加载 Driver,驱动包放至 jdbc module 下,而 web module 在使用 Spring Boot 打成 jar 包后,需要获取到内部 jdbc module 中的驱动包。Spring Boot jar 包启动时,自定义类加载需要能够读到驱动包资源。

曾几何时,我以为所谓的Spring Boot 启动原理这些知识点最多就是面试时候可能问到的东西,后来在工作时还真用到了。

前期尝试

这种需求,本以为并不难做,毕竟针对 jar 包内部的 jar,Java URL 协议中本身就有jar:file:前缀提供支持。使用PathMatchingResourcePatternResolver获取到 URL 后,直接赋给 URLClassLoader 应该是可行的。这种方式在 IDE 中运行时没有问题的,urls 中的 URL 都是以file:为前缀,运行正常;而打成 jar 包运行时,URL的格式变为jar:file:/...web.jar!/BOOT-INF/lib/...jdbc.jar!/.../hive/2.1.1/hive-common-2.1.1.jar,运行时出现了java.lang.ClassNotFoundException

1
2
3
4
5
6
7
8
String location = "classpath:drivers/hive/2.1.1/*.jar";  
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources(locationPattern);
URL[] urls = new URL[resources.length];
for (int i = 0; i < resources.length; i++) {
urls[i] = resources[i].getURL();
}
return new URLClassLoader(urls, getClassLoader());

重新调试

我选择在一个单独的调试项目下对刚才生成的jar包进行调试。第一直觉是 URLClassloader 是不是不支持这种 jar:file前缀 URL,不过并没有找到相关描述信息,不过倒是发现刚才的jar:file的URL中没有加上!/后缀(这表示在 jar 包内部就行查找)。实际上,对于原始的JarFile URL,只支持一个!/。那 Spring Boot 是怎么做到的呢,他是怎么做到能够读取到 jar in jar 呢,原来 Spring Boot 扩展了协议,使其能够支持读取嵌套 jar。于是在调试项目下,我引入了spring-boot-loader的依赖进行试验。

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
</dependency>

由于一般 Spring Boot 打成的 jar 包中的META-INF/MANIFEST.MF文件中会包含Main-Class: org.springframework.boot.loader.JarLauncher。即指定了 Main Class 为 JarLauncher。在其 launch 方法中发现,Spring Boot 就是在 JarFile.registerUrlProtocolHandler()注册了URLStreamHandler的实现:org.springframework.boot.loader.jar.Handler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// in Launcher
protected void launch(String[] args) throws Exception {
JarFile.registerUrlProtocolHandler();
ClassLoader classLoader = createClassLoader(getClassPathArchives());
launch(args, getMainClass(), classLoader);
}

// in JarFile
private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs";
public static void registerUrlProtocolHandler() {
String handlers = System.getProperty(PROTOCOL_HANDLER, "");
System.setProperty(PROTOCOL_HANDLER, ("".equals(handlers) ? HANDLERS_PACKAGE
: handlers + "|" + HANDLERS_PACKAGE));
resetCachedUrlHandlers();
}

在测试项目下,由于是 IDE 运行,我先在 main 方法中加入了JarFile.registerUrlProtocolHandler()。在Handle的openConnection方法中调用了JarURLConnectionget(URL url, JarFile jarFile)方法,真正报错的地方在于内部的jarFile.getNestedJarFile(jarEntry),这个信息在java.lang.ClassNotFoundException的堆栈中是没有显示的:

1
java.lang.IllegalStateException: Unable to open nested entry '....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

显示是 Jar 包压缩的问题?于是我在 jdbc module 的 pom 文件中加了这么一段,compress 置为了false,结果发现终于成功了。

1
2
3
4
5
6
7
8
9
10
11
12
13
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<compress>false</compress>
</archive>
</configuration>
</plugin>
</plugins>
</build>

最终实现

到最后发现实际上改的地方不多,但是如果自己不去调试 Spring Boot 的启动包,也发现不了真正的原因。

改动1就是 jdbc module 加入了maven-jar-plugin插件且compress置为 false。 改动2是在一开始实现的 for 循环中,对于jar:file开头的URL加了!/后缀。

1
2
3
4
5
6
7
8
9
for (int i = 0; i < resources.length; i++) {
String urlStr = resources[i].getURL().toString();
if (StringUtils.isNotBlank(urlStr) && urlStr.startsWith("jar:file")) {
urlStr = urlStr + "!/";
}
URL url = ResourceUtils.getURL(urlStr);
urls[i] = url;
log.info(url.toString());
}

实际上代码中不需要加JarFile.registerUrlProtocolHandler()注册协议,由于刚才的调试项目的main方法运行的,所以才加上。而在正式项目代码处,若打包运行的话,jar 包启动时本身就执行了注册协议;若在 IDE 里运行,file:前缀则不需要走此协议。当然如果不是加上这句话进行调试,也发现不了问题。

Reference

CATALOG
  1. 1. 前期尝试
  2. 2. 重新调试
  3. 3. 最终实现
  4. 4. Reference