精彩文章免费看

探讨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.
    这里我们主要用两个比较有代表性的例子通过IDE main 方法启动和通过 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
    

    可见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 所以目光聚焦在这里

    这里是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;
    

    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
  • 最后编辑于:2018-05-30 23:34