问题描述
在开发一个Spring Boot应用的过程中发现了这样一个问题:在IDE(Intellij)中程序可以调试成功,可以运行,但是当用Maven打包(用的spring-boot-maven-plugin)过后,在命令行中运行这个jar包就会报错。错误指向的是一处ClassLoader的调用:
URL url = ClassLoader.getSystemResource("data/test.xml");
也就是说,在IDE中运行通过ClassLoader.getSystemResource()
能找到data/test.xml
,但是打包过后找不到,会返回null。
那么问题就是出在这个ClassLoader的调用上面了。
解决办法
我的应用场景是将部分资源放在classpath目录下,然后通过代码来加载,有很多其他的开源工具也需要加载一些静态文件(或配置文件),比如log4j,因此可以参考log4j的解决方案。实际上,避免直接使用ClassLoader,换成是使用log4j中的Loader来加载资源就完全可以解决这个问题。
log4j中加载配置文件是通过一个自定义的Loader类来实现的,Loader中封装了ClassLoader的getResource方法和getClass方法。getResource方法的实现逻辑是:
- 如果jdk版本是1.2及以上,则(通过反射)调用当前线程对象的
getContextClassLoader()
方法来获取ClassLoader,然后加载资源 - 如果jdk版本低于1.2,则使用加载Loader类的ClassLoader来加载资源:
Loader.class.getClassLoader()
Loader.java中getResource方法的定义为:
static public URL getResource(String resource) {
ClassLoader classLoader = null;
URL url = null;
try {
if(!java1 && !ignoreTCL) {
classLoader = getTCL();
if(classLoader != null) {
LogLog.debug("Trying to find ["+resource+"] using context classloader "
+classLoader+".");
url = classLoader.getResource(resource);
if(url != null) {
return url;
}
}
}
// We could not find resource. Ler us now try with the
// classloader that loaded this class.
classLoader = Loader.class.getClassLoader();
if(classLoader != null) {
LogLog.debug("Trying to find ["+resource+"] using "+classLoader
+" class loader.");
url = classLoader.getResource(resource);
if(url != null) {
return url;
}
}
} catch(IllegalAccessException t) {
LogLog.warn(TSTR, t);
} catch(InvocationTargetException t) {
if (t.getTargetException() instanceof InterruptedException
|| t.getTargetException() instanceof InterruptedIOException) {
Thread.currentThread().interrupt();
}
LogLog.warn(TSTR, t);
} catch(Throwable t) {
//
// can't be InterruptedException or InterruptedIOException
// since not declared, must be error or RuntimeError.
LogLog.warn(TSTR, t);
}
// Last ditch attempt: get the resource from the class path. It
// may be the case that clazz was loaded by the Extentsion class
// loader which the parent of the system class loader. Hence the
// code below.
LogLog.debug("Trying to find ["+resource+
"] using ClassLoader.getSystemResource().");
return ClassLoader.getSystemResource(resource);
}
getTCL方法的定义为:
private static ClassLoader getTCL() throws IllegalAccessException,
InvocationTargetException {
// Are we running on a JDK 1.2 or later system?
Method method = null;
try {
method = Thread.class.getMethod("getContextClassLoader", null);
} catch (NoSuchMethodException e) {
// We are running on JDK 1.1
return null;
}
return (ClassLoader) method.invoke(Thread.currentThread(), null);
}
问题出现原因
真正的解决办法比上面简单得多。我在原来的代码里面调用的是getSystemResource方法,这个方法是通过ClassLoader找到SystemClassLoader,然后通过这个SystemClassLoader来加载资源。实际上应该调用getResource方法,指定加载当前类(或任意一个自定义类)的ClassLoader来调用即可:
URL url = Loader.class.getClassLoader.getResource("data/test.xml");
无论ClassLoader是通过静态方法getSystemResource,还是通过一个ClassLoader实例(Loader.class.getClassLoader())调用getSystemResource,最后都需要获取到SytemClassLoader来加载资源。
在程序中添加下面的代码:
logger.info("ClassLoader.getSystemClassLoader().getClass()");
logger.info(ClassLoader.getSystemClassLoader().getClass().getName());
在打包前后输出的都是:
ClassLoader.getSystemClassLoader().getClass()
sun.misc.Launcher$AppClassLoader
在打包以前可以用sun.misc.Launcher$AppClassLoader
来加载资源,因为程序是直接在target/classes
里面查找,而打包之后jar包的结构发生了变化,加载jar包内部的资源需要用org.springframework.boot.loader.LaunchedURLClassLoader
。
把上面的代码改为:
logger.info("Loader.class.getClassLoader()");
logger.info(Loader.class.getClassLoader().getClass().getName());
在打包前后输出的结果中就能看到他们的加载器不同:
Loader.class.getClassLoader()
sun.misc.Launcher$AppClassLoader
Loader.class.getClassLoader()
org.springframework.boot.loader.LaunchedURLClassLoader
总结
ClassLoader#getResource
和ClassLoader#getSystemResource
不同,前者是调用加载器实例来加载资源,而后者是调用系统加载器加载资源;- Spring Boot项目打包之后应该用Spring提供的ClassLoader来加载资源(通过自定义类的getClassLoader即可),否则可能会出错;
- 直接参考成型的开源代码能快速解决问题