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

背景

最近在使用一些方法獲取當前代碼的運行路徑的時候,發現代碼中使用的this.getClass().getClassloader().getResource("").getPath() 有時候好使,有時候則是NPE(空指針),緣由就是有時候this.getClass().getClassloader().getResource("") 會返回空,那麼爲何是這樣呢?java

舉例

先想象一下,咱們平時如何啓動一個 Java 應用?git

  • 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 啓動spring

經過 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);
    }
}
複製代碼

結果bootstrap

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
複製代碼

執行結果:tomcat

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

可見ClassLoader.getResource("") 在某些狀況下並不能如願獲取項目執行的根路徑,那麼這裏面的緣由是什麼?是否有通用的方法能夠避免這些問題呢?固然.bash

分析

首先咱們分下一下 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) 中得到.app

可是 getResource 方法確實這樣的ide

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
相關文章
相關標籤/搜索