SpringBoot源码解读——打包启动器原理

SpringBoot提供了非常多的非常好用的特性,比如内置容器,一键运行,注解驱动,内置监控等等。其中一个非常吸引人也非常便捷的特性就是一键启动:即将应用打包成一个可执行的JAR,并直接启动运行

很多初学者都会比较困惑,SpringBoot是如何做到将应用代码和所有的依赖打包成一个独立的JAR并运行的,因为传统的开发方式如果要将应用打包成独立的JAR并通过java -jar命令的话,需要通过-classpath属性来指定依赖。我们今天就来分析讲解一下SpringBoot的打包、启动及运行的原理;

1. SpringBoot打包插件

我们先看一下SpringBoot打包后的结构是什么样的,打开target目录我们发现有两个jar包:

  1. boot2-example-0.0.1-SNAPSHOT.jar:46.6MB
  2. boot2-example-0.0.1-SNAPSHOT.jar.original:52KB

其中,boot2-example-0.0.1-SNAPSHOT.jar是通过SpringBoot提供的打包插件采用新的格式打成一体化的Jar,包含了所有的依赖,所以比较大,有46.6MB;而boot2-example-0.0.1-SNAPSHOT.jar.original则是Java原生的打包方式生成的,仅仅只包含了项目本身的内容,没有依赖,所以比较小,52KB。

简单的从大小来看也知道52KB的原生Jar包是不可能独立的运行的,想要通过这个Jar包运行的话,必须要通过-classpath来指定所需要的依赖路径;

而 SpringBoot 插件打出来的包就可以独立运行,因为它将所依赖的包也一同包括进来了,通过一定的组织结构和定制的 ClassLoader 实现了 Jar in Jar 的加载与执行;

我们通过Archetype或者通过http://start.spring.io新建一个SpringBoot应用的时候,在pom.xml中我们都会看到SpringBoot内置的Maven打包插件,如所示(这是完整的插件配置,并不在项目的pom.xml,实际上它定义在spring-boot-starter-parent中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<mainClass>${start-class}</mainClass>
</configuration>
</plugin>

感兴趣的同学可以去翻阅一下这个插件打包的源码,了解是怎么组织最终的代码结构的,这里我们只需要了解到其独特的包结构是通过这个插件打包出来的就行,我们重点分析最终打好的包,并分析SpringBoot是怎么运行起来的;

2. SpringBoot FatJar 的组织结构

我们将SpringBoot打的可执行Jar展开后的结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
.
├── BOOT-INF
│ ├── classes
│ │ ├── application.yml
│ │ ├── com
│ │ │ └── doleje
│ │ │ └── boot2
│ │ │ └── example
│ │ └── i18n
│ │ ├── message.properties
│ │ ├── message_en_US.properties
│ │ └── message_zh_CN.properties
│ └── lib
│ ├── spring-boot-2.1.0.RELEASE.jar
│ ├── spring-boot-autoconfigure-2.1.0.RELEASE.jar
│ ├── spring-boot-configuration-processor-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-aop-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-cache-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-data-jpa-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-jdbc-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-json-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-logging-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-security-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-tomcat-2.1.0.RELEASE.jar
│ ├── spring-boot-starter-web-2.1.0.RELEASE.jar
│ ├── ...
├── META-INF
│ ├── MANIFEST.MF
│ ├── maven
│ │ └── com.dole.framework
│ │ └── boot2-example
│ │ ├── pom.properties
│ │ └── pom.xml
│ └── spring-configuration-metadata.json
├── org
│ └── springframework
│ └── boot
│ └── loader
│ ├── ExecutableArchiveLauncher.class
│ ├── JarLauncher.class
│ ├── LaunchedURLClassLoader$UseFastConnectionExceptionsEnumeration.class
│ ├── LaunchedURLClassLoader.class
│ ├── Launcher.class
│ ├── MainMethodRunner.class
│ ├── ...
  • BOOT-INF:包含了我们的项目代码(classes目录),以及所需要的依赖(lib 目录)
  • META-INF:常见的Jar包元信息,我们的启动信息将在这里配置
  • org.springframework.boot.loader:SpringBoot的加载器代码,实现的Jar in Jar加载的魔法源

我们看到,如果去掉BOOT-INF目录,这将是一个非常普通且标准的Jar包,包括元信息以及可执行的代码部分,其/META-INF/MAINFEST.MF指定了Jar包的启动元信息,org.springframework.boot.loader执行对应的逻辑操作。

3. /META-INF/MAINFEST.MF 元信息分析

元信息内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Manifest-Version: 1.0
Implementation-Title: boot2-example
Implementation-Version: 0.0.1-SNAPSHOT
Built-By: ivan
Implementation-Vendor-Id: com.dole.framework
Spring-Boot-Version: 2.1.0.RELEASE
Implementation-Vendor: DoLe Team

# 整个Jar包的执行入口
Main-Class: org.springframework.boot.loader.JarLauncher

# 定义了应用的入口
Start-Class: com.doleje.boot2.example.Boot2ExampleApplication

# 定义了应用的代码目录
Spring-Boot-Classes: BOOT-INF/classes/

# 定义了应用所依赖的库目录
Spring-Boot-Lib: BOOT-INF/lib/

Created-By: Apache Maven 3.3.9
Build-Jdk: 1.8.0_191
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-bo
ot-starter-parent/dole-boot2-parent/boot2-example

根据上面的注释我们了解到,除了一些常规提版本信息之外,主要指定了:

  1. 两个入口:一个用于执行标准的java -jar命令,一个用于查询真正的业务入口类;
  2. 两个目录:一个用于指定加载应用类的入口,一个指定了应用的依赖库,即我们所说的-classpath

4. JarLauncher

根据上一段落的讲解,我们知道在通过独立java -jar执行时,是一个标准的Jar执行过程,即通过MAINFEST.MF的定义进入到我们的Main-Class进行执行,即:org.springframework.boot.loader.JarLauncher,注意这个类不在我们的应用中,也不在SpringBoot的基础框架中,所以我们直接在应用代码中是找不到这个类的,如果想看这个类的源码,我们可以将下面的依赖加入到项目中去:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
</dependency>

JarLauncher源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class JarLauncher extends ExecutableArchiveLauncher {

static final String BOOT_INF_CLASSES = "BOOT-INF/classes/";

static final String BOOT_INF_LIB = "BOOT-INF/lib/";

public JarLauncher() {
}

protected JarLauncher(Archive archive) {
super(archive);
}

@Override
protected boolean isNestedArchive(Archive.Entry entry) {
if (entry.isDirectory()) {
return entry.getName().equals(BOOT_INF_CLASSES);
}
return entry.getName().startsWith(BOOT_INF_LIB);
}

public static void main(String[] args) throws Exception {
new JarLauncher().launch(args);
}

}

这个类比较简单,真正的启动逻辑都在其父类中(两级父类ExecutableArchiveLauncher&Launcher):

1
2
3
public abstract class ExecutableArchiveLauncher extends Launcher {
private final Archive archive;
}

我们先把启动流程放一放,来看看这里出现的一个很重要的一个概念:Archive

archive即归档文件,这个概念在linux下比较常见,通常就是一个tar/zip格式的压缩包,jar是zip格式。
在spring boot里,抽象出了Archive的概念,一个archive可以是一个jar(JarFileArchive),也可以是一个文件目录(ExplodedArchive)。可以理解为Spring boot抽象出来的统一访问资源的层。

上面的demo-0.0.1-SNAPSHOT.jar 是一个Archive,然后demo-0.0.1-SNAPSHOT.jar里的/lib目录下面的每一个Jar包,也是一个Archive,就是嵌套的 Archive 概念。

每个 Archive 有一个自己的 URL,便于 ClassLoader 来加载,标准的 Java Archive 提供的 URL 如下所示:

1
jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/

!/ 分隔,且只支持一个,不支持嵌套,而 SpringBoot 对其的扩展后的 URL 如下:

1
jar:file:/demo/target/demo-0.0.1-SNAPSHOT.jar!/lib/spring-beans-4.2.3.RELEASE.jar!/META-INF/MANIFEST.MF

可以看到以 !/分隔的 Jar in Jar 的资源表达方式。

当然,标准的 ClassLoader 是不支持这种资源 URL 格式的,而为了支持这种 Jar in Jar 的嵌套资源,SpringBoot 实现了自己扩展的 ClassLoader。

构建 Archive 的代码在 Launcher 里面,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}
return (root.isDirectory() ? new ExplodedArchive(root)
: new JarFileArchive(root));
}

可以看见最后一行,如果是目录的话将会创建 ExplodedArchive,否则将会创建一个 JarFileArchive

5. LaunchedURLClassLoader

前面提到 SpringBoot 定义了一种嵌套的 Jar in Jar 格式,这种格式是不被标准的 Java ClassLoader 所支持,这就需要自定义的 ClassLoader 来完成这种内嵌的资源加载。

一般来说 ClassLoader 除了加载 Java 字节码生成类之外,还需要加载指定的资源。SpringBoot 的扩展 ClassLoader 是 LaunchedURLClassLoader,入口地址仍然在 Launcher 中,如下所示:

1
2
3
4
5
6
7
8
9
10
11
/**
* 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);
}

其实如果知道了资源的格式(自己定义的,当然知道),再根据规则来自定义实现相应的 ClassLoader 是一件非常容易的事情,只需要实现 ClassLoader.findResource 并根据规则来返回资源的路由即可。SpringBoot 通过定义了一个 URLStreamHandler 来实现这种资源的查找与路由,具体的实现过程可以查阅 org.springframework.boot.loader.jar.Handler,这里不再帖出来了。

Handler 是如何和我们的资源关联起来的呢,这部分代码是在 JarFile.getUrl() 方法里面,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Return a URL that can be used to access this JAR file. NOTE: the specified URL
* cannot be serialized and or cloned.
* @return the URL
* @throws MalformedURLException if the URL is malformed
*/
public URL getUrl() throws MalformedURLException {
if (this.url == null) {
Handler handler = new Handler(this);
String file = this.rootFile.getFile().toURI() + this.pathFromRoot + "!/";
file = file.replace("file:////", "file://"); // Fix UNC paths

// 关联定制的 Handler 以处理 Jar in Jar 的资源路由
this.url = new URL("jar", "", -1, file, handler);
}
return this.url;
}

JarFile 组成了我们的 JarFileArchive,而我们的 JarFile 在执行 getUrl() 时,会通过之前定义好的 Handler 来完成嵌套资源的路由,以便定制的 ClassLoader 加载。

至此,我们解决了 SpringBoot 打包,并解压完成压缩包内嵌套资源的加载问题,之后的执行过程将和普通的 Java 应用程序是一样的,大家可以参考另外一篇文章SpringBoot源码解读——启动篇来了解。