探讨Classloader的 getResource("") 获取运行根目录方法

背景

最近在使用一些方法获取当前代码的运行路径的时候,发现代码中使用的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

引用

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容