讀取 jar 包中巢狀的 jar 包內容的方法

candyleer發表於2019-02-23

背景

最近在做 javaagent 的時候,我們需要將很多依賴的包打成一個大大的 jar 包,這時候可以用maven-shade-plugin 進行操作,但是如果我們的程式碼不想預設被 AppClassloader 來載入(javaagent 的程式碼預設是由 AppClassloader 來進行載入的),又不想將這些包放在這個大 jar 包的外面,這個時候我們就需要吧這些程式碼以 jar 包的形式放在 resources 裡面,最終 jar 包圖可能是這樣.

── org
    ├── Hello.class
── plugins
    ├── a.jar
    ├── b.jar
    ├── c.jar
    ...
複製程式碼

org 資料夾裡面存放的是我們編譯後的.class 檔案, plugins 存放的是一些需要額外載入的 jar 包,預設情況下,裡面的程式碼是當前 classloader 載入不到的,需要自定義 classloader 來載入.

如何載入

但是如何來載入jar 包裡面的檔案呢?假如外面這層 jar 包的名字為demo.jar. 你可能會自定義一個 classloader, 加入有個 World.class位於 a.jar 中,自定義 classloader 當然需要覆寫 findClass 方法,如何把這個檔案載入到記憶體呢?

思路1

我們都知道對於讀取 jar 包裡面的路徑都有特定的格式,比如讀取 a.jar 的 jarEntry可以這樣讀取

         JarFile jarFile = new JarFile(new File(""));
            Enumeration<JarEntry> entries = jarFile.entries();
            while (entries.hasMoreElements()) {
                JarEntry jarEntry = entries.nextElement();
                String name = jarEntry.getName();
               if("plugins/a.jar".equals(name)){
                .....
                }
          }
複製程式碼

這樣我們可以拿到這個 jar 包對應的 jarEntry, 但是拿到之後好像並不能幹啥,也沒有方法把他當成一個 jar file 繼續獲取裡面的類.所以這種方式暫時不可行

思路2

直接用 URL 獲取路徑,比如獲取 a.jar

  URL url = new URL("jar:file:","",-1,"demo.jar!/plugins/a.jar");
  url.getInputstream();
....
複製程式碼

這樣貌似可以將一個 jar 獲取為一個 inputstream, 但是 a.jar 裡面的類怎麼獲取呢?獲取你會想這樣

 URL url = new URL("jar:file:","",-1,"demo.jar!/plugins/a.jar!/World.class");
url.openConnection();
複製程式碼

但是好像是不行的.

思路3

既然讀取 jar 包裡面的內可以用!/這樣的格式,那麼讀取一層 jar 包應該是沒有問題的,如果我們可以在執行前將 jar 包中的 jar 檔案解壓出來,放在一個目錄,那麼就有辦法讀取其中的內容了,所以我們的思路是:

  • 解壓需要讀取的巢狀 jar 包檔案到一個一個臨時的資料夾,並且每次解壓要唯一
  • 通過臨時資料夾讀取其中的類,載入到類載入器.
  • JVM 退出的時候刪除這個臨時資料夾,避免無謂的儲存消耗.

按照這樣的思路,於是有了下面的方法:

獲取臨時目錄

      if (TEMP_FOLDER == null) {
            synchronized (AgentClassLoader.class) {
                if (TEMP_FOLDER == null) {
                    TEMP_FOLDER = unpackToFolder(jarPath);
                }
            }
        }
複製程式碼
//需要的 jar 包解壓到資料夾
private File unpackToFolder(File jarPath) {
        try {
            File tempFolder = new File(System.getProperty("java.io.tmpdir"));
            File folder = new File(tempFolder, "test-loader-" + UUID.randomUUID());
            File pluginsFolder = new File(folder, "plugins");
            if (!pluginsFolder.mkdirs() || !activationsFolder.mkdirs()) {
                logger.error("cannot makedir temp dir");
                throw new RuntimeException("can not mkdir temp dir");
            }
            folder.deleteOnExit();
            pluginsFolder.deleteOnExit();
            logger.info(" temp folder is {}",folder.getCanonicalPath());
            JarFile jarFile = new JarFile(jarPath);
            Enumeration<JarEntry> entries = jarFile.entries();
            while (entries.hasMoreElements()) {
                JarEntry jarEntry = entries.nextElement();
                String name = jarEntry.getName();
                String[] split = name.split("/");
                if (name.startsWith("plugins/") && split.length > 1) {
                    File file = new File(pluginsFolder, split[1]);
                    unpack(jarFile, jarEntry, file);
                    file.deleteOnExit();
                } 
            }
            return folder;
        } catch (Exception e) {
            logger.error(" unpack to folder error", e);
            throw new RuntimeException(e);
        }
  }

// 解壓 jar 包;
 private static void unpack(JarFile jarFile, JarEntry entry, File file) throws IOException {
        try (InputStream inputStream = jarFile.getInputStream(entry)) {
            try (OutputStream outputStream = new FileOutputStream(file)) {
                byte[] buffer = new byte[BUFFER_SIZE];
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                }
                outputStream.flush();
            }
        }
    }
複製程式碼

其實我們最終獲取到TEMP_FOLDER其他操作都像讀檔案一樣了,關鍵在於如何解壓,這裡面的幾個小細節:

  • file.deleteOnExit(); 的使用,相當於給檔案刪除註冊了一個鉤子,當 JVM 退出的時候,自動回刪除這個檔案,最終被刪除的檔案是儲存在一個佇列裡面的,所以這裡的刪除程式碼順序註冊也是有講究的.
  • 每次建立的資料夾都不一樣,避免汙染環境,讀取的檔案過多或者過少.

直接讀取

雖然不能隨意讀取巢狀jar 包中的內容,但是JarFileEntry 中可以讀取manifest 檔案,我們可以一些需要讀取的放在這個檔案裡面,然後在外面直接讀取.

public synchronized Manifest getManifest() throws IOException {}
複製程式碼

引用

ps: 除了臨時目錄的方法,可能還有更好的方法.

相關文章