与传统的 web 应用服务部署不同,Spring Boot 提供了 java -jar 这种方式的一键部署,不再需要单独部署 tomcat 实例,使得部署变得相当简单。这背后究竟是如何实现的呢?要想分析这个问题,我们需要先了解 Spring Boot 打包后的 jar 文件,究竟是什么样子的。
1. 目录结构分析
我们在 IDEA 中新建一个 Spring Boot 工程,叫 spring-boot-demo,打包之后,得到一个 jar 文件:spring-boot-demo-0.0.1-SNAPSHOT.jar,我们用 unzip 命令解压该 jar 包后,能够看到对应的目录结构,如下图所示:
$ tree -L 3
.
├── BOOT-INF
│ ├── classes
│ │ ├── application.properties
│ │ ├── cn
│ │ ├── static
│ │ └── templates
│ └── lib
│ ├── spring-core-5.2.2.RELEASE.jar
│ ├── spring-webmvc-5.2.2.RELEASE.jar
│ ├── ...// 这里略了大量jar包
├── META-INF
│ └── MANIFEST.MF
└── org
└── springframework
└── boot
我们注意到,工程中的源代码部分编译完成后会进入 BOOT-INF/classes 文件夹下,工程的依赖会进入 BOOT-INF/lib 目录下。除此之外还有一个 META-INF 与 org/springframework/boot/..的文件夹。看到这个,不禁就有疑问,这两个文件夹是干嘛用的?
我们先来看 META-INF 目录,这个目录下就只有一个 MANIFEST.MF 文件,我们看下里面的内容:
$ cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: cn.xiajl.springbootdemo.SpringBootDemoApplication
Spring-Boot-Version: 2.2.2.RELEASE
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
我们可以查询下中 oracle 对该文件的定义,见参考资料 1。我们会发现,只有 Manifest-Version、Main-Class 是 oracle 定义的,Manifest-Version 表示 jar 包的版本号,Main-Class 表示 jar 启动时的启动类。其它的 Start-Class、Spring-Boot-Version、Spring-Boot-Classes、Spring-Boot-Lib 都不在 MANIFEST.MF 的规范里,换句话说,这是 SpringBoot 自己定义的。
这里有个问题,既然 Main-Class 是启动类,那么 Main-Class 为什么是 org.springframework.boot.loader.JarLauncher,而不是 cn.xiajl.springbootdemo.SpringBootDemoApplication?
我们可以尝试把 Main-Class 改成工程中的 SpringBootDemoApplication 类试试,打包回去,尝试看看能不能启动:
$ vi META-INF/MANIFEST.MF
$ cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
Start-Class: org.springframework.boot.loader.JarLauncher
Main-Class: cn.xiajl.springbootdemo.SpringBootDemoApplication
Spring-Boot-Version: 2.2.2.RELEASE
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
$ jar -cvf0M rarpack.jar *
...//略去输出
$ java -jar rarpack.jar
错误: 找不到或无法加载主类 cn.xiajl.springbootdemo.SpringBootDemoApplication
原因: java.lang.ClassNotFoundException: cn.xiajl.springbootdemo.SpringBootDemoApplication
很明显启动不了。那看到这里,很自然的就能想到,这个 JarLauncher 是不是要帮我们解决类路径的问题?jar 启动的时候,按照 oracle 的规范似乎要手工指定 classpath,并且也没有办法定位到 jar 包中的 BOOT-INF/BOOT-Classes 路径,只会加载 jar 包下直接解决的 class 文件。
2. JarLauncher 分析
当思考到这里的时候,我们像往常一样,在 Idea 里面搜 JarLauncher 这个类,试图进去看看这个类的源码,你会发现找不到。为什么呢?因为这个是插件导进去的,对于 gradle 编译的工程,是在 bootJar 阶段,将 spring-boot-loader 中类直接解压拷贝到 jar 包中的。那我们怎么才能看到源码呢?直接在依赖里加上 spring-boot-loader 即可:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
// 仅用于研究spring-boot-loader,实际工程中不需要该依赖
implementation group: 'org.springframework.boot', name: 'spring-boot-loader', version: '2.2.2.RELEASE'
}
打开 JarLancher 的源码,我们会看一下类的说明:
Launcher for JAR based archives. This launcher assumes that dependency jars are included inside a /BOOT-INF/lib directory and that application classes are included inside a /BOOT-INF/classes directory.
文档很明确地说明了,它是来帮我们加载/BOOT-INF/lib 下的 jar 包与/BOOT-INF/classes 下的 classes 文件。
那它到底是怎么帮我们加载的呢?
我们跟进源码,JarLancher 中存在如下 main 方法:
public static void main(String[] args) throws Exception {
new JarLauncher().launch(args);
}
我们来看看 launch 是怎么实现的:
/**
* Launch the application. This method is the initial entry point that should be
* called by a subclass {@code public static void main(String[] args)} method.
* @param args the incoming arguments
* @throws Exception if the application fails to launch
*/
protected void launch(String[] args) throws Exception {
JarFile.registerUrlProtocolHandler();
ClassLoader classLoader = createClassLoader(getClassPathArchives());
launch(args, getMainClass(), classLoader);
}
/**
* Create a classloader for the specified URLs.
* @param urls the URLs
* @return the classloader
* @throws Exception if the classloader cannot be created
*/
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
return new LaunchedURLClassLoader(urls, getClass().getClassLoader());
}
/**
* Launch the application given the archive file and a fully configured classloader.
* @param args the incoming arguments
* @param mainClass the main class to run
* @param classLoader the classloader
* @throws Exception if the launch fails
*/
protected void launch(String[] args, String mainClass, ClassLoader classLoader) throws Exception {
Thread.currentThread().setContextClassLoader(classLoader);
createMainMethodRunner(mainClass, args, classLoader).run();
}
代码一目了然,Spring Boot 会为我们创建一个自定义类加载器 LaunchedURLClassLoader,并将其设置为线程上下文类加载器。应用执行过程
中,会从 LaunchedURLClassLoader 进行类加载,这个类加载过程会遵循双亲委派机制,对于父类无法加载的类,则由 LaunchedURLClassLoader 进行加载,LaunchedURLClassLoader 加载的路径就是 BOOT-INF/lib 和 BOOT-INF/classes。
3. 为什么需要 BOOT-INF/lib 与 BOOT-INF/classes?
执行逻辑我们搞清楚了,我们还需要了解一下为什么 Spring Boot 不按照传统的方式,即像 Spring Boot Loader 中的类一样,将所有的依赖解压放到 jar 中呢?是出于什么考虑?
这里其实是有很多工程上的考量,我这边里总结一下:
- 不同组件之前也有可能存在重名的情况,同样的组件不同版本之间也是不能兼容的。在解压时,无法预测知道重名的那些文件,哪个组件的哪个版本会被保留下来。
- SPI 机制规范产生的配置文件很多时候是同名的,比如 spring.factories(如下图所示),这种配置文件是不能覆盖的,必须保留多份。
因此,Spring Boot 创新性的采用自定义类加载路径的方式来进行。生成的这种带依赖的 jar 也有一个专有的名词,叫 FatJar/UberJar,如下图所示(来源:参考资料 2):
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于