ClassLoader中的getResource和getSystemResource

拽根胡來發表於2019-11-19

問題描述

在開發一個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即可),否則可能會出錯;
  • 直接參考成型的開原始碼能快速解決問題

相關文章