探讨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

猜你喜欢

转载自juejin.im/post/5c70e18de51d455c8c669221