1. 概述
用过springboot的人都知道,springboot一个特点是能直接将springboot的项目打包成一个jar就可以直接运行。但为什么springboot打的jar包就可以直接运行?这其中有什么秘密?可能对于大多数胖友来说要回答这两个问题都是一脸懵逼的,不过先不要方,今天蜗牛就来给大家说道说道。
开始之前,我们先将springboot先放在一边。让我们回想一下,我们如果要让一个普通项目生成一个可运行的jar包需要具备什么条件?
- 首先,这jar包中肯定需要指明一个程序入口吧。(和war包运行在tomcat等容器中不同,jar包的运行需要指明一个启动入口即main方法)
- 其次,如果运行的jar包依赖其他的jar包,这些依赖的jar包肯定也需要能被程序扫描到吧。
由此我们知道一个能够运行的jar包需要以上两个条件。那么我们接下来就从 “普通项目的jar包如何启动”开始讲起,介绍springboot的启动原理
2. 普通项目jar包的启动
2.1 jar包的启动方式
我们刚才说了,一个jar包能够启动的前提首先需要指明一个程序启动的入口。我们首先创建一个项目来看下,如何指定jar包的入口并启动它
创建一个项目:
项目中只有两个类,并且每个类中都包含一个main方法
LocalStart00:
public class LocalStart00 {
public static void main(String[] args) {
System.out.println("=================LocalStart00 start===================");
}
}
LocalStart01:
public class LocalStart01 {
public static void main(String[] args) {
System.out.println("=================LocalStart01 start===================");
}
}
使用mvn package命令打包,并进入包所在目录。首先在未指明jar启动入口的情况下运行一下:
我是直接在idea的terminal中运行的,可以看到如果未指明程序入口(即运行的是哪个main方法)会直接报错。
那么我们如何指定jar的入口呢?这里有两种方法:
- 在启动时指定入口,通过命令 java -cp XXX.jar main方法对应类的全路径名称 运行
- 在打包时指定mainClass,通过命令 java -jar XXX.jar 运行
我们分别来看一下这两种方式
2.1.1 使用命令指定
还是进入jar包所在目录,输入java -cp命令运行
其中-cp可以替换成-classpath这两者时等价的
2.1.2 打包时指定
相比起使用命令时每次启动都需要指定程序入口,在打包时指定就要方便的多了。我们由此需要对pom文件进行改造
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>learn-springboot-jarstart</artifactId>
<groupId>org.springboot.learn</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>springboot-jarstart-base</artifactId>
<packaging>jar</packaging>
<!--新增打包插件-->
<build>
<plugins>
<!--使用maven-jar-plugin将项目打成jar包,这也是默认的打包方式-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<!--指定jar包执行入口-->
<mainClass>com.learn.jarstart.mainStart.LocalStart00</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
注意代码中的注解。我们在pom文件中新增maven-jar-plugin打包插件,同时指定mainClass为com.learn.jarstart.mainStart.LocalStart00。
有胖友可能对“manifest”不太了解,它其实对应着jar包中META-INF/MANIFEST.MF文件。MANIFEST.MF是一个元数据文件,其中定义了我们一些打包信息和启动信息,诸如jdk版本号,maven版本号等等。其中我们在打包时设置的mainClass也会被写入该文件。
这里由于指定了mainClass,我们直接使用Java -jar就可以直接运行了
2.2 将依赖包打入jar
上一节我们的项目并没有依赖任何包,如果我们依赖了其他的jar包,并且想将项目打包成可运行的jar,又该怎么做呢?
maven中有三种打包方式
- maven-jar-plugin,默认的打包插件,用来打普通的project JAR包;
- maven-shade-plugin,用来打可执行JAR包,也就是所谓的fat JAR包;
- maven-assembly-plugin,支持自定义的打包结构,也可以定制依赖项等。(只能原样打包,如何有包和文件冲突,不会解决冲突)
以上三种方法中maven-shade-plugin和maven-assembly-plugin都可以将项目的依赖打包到新的jar包中。这里我们以maven-assembly-plugin为例。
如果有想详细了解以上三种打包方式区别的可以看:Maven将代码及依赖打成一个Jar包的方式
2.2.1 项目说明
我们创建两个新的项目,其中项目jarstart-dependency-main依赖jarstart-dependency-service项目:
jarstart-dependency-service:
这个项目很简单,只有一个JarService。其中定义了一个供jarstart-dependency-main调用的方法:
public class JarService {
public void test(){
System.out.println("===================JarService test==================");
}
}
jarstart-dependency-main:
JarMain类中包含一个main方法
public class JarMain {
public static void main(String[] args) {
JarService jarService=new JarService();
jarService.test();
}
}
逻辑很简单实,例化一个JarService并调用test方法。JarService引用的是jarstart-dependency-service项目下的类,为此我们需要在打包的时候将jarstart-dependency-service项目也一并打包入新的jar包中。我们在pom文件定义打包方式:
<dependencies>
<dependency>
<groupId>org.springboot.learn</groupId>
<artifactId>jarstart-dependency-service</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>
<build>
<plugins>
<!--使用maven-assembly-plugin插件打包-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<descriptorRefs>
<!--这个jar-with-dependencies是assembly预先写好的一个组装描述-->
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<!--指定main class-->
<archive>
<manifest>
<mainClass>learn.jarstart.JarMain</mainClass>
</manifest>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
注意看上面的注释哈。打包后target下面会生成两个jar包,其中一个以jar-with-dependencies结尾,我们要看的就是这个包
我们看到打包时将依赖包中的类也一并打入了新的jar。由于本项目中JarMain和JarService的包路径是一样的所以这里两个class在一个文件夹中。
运行成功
3. springboot jar包的启动
从上一节我们知道,一个可运行的jar包需要同时具有“一个运行入口即指定mainClass”和“能够扫描到依赖”这两个条件。既然普通的java项目都需要满足这两个条件,那springboot肯定也必须满足(毕竟java是爸爸嘛……)。
在springboot中它使用其自定义的spring-boot-maven-plugin插件进行打包。我们首先创建一个springboot项目:
DemoController:
@RestController
@RequestMapping("/demo")
public class DemoController {
@GetMapping("/hello")
public String echo() {
return "hello";
}
}
JarstartApplication:
@SpringBootApplication
public class JarstartApplication {
public static void main(String[] args) {
SpringApplication.run(JarstartApplication.class,args);
}
}
pom:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
</dependency>-->
</dependencies>
<build>
<plugins>
<!--使用springboot提供的maven插件进行打包-->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
我们使用mvn package命令进行打包,然后进入包目录输入java -jar springboot-jarstart-springboot-1.0-SNAPSHOT.jar & 运行
可见springboot打成的jar包是能够直接使用java -jar运行的。
3.1 springboot jar目录结构
要想搞清楚springboot为什么能够直接运行,那前提是要搞清楚使用spring-boot-maven-plugin打成的包是啥子样子的吧?我们来看一下 springboot-jarstart-springboot-1.0-SNAPSHOT.jar中的内容是个什么样的
可以看到和普通的jar包相比,这里有几点不同之处:
- 首先多了一个BOOT-INF目录,而普通jar包是没有的
- 我们项目的包路径是以learn开头的,但是在这个jar包中却多了一个org目录,进入该目录可看到里面的class并不是我们项目中的class(这一点后面说明)。而普通的jar包,其项目class都是放在第一级目录下的。
我们一个目录一个目录的看,首先看BOOT-INF
该目录下又有classes和lib两个目录,从其名称可知道,它一定是放编译后的class文件和依赖包的。进入这两个目录可知道,其中存放的其实就是我们项目的class和依赖包。
可见我们项目编译后生成的东西实际上是放在BOOT-INF下的。
再看org目录
这个目录好像是一个和我们项目无关凭空生成的一样。其实这个目录是使用springboot的maven插件编译后自己加入的一个spring-boot-loader项目。从名字上聪明的胖友就可以看到,他是负责springboot项目加载和运行有关的。至于为什么这么做后面再提。
META-INF
这个目录是我们最熟悉的。因为每个jar包中都会有。
注意上面的注解。MANIFEST.MF中定义的是一些元数据(即一些启动配置信息)。除了我们熟悉的Main-Class外。spring-boot-classes和spring-boot-lib指明了项目的class和依赖包目录。其次文件中和定义了一个start-class用以指明springboot的启动入口(即我们使用@SpringbootApplication标记的类)。
看到这里,我们可以从这个包的目录下大概推测一下:
- jar的启动是从Main-Class开始的,由此可知springboot的启动是从JarLauncher这个类开始的
- 从JarLauncher的包路径来看,这个类应该在spring-boot-loader这个项目中。那springboot的启动肯定和spring-boot-loader有关
- springboot的class和依赖包被放入了BOOT-INF下。那么springboot启动的时候肯定会去扫描BOOT-INF下面的class和依赖。
有了以上这些推论,我们接下来进入springboot的源代码之旅
3.2 springboot jar启动源代码
上面我们一直反复说,jar启动的入口是从MainClass开始的。那么看springboot jar启动源码肯定也要从MainClass开始(即JarLauncher)。
下载springboot源码,不知道的胖友可以看:Spring Boot源码——源码阅读环境搭建
JarLauncher是springboot jar的启动类,其整体类图如下:
WarLaucncher是springboot war的启动类,其整体逻辑和JarLauncher类似,这里只以JarLauncher为例。
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 {
<1.1>
new JarLauncher().launch(args);
}
}
protected void launch(String[] args) throws Exception {
//注册url处理器
<1.2>
JarFile.registerUrlProtocolHandler();
//创建类加载器
<1.3>
ClassLoader classLoader = createClassLoader(getClassPathArchives());
//执行启动类的main方法
<1.4>
launch(args, getMainClass(), classLoader);
}
从JarLauncher的main方法进入launch(),其中做了三件事:
- 注册url处理器
- 创建类加载器(该步骤会去扫描BOOT-INF下的classes和lib下的jar包)
- 执行启动类的main方法(该步骤将执行由@SpringbootApplication标注的类的main方法)
3.2.1 注册协议处理器
我们先来看<1.2>部分代码,该方法从名称来看就知道它的目的是注册一个协议处理器。什么是协议处理器?这里需要说明下:
- 在理解“协议处理器”之前,首先需要理解什么是“协议”。协议是一个网络通信上的概念,是为了实现资源的读取而创造出来的东西。我们的数据以二进制在网络上传递。协议即是一种用以正确解析这些在网络上传输的二进制流的规定或机制。不同的协议代表着不同的传输和解析规则,而不同的机制也对应着不同的处理和解析方法即不同的"协议处理"。所以”协议处理器“即是处理不同协议的方法。
- 在java中默认实现了一些协议处理,如HTTP 、 File 、 FTP ,jar等协议处理。
需要更近一步了解协议处理器的可以看:Java url扩展协议实现
private static final String PROTOCOL_HANDLER = "java.protocol.handler.pkgs";
private static final String HANDLERS_PACKAGE = "org.springframework.boot.loader";
public static void registerUrlProtocolHandler() {
String handlers = System.getProperty(PROTOCOL_HANDLER, "");
System.setProperty(PROTOCOL_HANDLER,
("".equals(handlers) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE));
resetCachedUrlHandlers();
}
进入<1.2>对应的代码,如上所示。其逻辑还是很简单的。首先取出”java.protocol.handler.pkgs"环境变量的值,然后将"org.springframework.boot.loader"路径追加到其中,最后清空协议处理的相关缓存。
为什么需要这么做?其实在java中通过设置 JVM 启动参数 -D java.protocol.handler.pkgs 来设置 URLStreamHandler(自定义协议处理器时可以通过继承它实现) 实现类的包路径。将org.springframework.boot.loader包设置到 java.protocol.handler.pkgs中,启动时就能加载到springboot在loader下定义的自定义协议解析器。
3.2.2 创建类加载器
<1.3>处getClassPathArchives()对应的是一个抽象方法,其在ExecutableArchiveLauncher(其是JarLauncher的父类)中实现。
//ExecutableArchiveLauncher.java
@Override
protected List<Archive> getClassPathArchives() throws Exception {
<1.3.1>
List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive));
<1.3.2>
postProcessClassPathArchives(archives);
return archives;
}
<1.3.1>部分的this.archive是在ExecutableArchiveLauncher构造方法中初始化的
public ExecutableArchiveLauncher() {
try {
this.archive = createArchive();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
protected final Archive createArchive() throws Exception {
//jar包所在路径
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));
}
将上面那段代码拷贝入main方法中(注意需要引入spring-boot-loader项目),来看看其中的root和archive是什么东东。注意这里需要用java -jar运行
可见root是我们项目的jar的路径,而archive是JarFileArchive。JarFileArchive的类关系图如下:
Archive从字面意思来看是”档案“的意思,可以理解成一种对“目录”的抽象。这里以项目的jar的路径创建了一个档案。
再次回到<1.3.1>this.archive.getNestedArchives(this::isNestedArchive)方法。由于上面我们已经知道了archive是JarFileArchive,所以这里直接进入JarFileArchive中的对应实现。 this::isNestedArchive 是一个过滤方法,由JarLauncher实现。
//JarLauncher.java
@Override
protected boolean isNestedArchive(Archive.Entry entry) {
if (entry.isDirectory()) {
return entry.getName().equals(BOOT_INF_CLASSES);
}
return entry.getName().startsWith(BOOT_INF_LIB);
}
//JarFileArchive.java
@Override
public List<Archive> getNestedArchives(EntryFilter filter) throws IOException {
List<Archive> nestedArchives = new ArrayList<>();
for (Entry entry : this) {
if (filter.matches(entry)) {
nestedArchives.add(getNestedArchive(entry));
}
}
return Collections.unmodifiableList(nestedArchives);
}
这两个方法的目的很简单,遍历项目jar包的目录,筛选出BOOT-INF下的classes和lib下的jar包。我们将上面那个测试的main方法改造下,模拟上述过程。如图:
<1.3.2>是一个模板方法,这里JarLuancher并没有覆盖该方法,所以我们直接跳过。终于山路十八弯的说完了getNestedArchives()方法,我们回到<1.3>再来看createClassLoader(getClassPathArchives())方法。
从上面我们知道getClassPathArchives()返回的是一个List<Archive>,这个list中包含了BOOT-INF下的classes和lib下的jar的”Archive。
protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {
List<URL> urls = new ArrayList<>(archives.size());
for (Archive archive : archives) {
urls.add(archive.getUrl());
}
return createClassLoader(urls.toArray(new URL[0]));
}
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
return new LaunchedURLClassLoader(urls, getClass().getClassLoader());
}
这里返回了一个自定义类加载器,LaunchedURLClassLoader。从我们之前扫描了BOOT-INF下的class和jar包来看,LaunchedURLClassLoader肯定就是用来装载这些class以及jar包的了。只要将这些class载入了jvm我们后面自然可以实例化这些类了。
限于篇幅。<1.3>就讲到这里,至于class如何加载的,有感兴趣的胖友可以自行挖坑……
3.2.3 执行springboot main方法
最后一步,回到<1.4>。是不是有胖友已经忘了<1.4>是什么了?哈哈……
launch(args, getMainClass(), classLoader)
该方法由三个参数args是启动参数。classLoader之前我们已经看到过了,是springboot自定义的类加载器。最后我们来看一下getMainClass()这个方法。
//ExecutableArchiveLauncher.java
@Override
protected String getMainClass() throws Exception {
Manifest manifest = this.archive.getManifest();
String mainClass = null;
if (manifest != null) {
mainClass = manifest.getMainAttributes().getValue("Start-Class");
}
if (mainClass == null) {
throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
}
return mainClass;
}
这个方法会从jar包中的/META-INF/MANIFEST.MF文件中获取start-class所对应的class,而这个class其实就是我们springboot启动时@SpringbootApplication锁标记的类。
3.3 小结
我们总结一下springboot启动的整个过程
- 通过manifest中定义的JarLauncher.main方法启动
- 然后通过向java.protocol.handler.pkgs环境参数追加 org.springframework.boot.loader包注册协议处理器
- 扫描项目jar包下的BOOT-INF下的classes和lib下的jar,并将classes和每个jar单独封装为Archive返回
- 通过自定义类加载器加载classes中的class和lib下每个jar
- 获取manifest中定义的start-class启动以@SpringbootApplication标记的springboot启动方法。
ps:题外话
终于写完了。写完一看哦豁,好长一篇……,真的对于这种阅读源码的文章,蜗牛真心不知道怎么能够让人简单易懂。特别是有些代码又长又深。不过蜗牛一直努力将相关知识点都讲到,写这么长,也不知道有么有人看。不过还是要开心的撒花,终于写完了。(^_^),撒花❀