ClassLoader中的getResource和getSystemResource

4,082 阅读3分钟

问题描述

在开发一个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#getResourceClassLoader#getSystemResource不同,前者是调用加载器实例来加载资源,而后者是调用系统加载器加载资源;
  • Spring Boot项目打包之后应该用Spring提供的ClassLoader来加载资源(通过自定义类的getClassLoader即可),否则可能会出错;
  • 直接参考成型的开源代码能快速解决问题