背景
最近在使用一些方法获取当前代码的运行路径的时候,发现代码中使用的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 所以目光聚焦在这里
这里是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 呢?
Loader
是URLClassPath
里面的一个静态内部类
sun.misc.URLClassPath.Loader
总共有两个子类
从名称上面看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