前言
SpringBoot项目通常都是由主类的main函数开始启动,好奇心驱使我想搞明白通常项目所有的内容都被打成了一个fat jar,按理说jar包中再包含的jar是没有办法被jdk加载的,所以这个过程SpringBoot又是如何让单个jar直接运行起来的?
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
spring-boot-loader
通过解压SpringBoot的maven插件二次打包的jar,可以看到目录如下:
-
BOOT-INF/classes
下是spring-boot项目中编写的java源码编译后的class -
BOOT-INF/lib
下是spring-boot项目依赖的所有jar包 -
META-INF
是jar的信息,包含主类和sring-boot添加的额外的信息记录
-org.springgramework
下则是maven插件装载进去的class文件,也就是fat jar可以运行起来的源码
app
├── BOOT-INF
│ ├── classes
│ └── lib
├── META-INF
│ ├── MANIFEST.MF
│ └── maven
└── org
└── springframework
当然,直接看org.springframework
下反编译的源码有点晦涩,毕竟是反编译而来的。查看spring boot的源码可以在spring-boot-tools项目下看到spring-boot-loader子项目,这个其实就是maven插件装载到fat jar中的class文件的源码,所以阅读这个子项目的源码基本就可以搞清楚,spring boot的fat jar是如何把自己跑起来的。
SpringBoot项目的启动方式
1. idea中的启动
通常在IDEA中默认的启动方式是直接通过主类启动,所有依赖的jar都通过jdk的参数添加进来。很明显,这种启动方式没有借助于spring-boot-loader,是正常的java程序运行方式启动。
这种启动方式经常用于开发,毕竟直接启动更快一些。但是也有弊端,那就是通过command line的形式启动时,如果依赖的jar过多,会导致拼接的命令过长而报错,所以此中方式通常没有办法用于中大型项目
除了在idea中借助于开发工具拼接运行命令之外,spring boot支持三种常见的启动方式。
- jar
- war
- properties
2. jar
jar方式就是借助于spring boot的maven插件二次打包后的fat jar的形式启动。对应spring-boot-loader项目中的JarLauncher类,源码如下(源码中的注释,部分翻译,部分为我自己添加,帮助阅读):
/**
* 用于基于JAR形式的启动,该jar依赖的所有的其他jar包在/BOOT-INF/lib路径下
* 该jar对应的spring boot的项目的java类全部位于/BOOT-INF/classes下
*/
public class JarLauncher extends ExecutableArchiveLauncher {
// 依赖的class文件的路径
static final String BOOT_INF_CLASSES = "BOOT-INF/classes/";
// 依赖的其他jar文件的路径
static final String BOOT_INF_LIB = "BOOT-INF/lib/";
public JarLauncher() {
}
protected JarLauncher(Archive archive) {
super(archive);
}
// 判断entry是否为内嵌依赖jar的,判断的依据是名称
@Override
protected boolean isNestedArchive(Archive.Entry entry) {
if (entry.isDirectory()) {
return entry.getName().equals(BOOT_INF_CLASSES);
}
return entry.getName().startsWith(BOOT_INF_LIB);
}
// jar形式的main-class
public static void main(String[] args) throws Exception {
new JarLauncher().launch(args);
}
}
由于jar形式的启动是最常见的方式,所以本文会着重jar形式启动的分析。
3. war包形式
在spring boot之前,大多数的spring mvc项目都是打成war包,置于tomcat的webapp目录下来运行,所以springboot也是支持这种形式的启动,只要在spring boot的maven插件中将打包的目前格式改为WAR即可。在loader项目中对应的启动类为:WarLauncher
/**
* 注释翻译:用于war包形式的启动,只支持标准的WAR归档文件。
* 三方依赖的jar位于 WEB-INF/lib, 也可以为WEB-INF/lib-provided,
* 项目的class文件位于WEB-INF/classes路径下。
*/
public class WarLauncher extends ExecutableArchiveLauncher {
private static final String WEB_INF = "WEB-INF/";
private static final String WEB_INF_CLASSES = WEB_INF + "classes/";
private static final String WEB_INF_LIB = WEB_INF + "lib/";
private static final String WEB_INF_LIB_PROVIDED = WEB_INF + "lib-provided/";
// 部分代码删除。。。
// 启动主类
public static void main(String[] args) throws Exception {
new WarLauncher().launch(args);
}
}
4. 基于配置属性的形式启动
基于自定义配置的启动方式,兼容Fat JAR。这种启动方式就比较灵活,可以通过三方插件将项目打成多种格式,或者不二次打包等等,最后通过配置解析来启动spring boot项目。
比如,可以将依赖,配置文件,启动类打包到指定目录,然后按照如下方式启动:
java -Dloader.main=xxx.xxx.Application \ # 主类
-Dloader.path=lib,config,resource,xxx.jar \ 依赖和配置资源
-Dspring.profiles.active=dev \ // profiles
org.springframework.boot.loader.PropertiesLauncher // 启动类
所以Spring Boot的loader项目,就是提供spring boot应用可以在不同场景和需求下都可以正常启动的能力,完成了从打包和实际项目运行的桥接过程。
接下来,我们以JAR启动的方式,来分析分析,Spring boot到底是如何完成启动过程的:
可执行Jar启动
jar形式的启动,主类为JarLauncher
,其继承自ExecutableArchiveLauncher
,最上层的父类为Launcher
,同时也是所有其他启动形式的顶层父类。
JarLauncher中代码不多,直接调用了Launcher中的launch方法,所以我们的代码分析也从这里开始。
launch方法了主要干了三件事情,
- 第一是注册扩展protocol handler
- 第二是获取fat jar中所有的归档(三方jar,class,资源文件等等)来创建自定义的类加载器(ClassLoader)
- 最后使用创建好的类加载器,携带启动参数,创建主类启动对象,启动主类(主类在loader中为Start Class,其实就是spring boot应用的启动类,在spring的maven插件中被打包定义为Start class)
protected void launch(String[] args) throws Exception {
// 注册URL protocol handler
JarFile.registerUrlProtocolHandler();
// 获取fat jar中的archives(也就是三方jar,class,以及其他资源文件),创建类加载器
ClassLoader classLoader = createClassLoader(getClassPathArchives());
// 获取sub class(也就是spring boot 应用的主类),使用启动参数和创建好的class loader启动
launch(args, getMainClass(), classLoader);
}
接下来,我们针对这三个步骤展开来讲。
1注册扩展UrlProtocolHandler
其实这个方法相当于在启动java应用时添加参数:-Djava.protocol.handler.pkgs=xxx.xxx.xxx
,其作用就是对Url类支持的协议进行扩展。多个指定的包的地址使用|
来连接。
/**
* 翻译:注册一个handler,以便定位URLStreamHandler来处理jar urls
* Register a {@literal 'java.protocol.handler.pkgs'} property so that a
* {@link URLStreamHandler} will be located to deal with jar URLs.
*/
public static void registerUrlProtocolHandler() {
// 获取当前jvm中的handler参数
String handlers = System.getProperty(PROTOCOL_HANDLER, "");
// 如果已有参数为空,则直接指定springboot的handler,否则|拼接进行扩展
System.setProperty(PROTOCOL_HANDLER,
("".equals(handlers) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE));
// 最后重置缓存的handler
resetCachedUrlHandlers();
}
那Spring boot扩展这个来干嘛呢,方法注释上说时为了定位URLStreamHandler
来处理jar文件,后续我们分析的过程中再继续看。
- 获取ClassPath下的文件资源
createClassLoader(getClassPathArchives());
虽然第二个步骤只有一句话,但这其实就是SpringBoot可以直接启动jar文件的核心逻辑,所以展开来讲,首先是获取jar中的资源文件。
getClassPathArchives
在Launcher中是abstract的,其具体实现在ExecutableArchiveLauncher
中。getClassPathArchives
的实现其实代码不多,核心方法是getNestedArchives
(获取嵌套的jar等文件)。看到这里其实我们就能稍微理解为什么Spring Boot能够直接启动并直接嵌套自身jar中的其他jar了,其逻辑就是通过某种方式解析并获取jar(猜测是作为普通资源文件获取,然后读内存或者写到其他目录,再加载进来,不过因为我已经读过了,所以猜测其实是对的,哈哈哈)然后传递给自定义的classLoader
加载,从而完成了依赖的jar的加载。
/**
* 获取class path下的文件,jar启动方式其实主要是获取嵌套在fat jar中的其他三方jar
*/
@Override
protected List<Archive> getClassPathArchives() throws Exception {
// 获取嵌套的文档文件,
List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive));
// 后置处理归档文件
postProcessClassPathArchives(archives);
// 返回结果
return archives;
}
看着这里其实有点疑惑,this.archive
是啥,之前没有提到过。JarLauncher
刚刚是在main方法中无参new的,所以就是隐含的执行ExecutableArchiveLauncher
的无参的构造方法,这个archive
就是在那个时候实例化的。
public ExecutableArchiveLauncher() {
try {
this.archive = createArchive();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
ok,所以这个时候需要搁置刚才的逻辑,先看看这个archive
是什么东西,才能接着看它是如何获取jar中的archives的。
createArchive
方法主要有两个逻辑,首先是获取当前类对应的绝对路径。接着判断,如果绝对路径对应的是目录,则archive
就是ExplodedArchive
,当前我们假设是用jar启动的,那绝对路径对应的就是jar文件本省,此时this.archive
就会被实例化成JarFileArchive
。
看到这里就清楚了,this.archive
是JarFileArchive
的实例。所以,获取jar中的archive逻辑就是在这个类中实现的。
ExplodedArchive
的实现会用于war
和properties
的启动形式的archives的获取。
/**
* 创建Archive
*/
protected final Archive createArchive() throws Exception {
// 反射获取当前jar的 protectionDomain,可以理解为一个jar会对应一个ProtectionDomain,主要是jar中资源的权限检查和控制
ProtectionDomain protectionDomain = getClass().getProtectionDomain();
// 然后获取当前类的codeSource
CodeSource codeSource = protectionDomain.getCodeSource();
// 最后获取当前类的路径URL,再拿到path
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");
}
// 有了path,就可以将其包装为java的抽象文件类
File root = new File(path);
if (!root.exists()) {
throw new IllegalStateException("Unable to determine code source archive from " + root);
}
// 最后如果是目录,archive就会被实例化成ExplodedArchive, 如果是Jar形式启动,那就是非目录,所以实例化成JarFileArchive
return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
}
到这里,就到最重要的逻辑:解析fat jar中的资源文件,包括三方jar,class文件,资源文件等等。
首先看入口方法,方法的逻辑很简单。首先是迭代自身,获取entry,第二是包装entry为Archive
,最后返回。所以对应的搞清楚这两个逻辑,就能理解jar中的资源文件是如何解析的。
/**
* 获取嵌套的archives
* @param filter the filter used to limit entries
*/
@Override
public List<Archive> getNestedArchives(EntryFilter filter) throws IOException {
List<Archive> nestedArchives = new ArrayList<>();
// 迭代自己本身,通过外部传递的isNestedArchive,JarLauncher中实现了,通过class的前缀判断
for (Entry entry : this) {
if (filter.matches(entry)) {
// 通过entry,获取并包装为Archive
nestedArchives.add(getNestedArchive(entry));
}
}
// 包装为不可变集合返回
return Collections.unmodifiableList(nestedArchives);
}
EntryIterator
首先是自身的迭代器,通过内部类EntryIterator
来实现,这里的逻辑很简单不赘述。核心就一句话,entries
都是通过this.jarFile
获取的。所以核心逻辑就在JarFile
类中 。
JarFile
基础jdk的JarFile
进行扩展的子类,类注释上解释说扩展的功能有两点。
- 获取嵌套的jar中的任一目录下的文件
- 获取嵌套的jar中的jar文件
finally,到了解析自身jar最核心的逻辑了。看完JarFile类就能明白~!
JarFileEntry
JarFile中有一个很重要的类: JarFileEntry
,其类图如下 :
首先是其实现了迭代接口,用于jar中entry的迭代遍历。第二个比较重要的就是实现了中央目录的Visitor
,这个是核心。借助于CentralDirectoryParser
类,在RandomAccessData
(loader.dat
下的类,辅助数据读取)的帮助下,解析并遍历了整个JarFile
中的文件,然后JarFileEntry
作为visitor被set
到CentralDirectoryParser
中,也完成了整个JarFile中的文件的遍历,并将其缓存在entriesCache
中。entriesCache
是一个被同步的synchronizedMap
包裹的LinkedHashMap
。上文提到的EntryIterator
迭代数据其实就来自于这里的map
缓存的数据。
所以,loader的是如何解析jar中jar呢,还得继续往前看,搞明白RandomAccessData
和CentralDirectoryParser
后,也许这次就真的弄明白了jar中jar的解析的代码。
事实上这里其实才是整个loader项目中代码量最大的地方。因为JarFile牵扯到整个jar路径和data路径的所有类。其互相配合,相互调用,虽然看起来清晰,但是要说明白还是要花点时间,这周先到这里,下周继续填坑。