在做项目的时候写了以下代码,直接在 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 的