掘金 后端 ( ) • 2024-04-06 15:44

1、spring boot jar 直接启动的秘密

最近对springboot jar为什么可以直接运行很好奇,所以找了一些文章并下载了springbootloader相关的源码进行debug,观察spring 是如何在java jar标准之上,进行扩展,以使一个jar包可以直接运行的。简而言之。spring做了以下的工作:

  1. 在遵循java jar规范的前提下,定义了spring boot 可运行的jar包格式规范
  2. 因为spring boot jar 可以直接启动,所以它就必须要把所有的东西(启动类和依赖)打包在一个盒子里(fatjar)
  3. 为了支持解析jar中嵌套jar,就必须还要支持这种方式的解析-找到jar中jar里的class

下面分别对上诉的进行简单说明

1.1、 在遵循java jar规范的前提下,定义了spring boot 可运行的jar包格式规范

简而言之 spring boot 定义了自己的fat jar 的格式 如下

image.png

其中jar的原信息文件MANIFEST.MF内容如下:

Manifest-Version: 1.0
Implementation-Title: spring-learn
Implementation-Version: 0.0.1-SNAPSHOT
Start-Class: com.vincent.learn.SpringLearnApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.1.5.RELEASE
Created-By: Maven Archiver 3.4.0
Main-Class: org.springframework.boot.loader.JarLauncher

Java Jar规范是可以直接启动 Main-Class 对应的类的main方法,可以看到是 org.springframework.boot.loader.JarLauncher 这个类在 spring-boot- loader-2.xx.jar文件里(下面会详细介绍这个jar相关的内容)。

另外可以看到我们自己写的springboot项目的主启动类其实是Start-Class对应的类也就是com.vincent.learn.SpringLearnApplication 另外还有 Spring-Boot-Classes 以及 Spring-Boot-Lib 用来存放项目class 和 依赖的jar 文件。

所以可以看到springboot 的fat jar 需要有专门打包逻辑以及解包并寻找class的逻辑

1.2、 spring boot 打包fat jar逻辑

首先springboot提供了相应的maven 打包工具

image.png

执行maven clean package之后,会生成两个文件:

spring-learn-0.0.1-SNAPSHOT.jar
spring-learn-0.0.1-SNAPSHOT.jar.original

spring-boot-maven-plugin项目存在于spring-boot-tools目录中。 spring-boot-maven-plugin默认有5个goals: repackage、 run、 start、 stop、 build-info。在打包的时候默认使用的是repackage。

spring-boot-maven-plugin的repackage能够将mvn package生成的软件包,再次打包为可执行的软件包,并将mvn package生成的软件包重命名为*.original。

1.3、对这种fat jar寻找class文件进行支持

我们知道原始的java的三种classLoader进行class文件装载时,通过UrlClassPath下的URL,如果是jar协议的话,是只支持单层jar内寻找的,如 java.net.URL#URL(java.lang.String, java.lang.String, int, java.lang.String, java.net.URLStreamHandler) 中,遇到多层会直接抛出异常,所以

image.png springboot专门针对形如 jar:file:/Users/vincent/github_project/spring-boot-debug/target/spring-boot-debug-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-2.7.15.jar!/ 格式定制了专用的URLStreamHandler解析器,也就是 org.springframework.boot.loader.jar.Handler , 同时为这种专用的解析器设计了专用的类加载器org.springframework.boot.loader.LaunchedURLClassLoader 用于加载 BOOT-INF 问价内的class和jar , 到此所有为启动Main方法的Class以准备好了环境。

  • 大家有没有想过一个问题,为什么不可以直接把Jar的Main-Class 设置成我们的业务启动主类,依赖的相关jar依然打成嵌入jar的方式,这样不就可以省了通过 org.springframework.boot.loader.JarLauncher#main 引导我们的业类进行启动了吗 ?
public static void main(String[] args) throws Exception {  
new JarLauncher().launch(args);  
}

1.4 启动流程简单梳理

1、启动入口ork.boot.loader.JarLauncher#main

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

2、在JarLauncher的父类org.springframework.boot.loader.ExecutableArchiveLauncher#ExecutableArchiveLauncher() 构造函数中对获取了当前启动jar包的Archive-其实就是我们的package之后的Fat Jar

public ExecutableArchiveLauncher() {  
try {  
this.archive = createArchive();  
this.classPathIndex = getClassPathIndex(this.archive);  
}  
catch (Exception ex) {  
throw new IllegalStateException(ex);  
}  
}

3、接着JarLauncher#launch 方法进行

  • 设置Jar protocol Handler 覆盖 java 默认提供的 handler
  • 遍历Fat Jar中 BOOT-INT/classes 和 BOOT-INF/lib 下所有的资源 分别构造 JarFileive , 并把所有的JarFileArchive的URL 收集起来作为自定义ClassLoader的UrlClassPath ,然后创建自定义的ClassLoader
  • 在RootArchive - 也就是我们最初打的Fat Jar的MANIFEST.MF 文件里找到Start-Class 对应的业务主启动类字符串
  • 通过第二步创建的自定义ClassLoader来加载主业务启动类,并进行反射执行其main方法,完整jar的启动
protected void launch(String[] args) throws Exception {  
if (!isExploded()) {  
JarFile.registerUrlProtocolHandler();  
}  
ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator());  
String jarMode = System.getProperty("jarmode");  
String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass();  
launch(args, launchClass, classLoader);  
}

好了以上就是整个spring boot fat jar 启动的流程和设计思路了,当然上面只是一个比较粗线条的框架的梳理,其实整个设计过程中,涉及了很多的知识点,包括 ClassLoader 类加载 和URL 的协议解析,还有标准Jar文件 以及 Spring Jar 文件的流读取机制 等,这些都是很考验对于Java语言的底层机制的理解,有很深挖的意义和价值 。

1.4、如何验证上面Jar的启动流程 - debug SpringBot Fat Jar

网上有很多解析源码的文章,但是代码真正运行起来到底是什么样的,如果能debug一下整个运行流程 那就更能加深对于一些底层运行机制的理解了。Idea提供了Debug SpringBoot Fat Jar的机制。如下图配置就ok,主要就是设置打包好的Fat Jar的路径以及源码项目路径

image.png

遇到的问题

下面就是我在debug的过程中遇到一个当时觉得很疑惑的问题

在遍历获取fat jar 内部的 archive (先拿到JarEntry再构造成JarFile再构造成JarFileArchive)的过程中,发现构造JarFile 时候其内部 url属性是null, 但是执行完return new JarFileArchive(jarFile); 返回之后 JarFile里的url就被赋值了, 但是一直没有找到触发这个赋值url属性的地方,打的各种断点都没有进去, 为什么对这个URL的赋值这么在意,是因为这个URL就是最终要作为自定义ClassLoader的ClassPath传入的,而且这个URL中包含了解析用的Handler,我要验证下对于嵌入Jar类的URL的Handler是如何使用SpringBoot自定义的Handler 调用路径如下

org.springframework.boot.loader.archive.JarFileArchive.
NestedArchiveIterator#adapt
    ->  org.springframework.boot.loader.archive.JarFileArchive
    #getNestedArchive 
        -> 

image.png

image.png

image.png

image.png

一个很明显的JarFile#url赋值逻辑是在 org.springframework.boot.loader.jar.JarFile#getUrl

image.png

但是这个断点处的if分支一直没有进去,每次都是直接return一个已经初始化好的url,但是url怎么赋值的一直没找到(其实赋值逻辑就是if分支的逻辑)但是一直不进入而是直接返回

经过不断来回debug,在过程中idea弹出了一条提示消息

image.png

Skipped breakpoint at org.springframework.boot.loader.archive.JarFileArchive:192 because it happened inside debugger evaluation 看起来idea 在debug的过程中跳过了一些断点,看起来很可能是这个原因导致没有走上面的if语句,所以只要能关于idea的相关的跳过一些断点应该就能解决了。在stackoverflow有相关的解决方案

image.png

原因是idea在调试的时候,在debug控制台中如果点击相应的Class并展示其信息的时候其实是会调用其对应的toString()方法,而JarFileArchive#toString方法如下

image.png

image.png

image.png

JarFileArchive的toString()导致了JarFile#getUrl()方法的if逻辑的执行 算是学到了一个idea debug的知识点吧

参考

https://mp.weixin.qq.com/s/ikx8Ix93fuX7a3ChN0r4cg https://juejin.cn/post/6844904088707186701