jar、war 和 SpringBoot 載入包內外資源的方式總結,你再也不會出現FileNotFoundException了
工作中常常會用到檔案載入,然後又經常忘記,印象不深,沒有系統性研究過,從最初的war包專案到現在的springboot專案,從載入外部檔案到載入自身jar包內檔案,也發生了許多變化,這裡開一貼,作為自己的備忘錄,也希望能給廣大 java
coder 帶來幫助。
一、目標
通過此文,能熟知普通war包專案目錄內、jar包自身內檔案的載入方式。
二、檔案定位
2.1 WAR 包專案
為什麼先說war包專案,war包專案部署到Web容器裡後 ,會被解壓,所以檔案讀取方式,和在ide裡面讀取是類似的。
讀取檔案,首先要定位檔案,定位到檔案之後才能讀取。
定位檔案,java常用的有兩種,分別是
URL Class.getResource(String name)
URL ClassLoader.getResource(String name)
這裡的引數 name
,就是咱們認為的路徑,官方對這個引數名的描述是:
name of the desired resource
渴望得到的資源的名字
URL
則是資源的定位,可以得到資源所在路徑。
URL
可以是不同的資源,通過其欄位 protocol
來區分是哪種型別資源,取值有:
- ftp
- nntp
- http
- file
- jar
感興趣的同學可以自行了解 URL
的定義
2.1.1 Class.getResource(String name)
通過class例項獲得資源的定位,傳入引數有如下查詢方式:
- 以
/
開頭,則從classPath
即執行的class
檔案所在的專案的***/classes/
目錄下找起 - 非以
/
開頭的,則從當前class所在路徑下找起
驗證:
先上專案結構圖
驗證程式碼
public class ClassResource { public static void main(String[] args) { ClassResource classResource = new ClassResource(); classResource.resWithInstance(""); classResource.resWithInstance("/"); classResource.resWithInstance("ClassResource.class"); classResource.resWithInstance("/ClassResource.class"); classResource.resWithInstance("/1.txt"); } public void resWithInstance(String path) { URL resource = this.getClass().getResource(path); print(resource, path); } private static void print(URL resource, String path) { try { System.out.println("ClassResource 根據目錄[" + String.format("%-20s", path) + "] 獲取路徑為 " + resource); } catch (Exception e) { System.out.println("ClassResource 根據目錄[" + path + "] 獲取路徑出錯,錯誤原因:" + e.getMessage()); } } }
我們傳入了5個引數,分別是
- 空字串
/
- 當前類檔名
/
+ 當前類檔名/
+ 專案resources
目錄下的 1.txt 檔案
執行結果如下:
結果分析:
-
空字串
定位為當前類路徑
-
/
定位為
classPath
路徑 -
當前類檔名
定位為當前類檔案所在路徑,成功定位到檔案
-
/
+ 當前類檔名定位不到檔案
-
/
+ 專案resources
目錄下的 1.txt 檔案定位為
resoures/1.txt
,因為編譯後resources
目錄裡的檔案都移動到了classPath
路徑下,所以也成功定位
4 的錯誤原因很明顯,因為 classPath
路徑下沒有名叫 ClassResource.class
的檔案,所以定位不到
總結:
使用 class 查詢檔案,以
/
開頭的檔名,是從classPath
目錄下找,否則從當前類檔案目錄下找
2.1.2 ClassLoader.getResource(String name)
通過 classLoader
例項獲得資源的定位,傳入引數僅有如下查詢方式:
- 從
classPath
路徑下找起
驗證:
驗證程式碼
public class ClassLoaderResource {
public static void main(String[] args) {
ClassLoaderResource classLoaderResource = new ClassLoaderResource();
classLoaderResource.resWithInstance("");
classLoaderResource.resWithInstance("/");
classLoaderResource.resWithInstance("ClassLoaderResource.class");
classLoaderResource.resWithInstance("/ClassLoaderResource.class");
classLoaderResource.resWithInstance("1.txt");
classLoaderResource.resWithInstance("/1.txt");
}
public void resWithInstance(String path) {
URL resource = this.getClass().getClassLoader().getResource(path);
print(resource, path);
}
private static void print(URL resource, String path) {
try {
System.out.println("ClassLoaderResource 根據目錄[" + String.format("%-26s", path) + "] 獲取路徑為" + resource);
} catch (Exception e) {
System.out.println("ClassLoaderResource 根據目錄[" + path + "]獲取路徑出錯,錯誤原因:" + e.getMessage());
}
}
}
我們傳入了6個引數,分別是
- 空字串
/
- 當前類檔名
/
+ 當前類檔名1.txt
/1.txt
執行結果如下:
結果分析:
-
空字串
定位為
classPath
路徑 -
/
定位不到
-
當前類檔名
定位不到
-
/
+ 當前類檔名定位不到
-
resources
目錄下的 1.txt 檔名定位為
resoures/1.txt
,因為編譯後resources
目錄裡的檔案都移動到了classPath
路徑下,成功定位 -
/
+resources
目錄下的 1.txt 檔名定位不到
3 的錯誤原因很明顯,因為 classPath
路徑下沒有名叫 ClassResource.class
的檔案,所以定位不到
2、4、6 的錯誤原因是因為以 /
開頭,這裡先記著:
以
/
開頭的都會定位不到,但是引數中可以帶有/
來表示下一級路徑如:查詢
Main.class
的引數此處應寫為com/yx/jtest/Main.class
總結:
使用 classLoader 查詢檔案,總是從
classPath
目錄下找起,且不能以/
開頭
2.1.3 Class.getResource 與 ClassLoader.getResource 的異同原因
對於相同的開頭字元 /
、空字串
為什麼兩種方式的執行結果不一樣呢
來分析下 class.getResource
原始碼
public class Class {
public java.net.URL getResource(String name) {
name = resolveName(name); // ①
ClassLoader cl = getClassLoader0();
if (cl == null) {
// A system class.
return ClassLoader.getSystemResource(name);
}
return cl.getResource(name);
}
}
可以看到在進行 ① 轉換資源名稱後,內部還是呼叫了 classLoader.getResource
方法。
那麼異同的奧祕就都在這個第一行裡的 resolveName(name)
方法裡了
來看 resolveName(name)
public class Class {
/**
* Add a package name prefix if the name is not absolute Remove leading "/"
* if name is absolute
*/
private String resolveName(String name) {
if (name == null) {
return name;
}
if (!name.startsWith("/")) {
Class<?> c = this;
while (c.isArray()) {
c = c.getComponentType();
}
// 這裡的baseName類似 com.foo.Bar 之類的形式
String baseName = c.getName();
int index = baseName.lastIndexOf('.');
if (index != -1) {
// 拼name,把包名稱拼上,如 "com/foo/" + "/" + "Bar1"
// 就是獲取當前類目錄下的路徑名
name = baseName.substring(0, index).replace('.', '/')
+ "/" + name;
}
} else {
name = name.substring(1);
}
return name;
}
}
可以看到:
- 如果不以
/
開頭,就返回當前類所在目錄
+資源名
- 否則返回
/
後面的字串 - 總結就是該方法把相對路徑轉換為了基於
classPath
的絕對路徑
在經過資源名稱處理後,就跟 classLoader.getResource
的規則一樣了。
這裡處理 /
符號也間接說明了 classLoader.getResource
不再接受 /
開頭的資源名稱,因為它把 /
當成了路徑分隔符,下面是官方的引數說明
The name of a resource is a '/'-separated path name that identifies the resource.
資源的名稱是一個“/”分隔的路徑名,用於標識資源。
所以兩者的異同點在於:
class.getResource
先進行了/
符號開頭的路徑的預處理,使之轉換為了基於classPath
的絕對路徑,再呼叫classLoader.getResource
的方法而
classLoader.getResource
只接受基於classPath
的絕對路徑,並不再接受以/
開頭的路徑,此時""
空字串則代表classPath
路徑,而非class.getResource
的/
2.2 JAR包專案
當專案為jar專案時,載入的方式變了,主要有
classPath
路徑由file
目錄變成了jar
檔案,這影響到資源的定位方式,而且不再支援獲取當前classPath
路徑URLClassPath
載入資源時候由FileLoader
變成了JarLoader
,這影響到資源對特殊符號的處理方式- 定位內部檔案的URL協議由
file
變成了jar
,這影響到資原始檔的讀取方式
先來看打包成jar後的執行情況,這次使用另外一個類去寫測試,該類直接呼叫上面的演示方法
程式碼:
public class Main {
public static void main(String[] args) {
ClassResource classResource = new ClassResource();
classResource.resWithInstance("");
classResource.resWithInstance("/");
classResource.resWithInstance("ClassResource.class");
classResource.resWithInstance("/ClassResource.class");
classResource.resWithInstance("1.txt");
classResource.resWithInstance("/1.txt");
ClassLoaderResource classLoaderResource = new ClassLoaderResource();
classLoaderResource.resWithInstance("");
classLoaderResource.resWithInstance("/");
classLoaderResource.resWithInstance("ClassResource.class");
classLoaderResource.resWithInstance("/ClassResource.class");
classLoaderResource.resWithInstance("1.txt");
classLoaderResource.resWithInstance("/1.txt");
}
}
執行結果:
分析:
class.getResource()
- 空字串仍代表當前類路徑,以非
/
開頭的資源名稱都會從當前類路徑下找起。記得沒有打包時候的規則嗎,沒錯,這裡的空格又被轉換成了當前類路徑,然後呼叫的classLoader.getResource()
方法/
仍代表應用classPath
路徑,以/
開頭的資源名稱都會從應用classPath
路徑下找起,找資源名為/
後面的字元的資源。但是如果只傳/
則定位不到,不能輸出classPath
路徑
classLoader.getResource()
- 還是不接受
/
開頭的資源名稱,所有/
開頭的資源名稱都會返回為null,定位不到- 空字串原本代表
classPath
路徑,這裡不再支援- 非
/
開頭的資源從應用classPath
路徑下找起
其他
classPath
由資料夾變成了jar檔案- URL協議由
file:
變成了jar:file:
三、檔案載入
上一章節,我們已經知道了 class
與 classLoader
定位資源的異同,和在打成 jar 包之後的變化。
現在定位檔案已經做到了,這裡不再區分究竟是 class
定位的檔案還是classLoader
定位的檔案,本章節就使用 class
去定位檔案如何載入我們定位到的檔案呢?
3.1 WAR 包專案
因為 WAR
專案會被解壓成為具體的檔案(Tomcat),所以這裡我們用傳統的 File
描述一個物件,並讀取即可。
public class ClassResource {
public static void main(String[] args) {
readFile("/1.txt");
}
public static void readFile(String path) {
//1.定位資源
URL resource = ClassResource.class.getResource(path);
System.out.println("[getResource ] 讀取檔案:" + resource);
if (null == resource) {
System.out.println("找不到資原始檔");
return;
}
//2.對映資源
File file = new File(resource.getPath());
InputStream inputStream = null;
try {
inputStream = new FileInputStream(file);
//3.讀取資源
read(inputStream);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (null != inputStream) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
private static void read(InputStream resource) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int i;
while ((i = resource.read()) != -1) {
baos.write(i);
}
System.out.println(new String(baos.toByteArray(), StandardCharsets.UTF_8));
}
}
執行結果:
可以看到能正常讀取,這裡不再敘述。
3.2 JAR 包專案
我們首先將上述方法打到 jar 包裡面去執行,看一下效果
import com.yx.jtest.loadfile.ClassLoaderResource;
import com.yx.jtest.loadfile.ClassResource;
public class Main {
public static void main(String[] args) {
System.out.println("#############打包後ClassResource開始讀取檔案############");
ClassResource.readFile("/1.txt");
}
}
執行結果:
可以看到,讀取失敗了:FileNotFoundException
,到這裡,大家可以思考下,為什麼檔案讀取不到了?
3.2.1 為什麼路徑是 jar:
開頭
注意看紅框部分輸出的檔案 URL,這個 URL 不再是以 file:
開頭的了。這裡先標記下,我們來跟蹤下 classLoader.getResource()
的方法,來找到為什麼是 jar:
開頭。不感興趣的同學可以跳過這部分
public class ClassLoader {
//...
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;
}
}
熟悉的雙親委任模型,這裡不多說,介紹下 ClassLoader
這個類和 Java 的類載入器
從Java虛擬機器的角度來講,只存在兩種不同的類載入器:
一種是啟動類載入器 (Bootstrap ClassLoader),這個類載入器使用C++語言實現,是虛擬機器自身的一部分;
另一種就是所有其他的類載入器,這些類載入器都由Java語言實現,獨立於虛擬機器外部,並且 全都繼承自抽象類java.lang.ClassLoader。
摘自:《深入理解Java虛擬機器-JVM高階特性與最佳實踐》
其中啟動類載入器和其他類載入器的關係,如下圖所示:
到這裡,我們能知道上述程式碼的 ClassLoader
例項的 parent
變數都是誰了,這裡揭示下:
jar 啟動呼叫的類載入器為
AppClassLoader
,其parent
為ExtClassLoader
,而ExtClassLoader
的父載入器就是啟動類載入器了
其中:
- 啟動類載入器 預設載入 <JAVA_HOME>/lib 目錄下的能被虛擬機器正確識別的類庫
- 擴充套件載入器 預設載入 <JAVA_HOME>/lib/ext 目錄下的類庫 可以看到,這兩個都不是用來載入我們指定的檔案的,載入
1.txt
只能是AppClassLoader
的工作了。
因為父類載入器得到的 url
均為null,所以方法執行到 findResource(name)
這一行
AppClassLoader
本身沒有這個方法的實現類,這裡追蹤到其父類 URLClassLoader
的實現
public class URLClassLoader {
//...
public URL findResource(final String name) {
/*
* 忽略這個方法,可以看到是交個成員變數 ucp 去找資源了
*/
URL url = AccessController.doPrivileged(
new PrivilegedAction<URL>() {
public URL run() {
//交給 ucp 尋找
return ucp.findResource(name, true);
}
}, acc);
return url != null ? ucp.checkURL(url) : null;
}
}
這裡的 ucp 變數,是個 URLClassPath
例項,繼續往下追
public class URLClassPath {
//...
public URL findResource(String var1, boolean var2) {
int[] var4 = this.getLookupCache(var1);
URLClassPath.Loader var3; //找到對應的Loader
for (int var5 = 0; (var3 = this.getNextLoader(var4, var5)) != null; ++var5) {
//讓Loader去找資源
URL var6 = var3.findResource(var1, var2);
if (var6 != null) {
//找到資源並返回
return var6;
}
}
return null;
}
}
通過註釋可以看到最終是通過 URLClassPath
的內部類 Loader
去定位的資源
這裡介紹下 Loader
的兩個實現類
- JarLoader
- FileLoader
到這裡就不再往下追蹤了,需要知道的是,打成Jar包後,檔案的定位靠 JarLoader
來了
private static class Loader implements Closeable {
private final URL base;
Loader(URL var1) {
this.base = var1;
}
}
static class JarLoader extends Loader {
private final URL csu;
JarLoader(URL var1, URLStreamHandler var2, HashMap<String, URLClassPath.Loader> var3, AccessControlContext var4) throws IOException {
//這裡設定 base url 的協議為 jar:
super(new URL("jar", "", -1, var1 + "!/", var2));
//..
}
//1
URL findResource(String var1, boolean var2) {
//先獲取resource, 找到 resource 獲得其資源定位符 URL
Resource var3 = this.getResource(var1, var2);
//返回 檔案 url 給我們寫的程式碼
//返回 檔案 url 給我們寫的程式碼
//返回 檔案 url 給我們寫的程式碼
return var3 != null ? var3.getURL() : null;
}
//2
Resource getResource(String var1, boolean var2) {
//省略部分程式碼
//其他不看,看這裡,checkResource後會返回resource
return this.checkResource(var1, var2, var3);
}
//3
Resource checkResource(final String var1, boolean var2, final JarEntry var3) {
final URL var4;
//..
//獲取初始化時候設定的 base url ,其協議為 jar,並重新封裝目標 url,然後賦值給下面的 Resource 例項
var4 = new URL(this.getBaseURL(), ParseUtil.encodePath(var1, false));
//..
//返回資源
return new Resource() {
public URL getURL() {
//上述的封裝的目標 url
return var4;
}
// ..
};
}
}
所以我們獲取到的資源定位就是以 jar:
開頭的了
3.2.2 打 jar 包後,jar 包內資源為什麼不能讀取了
顯而易見,對於 File
類來說,單個的 jar 檔案,既是一個 File
, 那麼,再通過一個 File
去描述一個檔案內部的 File
是不太合適的。
這有點像壓縮檔案一樣:你不能直接操作壓縮包內的檔案。
那麼,該如何快速方便地讀取 jar 包內我們想要操作的檔案(證書、固定配置)呢?
3.2.3 打 jar 包後,jar 包內資源該怎麼讀取
答案是,用流的形式,只要稍微改寫就可以了,請看如下demo
public class ClassResource{
//以流的形式讀取檔案
public static void readFileByStream(String path) {
System.out.println("[getResourceAsStream] 讀取檔案:" + path);
InputStream inputStream = null;
try {
//獲得jar包內的檔案的流
inputStream = ClassResource.class.getResourceAsStream(path);
read(inputStream);
} catch (IOException e) {
e.printStackTrace();
}
}
//輸出檔案內容
private static void read(InputStream resource) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int i;
while ((i = resource.read()) != -1) {
baos.write(i);
}
System.out.println(new String(baos.toByteArray(), StandardCharsets.UTF_8));
}
}
//jar 啟動類
public class Main {
public static void main(String[] args) {
System.out.println("#############打包後ClassResource開始讀取檔案############");
//注意這裡的檔名,因為仍然是使用 Class.getResourceXxxx(),所以檔名解析路徑方式仍然不變
//跟上述章節保持一致
ClassResource.readFileByStream("/1.txt");
}
}
輸出結果:
可以看到,是能夠正常讀取 jar 內部檔案的內容的
3.2.4 jar 包內資源的其他讀取方法
也可以使用 JarFile
的形式去讀取 jar 包內的資源,這種適合讀取別的 jar 包內的資源,這裡就不再介紹,感興趣的同學可以自行百度。
3.3 SpringBoot JAR 包的檔案載入方式
Spring boot 專案打包後不同於普通的 jar 包目錄結構
執行原有jar讀取方式程式碼
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
readFileByStream("/1.txt");
}
//以流的形式讀取檔案
public static void readFileByStream(String path) {
System.out.println("[getResourceAsStream] 讀取檔案:" + path);
InputStream inputStream = null;
try {
//獲得jar包內的檔案的流
inputStream = ClassResource.class.getResourceAsStream(path);
read(inputStream);
} catch (IOException e) {
e.printStackTrace();
}
}
//輸出檔案內容
private static void read(InputStream resource) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int i;
while ((i = resource.read()) != -1) {
baos.write(i);
}
System.out.println(new String(baos.toByteArray(), StandardCharsets.UTF_8));
}
}
輸出結果:
可以看到,即使目錄結構變了,Springboot jar 包也能正常讀取到檔案內容,這是因為,Spring boot 把如下兩個目錄新增到了 classPath
當中
- BOOT-INF/classes
- BOOT-INF/lib
Spring boot 額外提供了一種新的 jar 包內部的資源讀取方式,即 ClassPathResource
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
//使用SpringBoot的方式讀取資原始檔,這裡不再以 ‘/’ 開頭,類似ClassLoader載入資源的name寫法
ClassPathResource classPathResource = new ClassPathResource("1.txt");
try {
read(classPathResource.getInputStream());
} catch (IOException e) {
e.printStackTrace();
}
}
private static void read(InputStream resource) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int i;
while ((i = resource.read()) != -1) {
baos.write(i);
}
System.out.println(new String(baos.toByteArray(), StandardCharsets.UTF_8));
}
}
執行結果:
四、總結
現今微服務大行其道,讀取專案內的資原始檔也常常在 SpringBoot jar中出現問題,這裡使用 ClassPathResource
和 class.getResourceAsStream()
均可。
但是在企業提供高質量服務的目標下,應當把這些額外讀取資源的需求,遷移到可配置化的環境當中,這樣就能避免因改動配置引起的服務啟停和中斷。
本人才疏學淺,人微技輕,如有不妥之處,請留下寶貴批評指正。