前言
SpringBoot項目通常都是由主類的main函數(shù)開始啟動,好奇心驅(qū)使我想搞明白通常項目所有的內(nèi)容都被打成了一個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插件裝載進(jìn)去的class文件,也就是fat jar可以運行起來的源碼
app
├── BOOT-INF
│ ├── classes
│ └── lib
├── META-INF
│ ├── MANIFEST.MF
│ └── maven
└── org
└── springframework
當(dāng)然,直接看org.springframework下反編譯的源碼有點晦澀,畢竟是反編譯而來的。查看spring boot的源碼可以在spring-boot-tools項目下看到spring-boot-loader子項目,這個其實就是maven插件裝載到fat jar中的class文件的源碼,所以閱讀這個子項目的源碼基本就可以搞清楚,spring boot的fat jar是如何把自己跑起來的。
SpringBoot項目的啟動方式
1. idea中的啟動
通常在IDEA中默認(rèn)的啟動方式是直接通過主類啟動,所有依賴的jar都通過jdk的參數(shù)添加進(jìn)來。很明顯,這種啟動方式?jīng)]有借助于spring-boot-loader,是正常的java程序運行方式啟動。
這種啟動方式經(jīng)常用于開發(fā),畢竟直接啟動更快一些。但是也有弊端,那就是通過command line的形式啟動時,如果依賴的jar過多,會導(dǎo)致拼接的命令過長而報錯,所以此中方式通常沒有辦法用于中大型項目
除了在idea中借助于開發(fā)工具拼接運行命令之外,spring boot支持三種常見的啟動方式。
- jar
- war
- properties
2. jar
jar方式就是借助于spring boot的maven插件二次打包后的fat jar的形式啟動。對應(yīng)spring-boot-loader項目中的JarLauncher類,源碼如下(源碼中的注釋,部分翻譯,部分為我自己添加,幫助閱讀):
/**
* 用于基于JAR形式的啟動,該jar依賴的所有的其他jar包在/BOOT-INF/lib路徑下
* 該jar對應(yīng)的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是否為內(nèi)嵌依賴jar的,判斷的依據(jù)是名稱
@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之前,大多數(shù)的spring mvc項目都是打成war包,置于tomcat的webapp目錄下來運行,所以springboot也是支持這種形式的啟動,只要在spring boot的maven插件中將打包的目前格式改為WAR即可。在loader項目中對應(yīng)的啟動類為:WarLauncher
/**
* 注釋翻譯:用于war包形式的啟動,只支持標(biāo)準(zhǔn)的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應(yīng)用可以在不同場景和需求下都可以正常啟動的能力,完成了從打包和實際項目運行的橋接過程。
接下來,我們以JAR啟動的方式,來分析分析,Spring boot到底是如何完成啟動過程的:
可執(zhí)行Jar啟動
jar形式的啟動,主類為JarLauncher,其繼承自ExecutableArchiveLauncher,最上層的父類為Launcher,同時也是所有其他啟動形式的頂層父類。

JarLauncher中代碼不多,直接調(diào)用了Launcher中的launch方法,所以我們的代碼分析也從這里開始。
launch方法了主要干了三件事情,
- 第一是注冊擴(kuò)展protocol handler
- 第二是獲取fat jar中所有的歸檔(三方j(luò)ar,class,資源文件等等)來創(chuàng)建自定義的類加載器(ClassLoader)
- 最后使用創(chuàng)建好的類加載器,攜帶啟動參數(shù),創(chuàng)建主類啟動對象,啟動主類(主類在loader中為Start Class,其實就是spring boot應(yīng)用的啟動類,在spring的maven插件中被打包定義為Start class)
protected void launch(String[] args) throws Exception {
// 注冊URL protocol handler
JarFile.registerUrlProtocolHandler();
// 獲取fat jar中的archives(也就是三方j(luò)ar,class,以及其他資源文件),創(chuàng)建類加載器
ClassLoader classLoader = createClassLoader(getClassPathArchives());
// 獲取sub class(也就是spring boot 應(yīng)用的主類),使用啟動參數(shù)和創(chuàng)建好的class loader啟動
launch(args, getMainClass(), classLoader);
}
接下來,我們針對這三個步驟展開來講。
1注冊擴(kuò)展UrlProtocolHandler
其實這個方法相當(dāng)于在啟動java應(yīng)用時添加參數(shù):-Djava.protocol.handler.pkgs=xxx.xxx.xxx,其作用就是對Url類支持的協(xié)議進(jìn)行擴(kuò)展。多個指定的包的地址使用|來連接。
/**
* 翻譯:注冊一個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() {
// 獲取當(dāng)前jvm中的handler參數(shù)
String handlers = System.getProperty(PROTOCOL_HANDLER, "");
// 如果已有參數(shù)為空,則直接指定springboot的handler,否則|拼接進(jìn)行擴(kuò)展
System.setProperty(PROTOCOL_HANDLER,
("".equals(handlers) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE));
// 最后重置緩存的handler
resetCachedUrlHandlers();
}
那Spring boot擴(kuò)展這個來干嘛呢,方法注釋上說時為了定位URLStreamHandler來處理jar文件,后續(xù)我們分析的過程中再繼續(xù)看。
- 獲取ClassPath下的文件資源
createClassLoader(getClassPathArchives());雖然第二個步驟只有一句話,但這其實就是SpringBoot可以直接啟動jar文件的核心邏輯,所以展開來講,首先是獲取jar中的資源文件。
getClassPathArchives在Launcher中是abstract的,其具體實現(xiàn)在ExecutableArchiveLauncher中。getClassPathArchives的實現(xiàn)其實代碼不多,核心方法是getNestedArchives(獲取嵌套的jar等文件)??吹竭@里其實我們就能稍微理解為什么Spring Boot能夠直接啟動并直接嵌套自身jar中的其他jar了,其邏輯就是通過某種方式解析并獲取jar(猜測是作為普通資源文件獲取,然后讀內(nèi)存或者寫到其他目錄,再加載進(jìn)來,不過因為我已經(jīng)讀過了,所以猜測其實是對的,哈哈哈)然后傳遞給自定義的classLoader加載,從而完成了依賴的jar的加載。
/**
* 獲取class path下的文件,jar啟動方式其實主要是獲取嵌套在fat jar中的其他三方j(luò)ar
*/
@Override
protected List<Archive> getClassPathArchives() throws Exception {
// 獲取嵌套的文檔文件,
List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive));
// 后置處理歸檔文件
postProcessClassPathArchives(archives);
// 返回結(jié)果
return archives;
}
看著這里其實有點疑惑,this.archive是啥,之前沒有提到過。JarLauncher剛剛是在main方法中無參new的,所以就是隱含的執(zhí)行ExecutableArchiveLauncher的無參的構(gòu)造方法,這個archive就是在那個時候?qū)嵗摹?/p>
public ExecutableArchiveLauncher() {
try {
this.archive = createArchive();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
ok,所以這個時候需要擱置剛才的邏輯,先看看這個archive是什么東西,才能接著看它是如何獲取jar中的archives的。
createArchive方法主要有兩個邏輯,首先是獲取當(dāng)前類對應(yīng)的絕對路徑。接著判斷,如果絕對路徑對應(yīng)的是目錄,則archive就是ExplodedArchive,當(dāng)前我們假設(shè)是用jar啟動的,那絕對路徑對應(yīng)的就是jar文件本省,此時this.archive就會被實例化成JarFileArchive。
看到這里就清楚了,this.archive是JarFileArchive的實例。所以,獲取jar中的archive邏輯就是在這個類中實現(xiàn)的。
ExplodedArchive的實現(xiàn)會用于war和properties的啟動形式的archives的獲取。
/**
* 創(chuàng)建Archive
*/
protected final Archive createArchive() throws Exception {
// 反射獲取當(dāng)前jar的 protectionDomain,可以理解為一個jar會對應(yīng)一個ProtectionDomain,主要是jar中資源的權(quán)限檢查和控制
ProtectionDomain protectionDomain = getClass().getProtectionDomain();
// 然后獲取當(dāng)前類的codeSource
CodeSource codeSource = protectionDomain.getCodeSource();
// 最后獲取當(dāng)前類的路徑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中的資源文件,包括三方j(luò)ar,class文件,資源文件等等。
首先看入口方法,方法的邏輯很簡單。首先是迭代自身,獲取entry,第二是包裝entry為Archive,最后返回。所以對應(yīng)的搞清楚這兩個邏輯,就能理解jar中的資源文件是如何解析的。
/**
* 獲取嵌套的archives
* @param filter the filter used to limit entries
*/
@Override
public List<Archive> getNestedArchives(EntryFilter filter) throws IOException {
List<Archive> nestedArchives = new ArrayList<>();
// 迭代自己本身,通過外部傳遞的isNestedArchive,JarLauncher中實現(xiàn)了,通過class的前綴判斷
for (Entry entry : this) {
if (filter.matches(entry)) {
// 通過entry,獲取并包裝為Archive
nestedArchives.add(getNestedArchive(entry));
}
}
// 包裝為不可變集合返回
return Collections.unmodifiableList(nestedArchives);
}
EntryIterator
首先是自身的迭代器,通過內(nèi)部類EntryIterator來實現(xiàn),這里的邏輯很簡單不贅述。核心就一句話,entries都是通過this.jarFile獲取的。所以核心邏輯就在JarFile類中 。
JarFile
基礎(chǔ)jdk的JarFile進(jìn)行擴(kuò)展的子類,類注釋上解釋說擴(kuò)展的功能有兩點。
- 獲取嵌套的jar中的任一目錄下的文件
- 獲取嵌套的jar中的jar文件
finally,到了解析自身jar最核心的邏輯了??赐闖arFile類就能明白~!
JarFileEntry
JarFile中有一個很重要的類: JarFileEntry,其類圖如下 :

首先是其實現(xiàn)了迭代接口,用于jar中entry的迭代遍歷。第二個比較重要的就是實現(xiàn)了中央目錄的Visitor,這個是核心。借助于CentralDirectoryParser類,在RandomAccessData(loader.dat下的類,輔助數(shù)據(jù)讀取)的幫助下,解析并遍歷了整個JarFile中的文件,然后JarFileEntry作為visitor被set到CentralDirectoryParser中,也完成了整個JarFile中的文件的遍歷,并將其緩存在entriesCache中。entriesCache是一個被同步的synchronizedMap包裹的LinkedHashMap。上文提到的EntryIterator迭代數(shù)據(jù)其實就來自于這里的map緩存的數(shù)據(jù)。
所以,loader的是如何解析jar中jar呢,還得繼續(xù)往前看,搞明白RandomAccessData和CentralDirectoryParser后,也許這次就真的弄明白了jar中jar的解析的代碼。
事實上這里其實才是整個loader項目中代碼量最大的地方。因為JarFile牽扯到整個jar路徑和data路徑的所有類。其互相配合,相互調(diào)用,雖然看起來清晰,但是要說明白還是要花點時間,這周先到這里,下周繼續(xù)填坑。