背景
最近在使用一些方法獲取當前程式碼的執行路徑的時候,發現程式碼中使用的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