Spring Boot jar包启动原理及流程解析
Spring Boot项目的pom.xml文件中默认使用如下插件进行打包:
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
执行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。
spring-boot-maven-plugin的repackage在代码层面调用了RepackageMojo的execute方法,而在该方法中又调用了repackage方法。repackage方法代码及操作解析如下:
private void repackage() throws MojoExecutionException { // maven生成的jar,最终的命名将加上.original后缀 Artifact source = getSourceArtifact(); // 最终为可执行jar,即fat jar File target = getTargetFile(); // 获取重新打包器,将maven生成的jar重新打包成可执行jar Repackager repackager = getRepackager(source.getFile()); // 查找并过滤项目运行时依赖的jar Set<Artifact> artifacts = filterDependencies(this.project.getArtifacts(), getFilters(getAdditionalFilters())); // 将artifacts转换成libraries Libraries libraries = new ArtifactsLibraries(artifacts, this.requiresUnpack, getLog()); try { // 获得Spring Boot启动脚本 LaunchScript launchScript = getLaunchScript(); // 执行重新打包,生成fat jar repackager.repackage(target, libraries, launchScript); }catch (IOException ex) { throw new MojoExecutionException(ex.getMessage(), ex); } // 将maven生成的jar更新成.original文件 updateArtifact(source, target, repackager.getBackupFile()); }
jar包目录结构
spring-boot-theory-1.0.0.jar ├── META-INF │ └── MANIFEST.MF ├── BOOT-INF │ ├── classes │ │ └── 应用程序 │ └── lib │ └── 第三方依赖jar └── org └── springframework └── boot └── loader └── springboot启动程序
META-INF内容
Manifest-Version: 1.0 Implementation-Title: spring-learn Implementation-Version: 0.0.1-SNAPSHOT Start-Class: com.secbro2.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
可以看到有Main-Class是org.springframework.boot.loader.JarLauncher ,这个是jar启动的Main函数。
还有一个Start-Class是com.secbro2.learn.SpringLearnApplication,这个是我们应用自己的Main函数。
Archive的概念
- archive即归档文件,这个概念在linux下比较常见
- 通常就是一个tar/zip格式的压缩包
- jar是zip格式
在spring boot里,抽象出了Archive的概念。
SpringBoot抽象了Archive的概念,一个Archive可以是jar(JarFileArchive),可以是一个文件目录(ExplodedArchive),可以抽象为统一访问资源的逻辑层。
关于Spring Boot中Archive的源码如下:
public interface Archive extends Iterable<Archive.Entry> { // 获取该归档的url URL getUrl() throws MalformedURLException; // 获取jar!/META-INF/MANIFEST.MF或[ArchiveDir]/META-INF/MANIFEST.MF Manifest getManifest() throws IOException; // 获取jar!/BOOT-INF/lib/*.jar或[ArchiveDir]/BOOT-INF/lib/*.jar List<Archive> getNestedArchives(EntryFilter filter) throws IOException; }
SpringBoot定义了一个接口用于描述资源,也就是org.springframework.boot.loader.archive.Archive
。该接口有两个实现,分别是org.springframework.boot.loader.archive.ExplodedArchive
和org.springframework.boot.loader.archive.JarFileArchive
。前者用于在文件夹目录下寻找资源,后者用于在jar包环境下寻找资源。而在SpringBoot打包的fatJar中,则是使用后者。
JarLauncher
从MANIFEST.MF可以看到Main函数是JarLauncher,下面来分析它的工作流程。JarLauncher类的继承结构是:
class JarLauncher extends ExecutableArchiveLauncher class ExecutableArchiveLauncher extends Launcher
Launcher for JAR based archives. This launcher assumes that dependency jars are included inside a /BOOT-INF/lib directory and that application classes are included inside a /BOOT-INF/classes directory.
按照定义,JarLauncher可以加载内部/BOOT-INF/lib下的jar及/BOOT-INF/classes下的应用class,其实JarLauncher实现很简单:
public class JarLauncher extends ExecutableArchiveLauncher { public JarLauncher() {} public static void main(String[] args) throws Exception { new JarLauncher().launch(args); } }
其主入口新建了JarLauncher并调用父类Launcher中的launch方法启动程序
再创建JarLauncher时,父类ExecutableArchiveLauncher找到自己所在的jar,并创建archive。
JarLauncher
继承于org.springframework.boot.loader.ExecutableArchiveLauncher
。该类的无参构造方法最主要的功能就是构建了当前main方法所在的FatJar的JarFileArchive
对象。下面来看launch方法。该方法主要是做了2个事情:
- 以FatJar为file作为入参,构造JarFileArchive对象。获取其中所有的资源目标,取得其Url,将这些URL作为参数,构建了一个URLClassLoader。
- 以第一步构建的ClassLoader加载
MANIFEST.MF
文件中Start-Class
指向的业务类,并且执行静态方法main。进而启动整个程序。
public abstract class ExecutableArchiveLauncher extends Launcher { private final Archive archive; public ExecutableArchiveLauncher() { try { // 找到自己所在的jar,并创建Archive this.archive = createArchive(); } catch (Exception ex) { throw new IllegalStateException(ex); } } } public abstract class Launcher { protected final Archive createArchive() throws Exception { ProtectionDomain protectionDomain = getClass().getProtectionDomain(); CodeSource codeSource = protectionDomain.getCodeSource(); URI location = (codeSource == null ? null : codeSource.getLocation().toURI()); String path = (location == null ? null : location.getSchemeSpecificPart()); 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)); } }
在Launcher的launch方法中,通过以上archive的getNestedArchives方法找到/BOOT-INF/lib下所有jar及/BOOT-INF/classes目录所对应的archive,通过这些archives的url生成LaunchedURLClassLoader,并将其设置为线程上下文类加载器,启动应用。
至此,才执行我们应用程序主入口类的main方法,所有应用程序类文件均可通过/BOOT-INF/classes加载,所有依赖的第三方jar均可通过/BOOT-INF/lib加载。
URLStreamHandler
java中描述资源常使用URL。而URL有一个方法用于打开链接java.net.URL#openConnection()
。由于URL用于表达各种各样的资源,打开资源的具体动作由java.net.URLStreamHandler
这个类的子类来完成。根据不同的协议,会有不同的handler实现。而JDK内置了相当多的handler实现用于应对不同的协议。比如jar
、file
、http
等等。URL内部有一个静态HashTable
属性,用于保存已经被发现的协议和handler实例的映射。
获得URLStreamHandler
有三种方法
- 实现
URLStreamHandlerFactory
接口,通过方法URL.setURLStreamHandlerFactory
设置。该属性是一个静态属性,且只能被设置一次。 - 直接提供
URLStreamHandler
的子类,作为URL的构造方法的入参之一。但是在JVM中有固定的规范要求:- 子类的类名必须是 Handler ,同时最后一级的包名必须是协议的名称。比如自定义了Http的协议实现,则类名必然为xx.http.Handler
- JVM 启动的时候,需要设置
java.protocol.handler.pkgs
系统属性,如果有多个实现类,那么中间用 | 隔开。因为JVM在尝试寻找Handler时,会从这个属性中获取包名前缀,最终使用包名前缀.协议名.Handler
,使用Class.forName
方法尝试初始化类,如果初始化成功,则会使用该类的实现作为协议实现。
SpringBoot自定义的classLoader能够识别FatJar中的资源,包括有:在指定目录下的项目编译class、在指令目录下的项目依赖jar。JDK默认用于加载应用的AppClassLoader只能从jar的根目录开始加载class文件,并且也不支持jar in jar这种格式。
为了实现这个目标,SpringBoot首先从支持jar in jar中内容读取做了定制,也就是支持多个!/
分隔符的url路径。SpringBoot定制了以下两个方面:
- 实现了一个
java.net.URLStreamHandler
的子类org.springframework.boot.loader.jar.Handler
。该Handler支持识别多个!/
分隔符,并且正确的打开URLConnection
。打开的Connection是SpringBoot定制的org.springframework.boot.loader.jar.JarURLConnection
实现。 - 实现了一个
java.net.JarURLConnection
的子类org.springframework.boot.loader.jar.JarURLConnection
。该链接支持多个!/
分隔符,并且自己实现了在这种情况下获取InputStream的方法。而为了能够在org.springframework.boot.loader.jar.JarURLConnection
正确获取输入流,SpringBoot自定义了一套读取ZipFile的工具类和方法。这部分和ZIP压缩算法规范紧密相连,就不深入了。
WarLauncher
构建war包很简单
- build.gradle中引入插件
apply plugin: 'war'
- build.gradle中将内嵌容器相关依赖设为provided
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
- 修改WebApp内容,重写SpringBootServletInitializer的configure方法
@SpringBootApplication @RestController public class WebApp extends SpringBootServletInitializer { public static void main(String[] args) { SpringApplication.run(WebApp.class, args); } @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(WebApp.class); } @RequestMapping("/") @GetMapping public String hello() { return "Hello You!"; } }
构建出的war包,其目录机构为
spring-boot-theory-1.0.0.war ├── META-INF │ └── MANIFEST.MF ├── WEB-INF │ ├── classes │ │ └── 应用程序 │ └── lib │ └── 第三方依赖jar │ └── lib-provided │ └── 与内嵌容器相关的第三方依赖jar └── org └── springframework └── boot └── loader └── springboot启动程序
MANIFEST.MF内容为
Manifest-Version: 1.0 Start-Class: com.manerfan.springboot.theory.WebApp Main-Class: org.springframework.boot.loader.WarLauncher
此时,启动类变为了org.springframework.boot.loader.WarLauncher
,查看WarLauncher实现,其实与JarLauncher并无太大差别
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 WarLauncher() { } @Override public boolean isNestedArchive(Archive.Entry entry) { if (entry.isDirectory()) { return entry.getName().equals(WEB_INF_CLASSES); } else { return entry.getName().startsWith(WEB_INF_LIB) || entry.getName().startsWith(WEB_INF_LIB_PROVIDED); } } public static void main(String[] args) throws Exception { new WarLauncher().launch(args); } }
差别仅在于,JarLauncher在构建LauncherURLClassLoader时,会搜索BOOT-INF/classes目录及BOOT-INF/lib目录下jar,WarLauncher在构建LauncherURLClassLoader时,则会搜索WEB-INFO/classes目录及WEB-INFO/lib和WEB-INFO/lib-provided两个目录下的jar
如此依赖,构建出的war便支持两种启动方式
- 直接运行
./spring-boot-theory-1.0.0.war start
- 部署到Tomcat容器下
PropertiesLauncher
PropretiesLauncher 的实现与 JarLauncher WarLauncher 的实现极为相似,通过PropretiesLauncher可以实现更为轻量的thin jar,其实现方式可自行查阅源码。
spring boot应用启动流程总结
看到这里,可以总结下Spring Boot应用的启动流程:
- spring boot应用打包之后,生成一个fat jar,里面包含了应用依赖的jar包,还有Spring boot loader相关的类
- Fat jar的启动Main函数是JarLauncher,它负责创建一个LaunchedURLClassLoader来加载/lib下面的jar,并以一个新线程启动应用的Main函数。
ClassLoader如何读取到Resource
对于一个ClassLoader,它需要哪些能力?
- 查找资源
- 读取资源
对应的API是:
public URL findResource(String name) public InputStream getResourceAsStream(String name)
上面提到,Spring boot构造LaunchedURLClassLoader时,传递了一个URL[]数组。数组里是lib目录下面的jar的URL。
对于一个URL,JDK或者ClassLoader如何知道怎么读取到里面的内容的?
实际上流程是这样子的:
- LaunchedURLClassLoader.loadClass
- URL.getContent()
- URL.openConnection()
- Handler.openConnection(URL)
最终调用的是JarURLConnection的getInputStream()函数。
//org.springframework.boot.loader.jar.JarURLConnection @Override public InputStream getInputStream() throws IOException { connect(); if (this.jarEntryName.isEmpty()) { throw new IOException("no entry name specified"); } return this.jarEntryData.getInputStream(); }
从一个URL,到最终读取到URL里的内容,整个过程是比较复杂的,总结下:
- spring boot注册了一个Handler来处理”jar:”这种协议的URL
- spring boot扩展了JarFile和JarURLConnection,内部处理jar in jar的情况
- 在处理多重jar in jar的URL时,spring boot会循环处理,并缓存已经加载到的JarFile
- 对于多重jar in jar,实际上是解压到了临时目录来处理,可以参考JarFileArchive里的代码
- 在获取URL的InputStream时,最终获取到的是JarFile里的JarEntryData
这里面的细节很多,只列出比较重要的一些点。
然后,URLClassLoader是如何getResource的呢?
URLClassLoader在构造时,有URL[]数组参数,它内部会用这个数组来构造一个URLClassPath:
URLClassPath ucp = new URLClassPath(urls);
在 URLClassPath 内部会为这些URLS 都构造一个Loader,然后在getResource时,会从这些Loader里一个个去尝试获取。
如果获取成功的话,就像下面那样包装为一个Resource。
Resource getResource(final String name, boolean check) { final URL url; try { url = new URL(base, ParseUtil.encodePath(name, false)); } catch (MalformedURLException e) { throw new IllegalArgumentException("name"); } final URLConnection uc; try { if (check) { URLClassPath.check(url); } uc = url.openConnection(); InputStream in = uc.getInputStream(); if (uc instanceof JarURLConnection) { /* Need to remember the jar file so it can be closed * in a hurry. */ JarURLConnection juc = (JarURLConnection)uc; jarfile = JarLoader.checkJar(juc.getJarFile()); } } catch (Exception e) { return null; } return new Resource() { public String getName() { return name; } public URL getURL() { return url; } public URL getCodeSourceURL() { return base; } public InputStream getInputStream() throws IOException { return uc.getInputStream(); } public int getContentLength() throws IOException { return uc.getContentLength(); } }; }
从代码里可以看到,实际上是调用了url.openConnection()。这样完整的链条就可以连接起来了。
注意,URLClassPath这个类的代码在JDK里没有自带,在这里看到 http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/7u40-b43/sun/misc/URLClassPath.java#506
在IDE/开放目录启动Spring boot应用
在上面只提到在一个fat jar里启动Spring boot应用的过程,下面分析IDE里Spring boot是如何启动的。
在IDE里,直接运行的Main函数是应用自己的Main函数:
@SpringBootApplication public class SpringBootDemoApplication { public static void main(String[] args) { SpringApplication.run(SpringBootDemoApplication.class, args); } }
其实在IDE里启动Spring boot应用是最简单的一种情况,因为依赖的Jar都让IDE放到classpath里了,所以Spring boot直接启动就完事了。
还有一种情况是在一个开放目录下启动Spring boot启动。所谓的开放目录就是把fat jar解压,然后直接启动应用。
java org.springframework.boot.loader.JarLauncher
这时,Spring boot会判断当前是否在一个目录里,如果是的,则构造一个ExplodedArchive(前面在jar里时是JarFileArchive),后面的启动流程类似fat jar的。
总结
- SpringBoot通过扩展JarFile、JarURLConnection及URLStreamHandler,实现了jar in jar中资源的加载
- SpringBoot通过扩展URLClassLoader–LauncherURLClassLoader,实现了jar in jar中class文件的加载
- JarLauncher通过加载BOOT-INF/classes目录及BOOT-INF/lib目录下jar文件,实现了fat jar的启动
- WarLauncher通过加载WEB-INF/classes目录及WEB-INF/lib和WEB-INF/lib-provided目录下的jar文件,实现了war文件的直接启动及web容器中的启动
关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台
除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接