該系列文章是筆者在學習 Spring Boot 過程中總結下來的,裡面涉及到相關原始碼,可能對讀者不太友好,請結合我的原始碼註釋 Spring Boot 原始碼分析 GitHub 地址 進行閱讀
Spring Boot 版本:2.2.x
最好對 Spring 原始碼有一定的瞭解,可以先檢視我的 《死磕 Spring 之 IoC 篇 - 文章導讀》 系列文章
如果該篇內容對您有幫助,麻煩點選一下“推薦”,也可以關注博主,感激不盡~
概述
Spring Boot 提供了 Maven 外掛 spring-boot-maven-plugin
,可以很方便的將我們的 Spring Boot 專案打成 jar
包或者 war
包。
考慮到部署的便利性,我們絕大多數(99.99%)的場景下,都會選擇打成 jar
包,這樣一來,我們就無需將專案部署於 Tomcat、Jetty 等 Servlet 容器中。
那麼,通過 Spring Boot 外掛生成的 jar
包是如何執行,並啟動 Spring Boot 應用的呢?這個就是本文的目的,我們一起來弄懂 Spring Boot jar
包的執行原理。
這裡,我通過 Spring Boot Maven Plugin 生成了一個 jar 包,其裡面的結構如下所示:
BOOT-INF
目錄,裡面儲存了我們自己 Spring Boot 專案編譯後的所有檔案,其中classes
目錄下面就是編譯後的 .class 檔案,包括專案中的配置檔案等,lib
目錄下就是我們引入的第三方依賴META-INF
目錄,通過MANIFEST.MF
檔案提供jar
包的後設資料,宣告jar
的啟動類等資訊。每個 Javajar
包應該是都有這個檔案的,參考 Oracle 官方對於jar
的說明,裡面有一個Main-Class
配置用於指定啟動類org.springframework.boot.loader
目錄,也就是 Spring Boot 的spring-boot-loader
工具模組,它就是java -jar xxx.jar
啟動 Spring Boot 專案的祕密所在,上面的Main-Class
指定的就是該工具模組中的一個類
MANIFEST.MF
META-INF/MANIFEST.MF
檔案如下:
Manifest-Version: 1.0
Implementation-Title: spring-boot-study
Implementation-Version: 1.0.0-SNAPSHOT
Built-By: jingping
Implementation-Vendor-Id: org.springframework.boot.demo
Spring-Boot-Version: 2.0.3.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher # spring-boot-loader 中的啟動類
Start-Class: org.springframework.boot.demo.Application # 你的 Spring Boot 專案中的啟動類
Spring-Boot-Classes: BOOT-INF/classes/ # 你的 Spring Boot 專案編譯後的 .class 檔案所在目錄
Spring-Boot-Lib: BOOT-INF/lib/ # 你的 Spring Boot 專案所引入的第三方依賴所在目錄
Created-By: Apache Maven 3.6.3
Build-Jdk: 1.8.0_251
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-boot-starter-parent/info-dependencies/dwzq-info/info-stock-project/sp-provider
參考 Oracle 官方對該的說明:
Main-Class
:Java 規定的jar
包的啟動類,這裡設定為spring-boot-loader
專案的 JarLauncher 類,進行 Spring Boot 應用的啟動Start-Class
:Spring Boot 規定的主啟動類,這裡通過 Spring Boot Maven Plugin 外掛打包時,會設定為我們定義的 Application 啟動類
為什麼不直接將我們的 Application 啟動類設定為 Main-Class
啟動呢?
因為通過 Spring Boot Maven Plugin 外掛打包後的
jar
包,我們的 .class 檔案在BOOT-INF/classes/
目錄下,在 Java 預設的jar
包載入規則下找不到我們的 Application 啟動類,也就需要通過 JarLauncher 啟動載入。當然,還有一個原因,Java 規定可執行器的
jar
包禁止巢狀其它jar
包,在BOOT-INF/lib
目錄下有我們 Spring Boot 應用依賴的所有第三方jar
包,因此spring-boot-loader
專案自定義實現了 ClassLoader 實現類 LaunchedURLClassLoader,支援載入BOOT-INF/classes
目錄下的.class
檔案,以及BOOT-INF/lib
目錄下的jar
包。
接下來,我們一起來看看 Spring Boot 的 JarLauncher 這個類
1. JarLauncher
類圖:
上面的 WarLauncher 是針對 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) {
// 只接受 `BOOT-INF/classes/` 目錄
if (entry.isDirectory()) {
return entry.getName().equals(BOOT_INF_CLASSES);
}
// 只接受 `BOOT-INF/lib/` 目錄下的 jar 包
return entry.getName().startsWith(BOOT_INF_LIB);
}
/**
* 這裡是 java -jar 啟動 SpringBoot 打包後的 jar 包的入口
* 可檢視 jar 包中的 META-INF/MANIFEST.MF 檔案(該檔案用於對 Java 應用進行配置)
* 參考 Oracle 官方對於 jar 的說明(https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html)
* 該檔案其中會有一個配置項:Main-Class: org.springframework.boot.loader.JarLauncher
* 這個配置表示會呼叫 JarLauncher#main(String[]) 方法,也就當前方法
*/
public static void main(String[] args) throws Exception {
// <1> 建立當前類的例項物件,會建立一個 Archive 物件(當前應用),可用於解析 jar 包(當前應用)中所有的資訊
// <2> 呼叫其 launch(String[]) 方法
new JarLauncher().launch(args);
}
}
可以看到它有個 main(String[])
方法,前面說到的 META-INF/MANIFEST.MF
檔案中的 Main-Class
配置就是指向了這個類,也就會呼叫這裡的 main
方法,會做下面兩件事:
-
建立一個
JarLauncher
例項物件,在ExecutableArchiveLauncher
父類中會做以下事情:public abstract class ExecutableArchiveLauncher extends Launcher { private final Archive archive; public ExecutableArchiveLauncher() { try { // 為當前應用建立一個 Archive 物件,可用於解析 jar 包(當前應用)中所有的資訊 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"); } // 當前 jar 包 File root = new File(path); if (!root.exists()) { throw new IllegalStateException("Unable to determine code source archive from " + root); } // 為當前 jar 包建立一個 JarFileArchive(根條目),需要通過它解析出 jar 包中的所有資訊 // 如果是資料夾的話則建立 ExplodedArchive(根條目) return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root)); } }
會為當前應用建立一個 Archive 物件,可用於解析
jar
包(當前應用)中所有的資訊,可以把它理解為一個“根”物件,可以通過它獲取我們所需要的類資訊 -
呼叫
JarLauncher#launch(String[])
方法,也就是呼叫父類Launcher
的這個方法
2. Launcher
org.springframework.boot.loader.Launcher
,Spring Boot 應用的啟動器
2. launch 方法
public abstract class Launcher {
/**
* 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 {
// <1> 註冊 URL(jar)協議的處理器
JarFile.registerUrlProtocolHandler();
// <2> 先從 `archive`(當前 jar 包應用)解析出所有的 JarFileArchive
// <3> 建立 Spring Boot 自定義的 ClassLoader 類載入器,可載入當前 jar 中所有的類
ClassLoader classLoader = createClassLoader(getClassPathArchives());
// <4> 獲取當前應用的啟動類(你自己寫的那個 main 方法)
// <5> 執行你的那個 main 方法
launch(args, getMainClass(), classLoader);
}
}
會做以下幾件事:
- 呼叫
JarFile#registerUrlProtocolHandler()
方法,註冊 URL(jar)協議的處理器,主要是使用自定義的 URLStreamHandler 處理器處理 jar 包 - 呼叫
getClassPathArchives()
方法,先從archive
(當前 jar 包應用)解析出所有的 JarFileArchive,這個archive
就是在上面建立JarLauncher
例項物件過程中建立的 - 呼叫
createClassLoader(List<Archive>)
方法,建立 Spring Boot 自定義的 ClassLoader 類載入器,可載入當前jar
包中所有的類,包括依賴的第三方包 - 呼叫
getMainClass()
方法,獲取當前應用的啟動類(你自己寫的那個main
方法所在的 Class 類物件) - 呼叫
launch(...)
方法,執行你的專案中那個啟動類的main
方法(反射)
你可以理解為會建立一個自定義的 ClassLoader 類載入器,主要可載入 BOOT-INF/classes
目錄下的類,以及 BOOT-INF/lib
目錄下的 jar
包中的類,然後呼叫你 Spring Boot 應用的啟動類的 main
方法
接下來我們逐步分析上面的每個步驟
2.1 registerUrlProtocolHandler 方法
備註:註冊 URL(jar)協議的處理器
這個方法在 org.springframework.boot.loader.jar.JarFile
中,這個類是 java.util.jar.JarFile
的子類,對它進行擴充套件,提供更多的功能,便於操作 jar
包
public static void registerUrlProtocolHandler() {
// <1> 獲取系統變數中的 `java.protocol.handler.pkgs` 配置的 URLStreamHandler 路徑
String handlers = System.getProperty(PROTOCOL_HANDLER, "");
// <2> 將 Spring Boot 自定義的 URL 協議處理器路徑(`org.springframework.boot.loader`)新增至系統變數中
// JVM 啟動時會獲取 `java.protocol.handler.pkgs` 屬性,多個用 `|` 分隔,以他們作為包名字首,然後使用 `包名字首.協議名.Handler` 作為該協議的實現
// 那麼這裡就會將 `org.springframework.boot.loader.jar.Handler` 作為 jar 包協議的實現
System.setProperty(PROTOCOL_HANDLER,
("".equals(handlers) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE));
// <3> 重置已快取的 URLStreamHandler 處理器們,避免重複建立
resetCachedUrlHandlers();
}
方法的處理過程如下:
-
獲取系統變數中的
java.protocol.handler.pkgs
配置的 URLStreamHandler 路徑 -
將 Spring Boot 自定義的 URL 協議處理器路徑(
org.springframework.boot.loader
)新增至系統變數中JVM 啟動時會獲取
java.protocol.handler.pkgs
屬性,多個用|
分隔,以他們作為包名字首,然後使用包名字首.協議名.Handler
作為該協議的實現那麼這裡就會將
org.springframework.boot.loader.jar.Handler
作為 jar 包協議的實現,用於處理 jar 包 -
重置已快取的 URLStreamHandler 處理器們,避免重複建立
private static void resetCachedUrlHandlers() { try { URL.setURLStreamHandlerFactory(null); } catch (Error ex) { // Ignore }}
2.2 getClassPathArchives 方法
備註:從
archive
(當前 jar 包應用)解析出所有的JarFileArchive
該方法在 org.springframework.boot.loader.ExecutableArchiveLauncher
子類中實現,如下:
@Overrideprotected List<Archive> getClassPathArchives() throws Exception { // <1> 建立一個 Archive.EntryFilter 類,用於判斷 Archive.Entry 是否匹配,過濾 jar 包(當前應用)以外的東西 // <2> 從 `archive`(當前 jar 包)解析出所有 Archive 條目資訊 List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive)); postProcessClassPathArchives(archives); // <3> 返回找到的所有 JarFileArchive // `BOOT-INF/classes/` 目錄對應一個 JarFileArchive(因為就是當前應用中的內容) // `BOOT-INF/lib/` 目錄下的每個 jar 包對應一個 JarFileArchive return archives;}
過程如下:
-
建立一個
Archive.EntryFilter
實現類,用於判斷Archive.Entry
是否匹配,過濾掉jar
包(當前應用)以外的東西public class JarLauncher extends ExecutableArchiveLauncher { static final String BOOT_INF_CLASSES = "BOOT-INF/classes/"; static final String BOOT_INF_LIB = "BOOT-INF/lib/"; @Override protected boolean isNestedArchive(Archive.Entry entry) { // 只接受 `BOOT-INF/classes/` 目錄 if (entry.isDirectory()) { return entry.getName().equals(BOOT_INF_CLASSES); } // 只接受 `BOOT-INF/lib/` 目錄下的 jar 包 return entry.getName().startsWith(BOOT_INF_LIB); }}
-
從
archive
(當前 jar 包)解析出所有 Archive 條目資訊,這個archive
在上面 1. JarLauncher 講到過,建立 JarLauncher 例項化物件的時候會初始化archive
,是一個JarFileArchive
物件,也就是我們打包後的jar
包,那麼接下來需要從中解析出所有的 Archive 物件// JarFileArchive.java@Overridepublic List<Archive> getNestedArchives(EntryFilter filter) throws IOException { List<Archive> nestedArchives = new ArrayList<>(); // 遍歷 jar 包(當前應用)中所有的 Entry for (Entry entry : this) { // 進行過濾,`BOOT-INF/classes/` 目錄或者 `BOOT-INF/lib/` 目錄下的 jar 包 if (filter.matches(entry)) { // 將 Entry 轉換成 JarFileArchive nestedArchives.add(getNestedArchive(entry)); } } // 返回 jar 包(當前應用)找到的所有 JarFileArchive // `BOOT-INF/classes/` 目錄對應一個 JarFileArchive(因為就是當前應用中的內容) // `BOOT-INF/lib/` 目錄下的每個 jar 包對應一個 JarFileArchive return Collections.unmodifiableList(nestedArchives);}
返回 jar 包(當前應用)找到的所有 JarFileArchive:
BOOT-INF/classes/
目錄對應一個 JarFileArchive(因為就是當前 Spring Boot 應用編譯後的內容)BOOT-INF/lib/
目錄下的每個 jar 包對應一個 JarFileArchive
-
返回從
jar
包中找到的所有 JarFileArchive
這一步驟就是從 jar
包中解析出我們需要的東西來,如上描述,每個 JarFileArchive 會對應一個 JarFile 物件
2.3 createClassLoader 方法
備註:建立 Spring Boot 自定義的 ClassLoader 類載入器,可載入當前 jar 中所有的類
protected ClassLoader createClassLoader(List<Archive> archives) throws Exception { // <1> 獲取所有 JarFileArchive 對應的 URL List<URL> urls = new ArrayList<>(archives.size()); for (Archive archive : archives) { urls.add(archive.getUrl()); } // <2> 建立 Spring Boot 自定義的 ClassLoader 類載入器,並設定父類載入器為當前執行緒的類載入器 // 通過它解析這些 URL,也就是載入 `BOOT-INF/classes/` 目錄下的類和 `BOOT-INF/lib/` 目錄下的所有 jar 包 return createClassLoader(urls.toArray(new URL[0]));}protected ClassLoader createClassLoader(URL[] urls) throws Exception { return new LaunchedURLClassLoader(urls, getClass().getClassLoader());}
該過程如下:
- 獲取所有 JarFileArchive 對應的 URL
- 建立 Spring Boot 自定義的 ClassLoader 類載入器,並設定父類載入器為當前執行緒的類載入器
可以看到 LaunchedURLClassLoader 為自定義類載入器,這樣就能從我們 jar
包中的 BOOT-INF/classes/
目錄下和 BOOT-INF/lib/
目錄下的所有三方依賴包中載入出 Class 類物件
2.4 getMainClass 方法
備註:獲取當前應用的啟動類(你自己寫的那個 main 方法)
// ExecutableArchiveLauncher.java@Overrideprotected String getMainClass() throws Exception { // 獲取 jar 包(當前應用)的 Manifest 物件,也就是 META-INF/MANIFEST.MF 檔案中的屬性 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
包(當前應用)的Manifest
物件,也就是META-INF/MANIFEST.MF
檔案中的屬性 - 獲取啟動類(當前應用自己的啟動類),也就是
Start-Class
配置,並返回
可以看到,這一步就是找到你 Spring Boot 應用的啟動類,前面 ClassLoader 類載入器都準備好了,那麼現在不就可以直接呼叫這個類的 main
方法來啟動應用了
2.5 launch 方法
備註:執行你的 Spring Boot 應用的啟動類的
main
方法
protected void launch(String[] args, String mainClass, ClassLoader classLoader) throws Exception { // 設定當前執行緒的 ClassLoader 為剛建立的類載入器 Thread.currentThread().setContextClassLoader(classLoader); // 建立一個 MainMethodRunner 物件(main 方法執行器) // 執行你的 main 方法(反射) createMainMethodRunner(mainClass, args, classLoader).run();}protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) { return new MainMethodRunner(mainClass, args);}
整個過程很簡單,先設定當前執行緒的 ClassLoader 為剛建立的類載入器,然後建立一個 MainMethodRunner 物件(main
方法執行器),執行你的 main
方法(反射),啟動 Spring Boot 應用
public class MainMethodRunner { private final String mainClassName; private final String[] args; public MainMethodRunner(String mainClass, String[] args) { this.mainClassName = mainClass; this.args = (args != null) ? args.clone() : null; } public void run() throws Exception { // 根據名稱載入 main 方法所在類的 Class 物件 Class<?> mainClass = Thread.currentThread().getContextClassLoader().loadClass(this.mainClassName); // 獲取 main 方法 Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); // 執行這個 main 方法(反射) mainMethod.invoke(null, new Object[] { this.args }); }}
這裡就是通過反射呼叫你的 Spring Boot 應用的啟動類的 main
方法
LaunchedURLClassLoader
org.springframework.boot.loader.LaunchedURLClassLoader
是 spring-boot-loader
中自定義的類載入器,實現對 jar
包中 BOOT-INF/classes
目錄下的類和 BOOT-INF/lib
下第三方 jar
包中的類的載入。
public class LaunchedURLClassLoader extends URLClassLoader { static { ClassLoader.registerAsParallelCapable(); } /** * Create a new {@link LaunchedURLClassLoader} instance. * @param urls the URLs from which to load classes and resources * @param parent the parent class loader for delegation */ public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) { super(urls, parent); } /** * 重寫類載入器中載入 Class 類物件方法 */ @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { Handler.setUseFastConnectionExceptions(true); try { try { // 判斷這個類是否有對應的 Package 包 // 沒有的話會從所有 URL(包括內部引入的所有 jar 包)中找到對應的 Package 包並進行設定 definePackageIfNecessary(name); } catch (IllegalArgumentException ex) { // Tolerate race condition due to being parallel capable if (getPackage(name) == null) { // This should never happen as the IllegalArgumentException indicates // that the package has already been defined and, therefore, // getPackage(name) should not return null. throw new AssertionError("Package " + name + " has already been defined but it could not be found"); } } // 載入對應的 Class 類物件 return super.loadClass(name, resolve); } finally { Handler.setUseFastConnectionExceptions(false); } } /** * Define a package before a {@code findClass} call is made. This is necessary to * ensure that the appropriate manifest for nested JARs is associated with the * package. * @param className the class name being found */ private void definePackageIfNecessary(String className) { int lastDot = className.lastIndexOf('.'); if (lastDot >= 0) { // 獲取包名 String packageName = className.substring(0, lastDot); // 沒找到對應的 Package 包則進行解析 if (getPackage(packageName) == null) { try { // 遍歷所有的 URL,從所有的 jar 包中找到這個類對應的 Package 包並進行設定 definePackage(className, packageName); } catch (IllegalArgumentException ex) { // Tolerate race condition due to being parallel capable if (getPackage(packageName) == null) { // This should never happen as the IllegalArgumentException // indicates that the package has already been defined and, // therefore, getPackage(name) should not have returned null. throw new AssertionError( "Package " + packageName + " has already been defined but it could not be found"); } } } } } private void definePackage(String className, String packageName) { try { AccessController.doPrivileged((PrivilegedExceptionAction<Object>) () -> { // 把類路徑解析成類名並加上 .class 字尾 String packageEntryName = packageName.replace('.', '/') + "/"; String classEntryName = className.replace('.', '/') + ".class"; // 遍歷所有的 URL(包括應用內部引入的所有 jar 包) for (URL url : getURLs()) { try { URLConnection connection = url.openConnection(); if (connection instanceof JarURLConnection) { JarFile jarFile = ((JarURLConnection) connection).getJarFile(); // 如果這個 jar 中存在這個類名,且有對應的 Manifest if (jarFile.getEntry(classEntryName) != null && jarFile.getEntry(packageEntryName) != null && jarFile.getManifest() != null) { // 定義這個類對應的 Package 包 definePackage(packageName, jarFile.getManifest(), url); return null; } } } catch (IOException ex) { // Ignore } } return null; }, AccessController.getContext()); } catch (java.security.PrivilegedActionException ex) { // Ignore } }}
上面的程式碼就不一一講述了,LaunchedURLClassLoader 重寫了 ClassLoader 的 loadClass(String, boolean)
載入 Class 類物件方法,在載入對應的 Class 類物件之前新增了一部分邏輯,會嘗試從 jar
包中定義 Package 包物件,這樣就能載入到對應的 Class 類物件。
總結
Spring Boot 提供了 Maven 外掛 spring-boot-maven-plugin
,可以很方便的將我們的 Spring Boot 專案打成 jar
包,jar
包中主要分為三個模組:
BOOT-INF
目錄,裡面儲存了我們自己 Spring Boot 專案編譯後的所有檔案,其中classes
目錄下面就是編譯後的 .class 檔案,包括專案中的配置檔案等,lib
目錄下就是我們引入的第三方依賴META-INF
目錄,通過MANIFEST.MF
檔案提供jar
包的後設資料,宣告jar
的啟動類等資訊。每個 Javajar
包應該是都有這個檔案的,參考 Oracle 官方對於jar
的說明,裡面有一個Main-Class
配置用於指定啟動類org.springframework.boot.loader
目錄,也就是 Spring Boot 的spring-boot-loader
子模組,它就是java -jar xxx.jar
啟動 Spring Boot 專案的祕密所在,上面的Main-Class
指定的就是裡面的一個類
通過 java -jar
啟動應用時,根據 Main-Class
配置會呼叫 org.springframework.boot.loader.JarLauncher
的main(String[])
方法;其中會先建立一個自定義的 ClassLoader 類載入器,可從BOOT-INF
目錄下載入出我們 Spring Boot 應用的 Class 類物件,包括依賴的第三方 jar
包中的 Class 類物件;然後根據 Start-Class
配置呼叫我們 Spring Boot 應用啟動類的 main(String[])
方法(反射),這樣也就啟動了應用,至於我們的 main(String[])
方法中做了哪些事情,也就是後續所講的內容。