背景
最近在做 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: 除了临时目录的方法,可能还有更好的方法.