探討Classloader的 getResource("") 獲取執行根目錄方法

candyleer發表於2019-02-23

背景

最近在使用一些方法獲取當前程式碼的執行路徑的時候,發現程式碼中使用的this.getClass().getClassloader().getResource("").getPath() 有時候好使,有時候則是NPE(空指標),原因就是有時候this.getClass().getClassloader().getResource("") 會返回空,那麼為什麼是這樣呢?

舉例

先想象一下,我們平時如何啟動一個 Java 應用?

  • IDE中通過 main 方法啟動
  • 把專案打一個 war 包扔到伺服器中,諸如 tomcat,jetty 等
  • 通過 fat-jar 方法直接啟動.
  • 通過 spring-boot 啟動.

值得一提的是 spring-boot 和 fat-jar 都是通過java -jar your.jar 的方式啟動,之所以換分為兩類,是因為在 spring boot中類載入器(LaunchedURLClassLoader)是被重新定義過的,可以隨意載入 nested jars,而 fat-jar 目前都還是簡單實現了 classloader. 這裡我們主要用兩個比較有代表性的例子通過IDEmain 方法啟動和通過 fat-jar 啟動

通過 IDE main 方法啟動

package com.example.test;

import java.net.URL;

/**
 * @author lican
 */
public class FooTest {

    public static void main(String[] args) {
        ClassLoader classLoader = FooTest.class.getClassLoader();
        System.out.println(classLoader);
        URL resource = classLoader.getResource("");
        System.out.println(resource);
    }
}
複製程式碼

結果

sun.misc.Launcher$AppClassLoader@18b4aac2
file:/Users/lican/git/test/target/test-classes/
複製程式碼

通過 fat-jar 啟動

package com.test.fastjar.fatjartest;


import java.net.URL;

public class FatJarTestApplication {

    public static void main(String[] args) throws Exception {
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        System.out.println(contextClassLoader);
        URL resource = contextClassLoader.getResource("");
        System.out.println(resource);
    }
}
複製程式碼

mvn clean install -DskipTests進行打包,在命令列進行啟動

java -jar target/fat-jar-test-0.0.1-SNAPSHOT-jar-with-dependencies.jar
複製程式碼

執行結果:

jdk.internal.loader.ClassLoaders$AppClassLoader@4f8e5cde
null
複製程式碼

可見ClassLoader.getResource("") 在某些情況下並不能如願獲取專案執行的根路徑,那麼這裡面的原因是什麼?是否有通用的方法可以避免這些問題呢?當然.

分析

首先我們分下一下 jdk 關於這一段的原始碼或許就比較清楚了. 我們呼叫 getResource("") 首先會到java.lang.ClassLoader#getResource

    public URL getResource(String name) {
        URL url;
        if (parent != null) {
            url = parent.getResource(name);
        } else {
            url = getBootstrapResource(name);
        }
        if (url == null) {
            url = findResource(name);
        }
        return url;
    }
複製程式碼

這裡如果我們用的是 main 方法啟動,那麼當前的 classloader 就是AppClassloader,parent 就是ExtClassloader, 這裡無論從 parent 還是 bootstrapResource 都無法找到相對應的資源(通過 debug), 那麼這個返回值肯定是從 findResource(name) 中獲得.

但是 getResource 方法確實這樣的

  protected URL findResource(String name) {
        return null;
    }
複製程式碼

顯然被子類覆寫了,檢視一下實現的子類,由於 AppClassloader 繼承自 URLClassloader 所以目光聚焦在這裡

image.png

這裡是java.net.URLClassLoader#findResource 的實現

 public URL findResource(final String name) {
        /*
         * The same restriction to finding classes applies to resources
         */
        URL url = AccessController.doPrivileged(
            new PrivilegedAction<URL>() {
                public URL run() {
                    return ucp.findResource(name, true);
                }
            }, acc);

        return url != null ? ucp.checkURL(url) : null;
    }
複製程式碼

大概可以看明白,這裡最終是ucp.findResource(name, true);在查詢資源 定位到sun.misc.URLClassPath#findResource

 public URL findResource(String name, boolean check) {
        Loader loader;
        int[] cache = getLookupCache(name);
        for (int i = 0; (loader = getNextLoader(cache, i)) != null; i++) {
            URL url = loader.findResource(name, check);
            if (url != null) {
                return url;
            }
        }
        return null;
    }
複製程式碼

就是URL url = loader.findResource(name, check);這裡在載入. 但是這個loader是個什麼鬼?它又是從哪裡載入的我們查詢的 name 呢?

LoaderURLClassPath裡面的一個靜態內部類 sun.misc.URLClassPath.Loader總共有兩個子類

image.png

從名稱上面看FileLoader 就是載入檔案的 loader,JarLoader 就是載入 jar 包的 loader.最終的 findResource 會找到各自loader 的 findResource 進行查詢. 在分析這兩個 loader 之前,我們先看看這兩個 loader 是怎樣產生的? sun.misc.URLClassPath#getLoader(java.net.URL)

/*
     * Returns the Loader for the specified base URL.
     */
    private Loader getLoader(final URL url) throws IOException {
        try {
            return java.security.AccessController.doPrivileged(
                new java.security.PrivilegedExceptionAction<Loader>() {
                public Loader run() throws IOException {
                    String file = url.getFile();
                    if (file != null && file.endsWith("/")) {
                        if ("file".equals(url.getProtocol())) {
                            return new FileLoader(url);
                        } else {
                            return new Loader(url);
                        }
                    } else {
                        return new JarLoader(url, jarHandler, lmap, acc);
                    }
                }
            }, acc);
        } catch (java.security.PrivilegedActionException pae) {
            throw (IOException)pae.getException();
        }
    }
複製程式碼

需要說明的是,這裡的引數 url 是從 classpath 中 pop 出來的,迴圈 pop, 直到全部查詢完成. 那麼我們在 IDE 的 main方法執行時,他的 classpath之一其實就是file:/Users/lican/git/test/target/test-classes/ 而在用 jar 包執行的時候, classpath 之一是執行的 jar 包,比如 /Users/lican/git/fat-jar-test/target/fat-jar-test-0.0.1-SNAPSHOT-jar-with-dependencies.jar,由於這兩個 classpath 得不同導致了一個走向了 FileLoader, 一個走向了JarLoader, 最終的原因就定位到了這兩個 loader 得 getResource 的不同之處.

FileLoader#getResource()

Resource getResource(final String name, boolean check) {
            final URL url;
            try {
                URL normalizedBase = new URL(getBaseURL(), ".");
                url = new URL(getBaseURL(), ParseUtil.encodePath(name, false));

                if (url.getFile().startsWith(normalizedBase.getFile()) == false) {
                    // requested resource had ../..'s in path
                    return null;
                }

                if (check)
                    URLClassPath.check(url);

                final File file;
                if (name.indexOf("..") != -1) {
                    file = (new File(dir, name.replace('/', File.separatorChar)))
                          .getCanonicalFile();
                    if ( !((file.getPath()).startsWith(dir.getPath())) ) {
                        /* outside of base dir */
                        return null;
                    }
                } else {
                    file = new File(dir, name.replace('/', File.separatorChar));
                }

                if (file.exists()) {
                    return new Resource() {
                        public String getName() { return name; };
                        public URL getURL() { return url; };
                        public URL getCodeSourceURL() { return getBaseURL(); };
                        public InputStream getInputStream() throws IOException
                            { return new FileInputStream(file); };
                        public int getContentLength() throws IOException
                            { return (int)file.length(); };
                    };
                }
            } catch (Exception e) {
                return null;
            }
            return null;
        }
複製程式碼

這裡的 dir 就傳進來的 classpath:file:/Users/lican/git/test/target/test-classes/ 所以到了這一行file = new File(dir, name.replace('/', File.separatorChar)); 即使進來的是空字串(""),因為本身是一個目錄,所以 file 是存在的,所以下面的 exists 判斷城裡,最後返回了這個資料夾的 url 資源回去.於是拿到了根目錄.

JarLoader#getResource()

 /*
         * Returns the JAR Resource for the specified name.
         */
        Resource getResource(final String name, boolean check) {
            if (metaIndex != null) {
                if (!metaIndex.mayContain(name)) {
                    return null;
                }
            }

            try {
                ensureOpen();
            } catch (IOException e) {
                throw new InternalError(e);
            }
            final JarEntry entry = jar.getJarEntry(name);
            if (entry != null)
                return checkResource(name, check, entry);

            if (index == null)
                return null;

            HashSet<String> visited = new HashSet<String>();
            return getResource(name, check, visited);
        }
複製程式碼

首先會從 jar 包裡面去找""的資源,對於final JarEntry entry = jar.getJarEntry(name);顯然是拿不到的,這裡肯定會返回 null, 程式會繼續向下走到return getResource(name, check, visited);,我們看看這裡面的實現.

 Resource getResource(final String name, boolean check,
                             Set<String> visited) {

            Resource res;
            String[] jarFiles;
            int count = 0;
            LinkedList<String> jarFilesList = null;

            /* If there no jar files in the index that can potential contain
             * this resource then return immediately.
             */
            if((jarFilesList = index.get(name)) == null)
                return null;

            do {
...
複製程式碼

if((jarFilesList = index.get(name)) == null)這一步其實就永遠是 null 了(index就是一個檔名稱和 jar 包的一對多對映關係),因為 index 裡面不會快取""為 key 的東西.所以通過 jar 包去拿跟路徑永遠返回 null.

至此,我們就明白了為什麼通過this.getClass().getClassloader().getResource("")有時候拿得到,有時候拿不到的原因了,那麼有什麼辦法可以解決嗎?

解決方案

看過上面的實現,其實解決方案就比較明確了,使final JarEntry entry = jar.getJarEntry(name);返回不為空那麼我們便可以拿到路徑了,這裡我們用了一個變通的方法.實現如下,可以在任何情況下拿到路徑,比如當前的工具類是InstanceInfoUtils,那麼

private static String getRuntimePath() {
        String classPath = InstanceInfoUtils.class.getName().replaceAll("\\.", "/") + ".class";
        URL resource = InstanceInfoUtils.class.getClassLoader().getResource(classPath);
        if (resource == null) {
            return null;
        }
        String urlString = resource.toString();
        int insidePathIndex = urlString.indexOf('!');
        boolean isInJar = insidePathIndex > -1;
        if (isInJar) {
            urlString = urlString.substring(urlString.indexOf("file:"), insidePathIndex);
            return urlString;
        }
        return urlString.substring(urlString.indexOf("file:"), urlString.length() - classPath.length());
    }
複製程式碼

驗證上述 fat-jar 的例子,返回結果為

file:/Users/lican/git/fat-jar-test/target/fat-jar-test-0.0.1-SNAPSHOT-jar-with-dependencies.jar
複製程式碼

符合期望.

其他

為什麼 spring boot可以拿到呢? spring boot 自定義了很多東西來解決這些複雜的情況,後續有機會詳解,簡單來說

  • spring boot註冊了一個Handler來處理”jar:”這種協議的URL
  • spring boot擴充套件了JarFile和JarURLConnection,內部處理jar in jar的情況
  • 在處理多重jar in jar的URL時,spring boot會迴圈處理,並快取已經載入到的JarFile
  • 對於多重jar in jar,實際上是解壓到了臨時目錄來處理,可以參考JarFileArchive裡的程式碼
  • 在獲取URL的InputStream時,最終獲取到的是JarFile裡的JarEntryData

相關文章