1. 程式人生 > 其它 >jar、war 和 SpringBoot 載入包內外資源的方式總結,你再也不會出現FileNotFoundException了

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 檔案

執行結果如下:

結果分析:

  1. 空字串

    定位為當前類路徑

  2. /

    定位為 classPath 路徑

  3. 當前類檔名

    定位為當前類檔案所在路徑,成功定位到檔案

  4. / + 當前類檔名

    定位不到檔案

  5. / + 專案 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

執行結果如下:

結果分析:

  1. 空字串

    定位為 classPath 路徑

  2. /

    定位不到

  3. 當前類檔名

    定位不到

  4. / + 當前類檔名

    定位不到

  5. resources目錄下的 1.txt 檔名

    定位為 resoures/1.txt,因為編譯後 resources 目錄裡的檔案都移動到了 classPath 路徑下,成功定位

  6. / + 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;
    }
}

可以看到:

  1. 如果不以 / 開頭,就返回 當前類所在目錄 + 資源名
  2. 否則返回 / 後面的字串
  3. 總結就是該方法把相對路徑轉換為了基於 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專案時,載入的方式變了,主要有

  1. classPath 路徑由 file 目錄變成了 jar檔案,這影響到資源的定位方式,而且不再支援獲取當前 classPath 路徑
  2. URLClassPath 載入資源時候由 FileLoader 變成了 JarLoader,這影響到資源對特殊符號的處理方式
  3. 定位內部檔案的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()

  1. 空字串仍代表當前類路徑,以非 / 開頭的資源名稱都會從當前類路徑下找起。記得沒有打包時候的規則嗎,沒錯,這裡的空格又被轉換成了當前類路徑,然後呼叫的 classLoader.getResource() 方法
  2. / 仍代表應用 classPath 路徑,以 / 開頭的資源名稱都會從應用 classPath 路徑下找起,找資源名為 / 後面的字元的資源。但是如果只傳 / 則定位不到,不能輸出 classPath 路徑

classLoader.getResource()

  1. 還是不接受 / 開頭的資源名稱,所有 / 開頭的資源名稱都會返回為null,定位不到
  2. 空字串原本代表 classPath 路徑,這裡不再支援
  3. / 開頭的資源從應用 classPath 路徑下找起

其他

  1. classPath 由資料夾變成了jar檔案
  2. URL協議由 file: 變成了 jar:file:

三、檔案載入

上一章節,我們已經知道了 classclassLoader 定位資源的異同,和在打成 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,其 parentExtClassLoader,而 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中出現問題,這裡使用 ClassPathResourceclass.getResourceAsStream()均可。

但是在企業提供高質量服務的目標下,應當把這些額外讀取資源的需求,遷移到可配置化的環境當中,這樣就能避免因改動配置引起的服務啟停和中斷。

本人才疏學淺,人微技輕,如有不妥之處,請留下寶貴批評指正。