SpringBoot的main函数运行之前都发生了什么

前言

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,同时也是所有其他启动形式的顶层父类。

SpringBoot的main函数运行之前都发生了什么_第1张图片
image.png

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文件,后续我们分析的过程中再继续看。

  1. 获取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 getClassPathArchives() throws Exception {
    // 获取嵌套的文档文件,
    List 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.archiveJarFileArchive的实例。所以,获取jar中的archive逻辑就是在这个类中实现的。

ExplodedArchive的实现会用于warproperties的启动形式的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 getNestedArchives(EntryFilter filter) throws IOException {
    List 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,其类图如下 :

SpringBoot的main函数运行之前都发生了什么_第2张图片
image.png

首先是其实现了迭代接口,用于jar中entry的迭代遍历。第二个比较重要的就是实现了中央目录的Visitor,这个是核心。借助于CentralDirectoryParser类,在RandomAccessDataloader.dat下的类,辅助数据读取)的帮助下,解析并遍历了整个JarFile中的文件,然后JarFileEntry作为visitor被setCentralDirectoryParser中,也完成了整个JarFile中的文件的遍历,并将其缓存在entriesCache中。entriesCache是一个被同步的synchronizedMap包裹的LinkedHashMap。上文提到的EntryIterator迭代数据其实就来自于这里的map缓存的数据。

所以,loader的是如何解析jar中jar呢,还得继续往前看,搞明白RandomAccessDataCentralDirectoryParser后,也许这次就真的弄明白了jar中jar的解析的代码。

事实上这里其实才是整个loader项目中代码量最大的地方。因为JarFile牵扯到整个jar路径和data路径的所有类。其互相配合,相互调用,虽然看起来清晰,但是要说明白还是要花点时间,这周先到这里,下周继续填坑。

未完待续。。。

你可能感兴趣的:(SpringBoot的main函数运行之前都发生了什么)