在做项目的时候写了以下代码,直接在 IDE 中运行发现是没有问题的,但是打成 jar 包运行就出现了文件没有找到的错误。
// jar:file:/Users/x5456/IdeaProjects/spring-test/target/ars.jar!/BOOT-INF/classes!/application.yml
URL url = SpringTestApplication.class.getResource("/application.yml");
String path = url.getPath();
// jar包执行时抛出空指针异常。
FileInputStream fileInputStream = new FileInputStream(path);
其实仔细想一想就知道是什么原因了,是因为 FileInputStream 无法解析 !/
从而无法读取到 jar 包中的文件,所以之后采用了下面这种写法,代码就可以正常执行了:
URL url = SpringTestApplication.class.getResource("/application.yml");
InputStream inputStream = url.openStream();
那么为什么这种方式就可以正常获取到流呢,它内部的实现和第一种有啥不同吗?我们来探索一下,测试代码如下:
URL url = new URL("jar:file:/Users/x5456/IdeaProjects/spring-test/target/ars.jar!/BOOT-INF/classes/application.yml");
System.out.println(url.openStream().read());
先看一下 handler 是在什么时候赋值的吧:
好,我们继续看下 handler.openConnection() 做了什么吧
接下来看下它的 getInputStream() 方法:
想看的自己看吧,我逃了^v^。
URLStreamHandler 的主要作用就是打开各种各样的资源(http、ftp、file 等)的链接。
Java 中使用 URL 来描述资源,而 URL 有一个方法 URL#openConnection() 用于打开链接。由于 URL 用于表达各种各样的资源,打开资源的具体动作由 java.net.URLStreamHandler 这个类的子类来完成。根据不同的协议,会有不同的 handler 实现。而 JDK 内置了相当多的 handler 实现用于应对不同的协议,比如 jar、file、http 等。
URL 内部有一个静态 HashTable 属性,用于保存已经被发现的协议和 handler 实例的映射。
使用自定义的 URLStreamHandler 有以下两种方法:
注:在 JVM 中对 URLStreamHandler 的子类有固定的规范要求:子类的类名必须是 Handler,同时最后一级的包名必须是协议的名称。比如自定义了 Http 的协议实现,则类名必然为 xx.http.Handler;而且在 JVM 启动的时候,需要设置 java.protocol.handler.pkgs 系统属性,如果有多个实现类,那么中间用|隔开。因为 JVM 在尝试寻找 Handler 时,会从这个属性中获取包名前缀,最终使用包名前缀.协议名.Handler,使用 Class.forName 方法尝试初始化类,如果初始化成功,则会使用该类的实现作为协议实现。
示例:
什么是 Fat Jar?
简单地说就是胖 Jar 呗!哈哈!就是说这个 Jar 所装的东西比一般的 Jar 要多嘛!一般地,我们通过 maven 的插件 maven-jar-plugin 所打包生成的 jar 都是只包含我们项目的源码的,它不包含我们所依赖的第三方库,这样就会导致一个问题,如果我们的第三方库不在 CLASSPATH 下面会怎样?当然是会出现 java.lang.NoClassDefFoundError 的问题咯!因此,我们需要使用一种方式,能使得项目所依赖的第三方库能够随着我们的项目源码一起被打包,这样我们就完全不用担心类找不到的问题啦!这就是 Fat Jar 的来历!
如何打出 Fat Jar?
生成 Fat Jar 我们使用的是 One-Jar 这个 Maven 插件。具体配置代码如下:
<build>
<finalName>fat-jar-example</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>cn.x5456.TestCanRunJar</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>com.jolira</groupId>
<artifactId>onejar-maven-plugin</artifactId>
<version>1.4.4</version>
<executions>
<execution>
<goals>
<goal>one-jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
代码:
public class TestCanRunJar {
public static void main(String[] args) throws ClassNotFoundException {
System.out.println(StrUtil.format("{}是只小傻狗", "哒哒"));
System.out.println(Thread.currentThread().getContextClassLoader());
System.out.println(TestCanRunJar.class.getClassLoader());
System.out.println(TestCanRunJar.class.getClassLoader().getParent());
}
}
输出:
java -jar fat-jar-example.one-jar.jar
哒哒是只小傻狗
com.simontuffs.onejar.JarClassLoader@610455d6
com.simontuffs.onejar.JarClassLoader@610455d6
sun.misc.Launcher$AppClassLoader@42a57993 # 他的父类加载器为加载
通过输出结果我们发现,我们的类加载器好像并不是使用的默认的 AppClassLoader,而是使用的 com.simontuffs.onejar.JarClassLoader
,我们代码中并没有这个类啊,这个类是哪来的呢,我们解压下打包出来的 fat jar,看下它的文件结构:
tree fat-jar-example.one-jar
fat-jar-example.one-jar
├── META-INF
│ └── MANIFEST.MF
├── OneJar.class
├── com
│ └── simontuffs
│ └── onejar
│ ├── Boot$1.class
│ ├── Boot$2.class
│ ├── Boot$3.class
│ ├── Boot.class # 由 MANIFEST.MF 得知启动类是 Boot
│ ├── Handler$1.class
│ ├── Handler.class # 自定义了一个 onejar 协议,实现了 URLStreamHandler#openConnection() 方法,通过这个方法我们可以在获取到 URL 对象的时候拿到他的 inputStream
│ ├── IProperties.class
│ ├── JarClassLoader$1.class
│ ├── JarClassLoader$2.class
│ ├── JarClassLoader$ByteCode.class
│ ├── JarClassLoader$FileURLFactory$1.class
│ ├── JarClassLoader$FileURLFactory.class
│ ├── JarClassLoader$IURLFactory.class
│ ├── JarClassLoader$OneJarURLFactory.class
│ ├── JarClassLoader.class # 自定义的类加载器,用于加载 lib 和 main 目录下的 jar 包
│ ├── OneJarFile$1.class
│ ├── OneJarFile$2.class
│ ├── OneJarFile.class
│ └── OneJarURLConnection.class
├── lib
│ └── hutool-all-4.6.1.jar # 项目依赖的 jar 包
└── main
└── fat-jar-example.jar # 打出的可执行 jar
感兴趣的可以自行看一下源码,因为没有办法运行,我也不知道我说的对不对,反正就是启动的时候,Boot 调用 JarClassLoader 的 load 方法,将需要加载的类和 jar 包加载到内存中(byte[]),当用到的时候从中查找并 defineClass。
上篇文章我们说了为啥 MANIFEST.MF 文件中的 Main-Class 是 XXXLauncher,我们今天就介绍一下 JarLauncher 吧。为了看这部分代码,我们需要引入spring-boot-loader
的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
</dependency>
SpringBoot 抽象了 Archive 的概念,一个 Archive 可以是 jar(JarFileArchive),可以是一个文件目录(ExplodedArchive),可以抽象为统一访问资源的逻辑层。
Spring Boot 中 Archive 的源码如下:
public interface Archive extends Iterable<Archive.Entry>, AutoCloseable {
/**
* 获取该归档的url
*/
URL getUrl() throws MalformedURLException;
/**
* 获取jar!/META-INF/MANIFEST.MF或[ArchiveDir]/META-INF/MANIFEST.MF
*/
Manifest getManifest() throws IOException;
/**
* 获取嵌套档案,即 jar!/BOOT-INF/lib/*.jar或[ArchiveDir]/BOOT-INF/lib/*.jar
*/
List<Archive> getNestedArchives(EntryFilter filter) throws IOException;
/**
* Closes the {@code Archive}, releasing any open resources.
* @throws Exception if an error occurs during close processing
* @since 2.2.0
*/
@Override
default void close() throws Exception {
}
/**
* 代表档案(jar包或文件夹)中的资源文件
*/
interface Entry {
/**
* Returns {@code true} if the entry represents a directory.
* @return if the entry is a directory
*/
boolean isDirectory();
/**
* Returns the name of the entry.
* @return the name of the entry
*/
String getName();
}
/**
* Strategy interface to filter {@link Entry Entries}.
*/
interface EntryFilter {
/**
* Apply the jar entry filter.
* @param entry the entry to filter
* @return {@code true} if the filter matches
*/
boolean matches(Entry entry);
}
}
该接口有两个实现,分别是 org.springframework.boot.loader.archive.ExplodedArchive 和 org.springframework.boot.loader.archive.JarFileArchive。前者用于在文件夹目录下寻找资源,后者用于在 jar 包环境下寻找资源。
对 jar 包的封装,每个 JarFileArchive 都会对应一个 JarFile。JarFile 被构造的时候会解析内部结构,去获取 jar 包里的各个文件或文件夹,这些文件或文件夹会被封装到 Entry 中。
这个 JarFile 有很多 Entry,比如:
META-INF/
META-INF/MANIFEST.MF
spring/
spring/study/
....
spring/study/executablejar/ExecutableJarApplication.class
lib/spring-boot-starter-1.3.5.RELEASE.jar
lib/spring-boot-1.3.5.RELEASE.jar
...
其他(我表示没看懂):附录 D.2. Spring Boot 的"JarFile"类
类图
先看下它父类的构造
public abstract class ExecutableArchiveLauncher extends Launcher {
private final Archive archive;
public ExecutableArchiveLauncher() {
try {
// 主要就是将当前类所在的 jar 或者路径,创建一个 Archive 对象
this.archive = createArchive();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
...
}
public abstract class Launcher {
...
protected final Archive createArchive() throws Exception {
ProtectionDomain protectionDomain = getClass().getProtectionDomain();
CodeSource codeSource = protectionDomain.getCodeSource();
URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
String path = (location != null) ? location.getSchemeSpecificPart() : null;
if (path == null) {
throw new IllegalStateException("Unable to determine code source archive");
}
File root = new File(path);
if (!root.exists()) {
throw new IllegalStateException("Unable to determine code source archive from " + root);
}
// 根据代码所在路径封装成不同的档案类型,毕竟有两种运行方式嘛:
// java org/springframework/boot/loader/JarLauncher path -> /Users/x5456/IdeaProjects/canRunJar/target/classes/
// java -jar ars.jar path -> /Users/x5456/IdeaProjects/canRunJar/target/jarstarter.jar
return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
}
...
}
再看下 Launcher#launch() 方法
public abstract class Launcher {
/**
* 启动应用程序。 此方法是子类public static void main(String[] args)方法应调用的初始入口点。
* @param args the incoming arguments
* @throws Exception if the application fails to launch
*/
protected void launch(String[] args) throws Exception {
// 1)扩展 Jar 协议
JarFile.registerUrlProtocolHandler();
// 2)创建一个新的 ClassLoader
ClassLoader classLoader = createClassLoader(getClassPathArchives());
// 3)将新的 ClassLoader 放入线程上下文中,并执行 MANIFEST.MF 文件里面 Statr-Class 属性指定类的 main 方法
launch(args, getMainClass(), classLoader);
}
...
为什么需要扩展 jar 协议呢?
jar:file:/Users/x5456/IdeaProjects/spring-test/target/ars.jar!/BOOT-INF/lib/canRunJar-1.0-SNAPSHOT.jar!/aaa.txt
其中有两个!/
,而默认的 jar 协议对应的 Handler 无法通过这样的路径打开链接,所以需要我们对其进行扩展,从而实现对 jar in jar 中**资源(包括字节码和资源文件)**的加载。
为什么要创建新的 ClassLoader 呢?
!/
的路径当做类加载路径吧)对于 Java 标准的 jar 文件来说,规定在一个 jar 文件中,我们必须要将指定 main.class 的类直接放置在文件的顶层目录中(也就是说,它不予许被嵌套),否则将无法加载,对于 BOOT-INF/class/路径下的 class 因为不在顶层目录,因此也是无法直接进行加载, 而对于 BOOT-INF/lib/ 路径的 jar 属于嵌套的(Fatjar),也是不能直接加载,因此 Spring 要想启动加载,就需要自定义实现自己的类加载器去加载。
源码解析:
注:此处生成的 Archive 集合包含:一个 BOOT-INF/classes 目录的 Archive(classes 目录是一个 Archive) 和 BOOT-INF/lib 目录下 jar 包的 Archive(每个 jar 包是一个 Archive)。
System.getproperty("java.io.tmpdir") 是获取操作系统缓存的临时目录,不同操作系统的缓存临时目录不一样:
Windows 的缓存目录为:C:\Users\登录用户~1\AppData\Local\Temp\
Linux:/tmp
Mac:/var/folders/28/1tyh6prj3xg6xcdx_3qlkwr80000gn/T/
但我为啥觉得他只能处理 3 层嵌套,再多了就不行了,不往下看了,不知道多层嵌套 jar 的这种情况是怎么出现的。
当前类的类加载器为 AppClassLoader。
参见 LaunchedURLClassLoader
LaunchedURLClassLoader 继承了 URLClassLoader 主要重写了他的 loaderClass 方法,我们先看下它的构造:
再看下它的 loaderClass 方法:
loaderClass() 方法之前已经分析过了,主要做了双亲委派,其中与当前类加载器相关的方法时 findClass() 方法:
由于源码比较深,直接给大家看结果:
如果有 jar 包中包含 jar,或者 jar 包中包含 jar 包里面的 class 文件,那么会使用 !/
分隔开,这种方式只有 org.springframework.boot.loader.jar.Handler 能处理,它是 SpringBoot 内部扩展出来的一种 URL 协议。
各个组件的作用:
Jar/WarLaucher 定义扫描 jar 包和字节码的类路径
扩展了 java.util.jar.JarFile、URLStreamHandler、java.net.JarURLConnection 实现了 jar in jar 中资源的加载。
通过自定义类加载器 LauncherURLClassLoader,实现了 jar in jar 中 class 文件的加载。
JarFileArchive 相当于 JarFile 的适配器,适配了 Archive 接口,当然他内部逻辑也有很多。
本文是采用this.getClass().getResource("/")
获取的类路径地址,得到的是带!/
的路径,那么采用request.getServletContext().getRealPath("/");
获取到的地址是什么样的呢,经过测试发现,他会在使用 java -jar 命令的主机上新建一个缓存路径,在我的 mac 上路径就是这样:
# tomcat-docbase 前面的路径是 System.getProperty(java.io.tempdir)
/private/var/folders/28/1tyh6prj3xg6xcdx_3qlkwr80000gn/T/tomcat-docbase.2007207725651733988.8080
但是如果在这里保存了文件,重启的时候就找不到其中的文件了,因为重启后生成的缓存文件夹不一样。
当新建了 webapp 目录,jar 包启动的时候,他会指向 webapp 在主机上的路径,但是换了台主机,他依然会指向缓存文件夹
SpringBoot(二) 启动分析 JarLauncher 图不错
SpringBoot 源码分析之 SpringBoot 可执行文件解析 版本是 1.x 的