背景
最近在做 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: 除了臨時目錄的方法,可能還有更好的方法.