很多初學者會比較困惑,Spring Boot 是如何做到將應用程式碼和所有的依賴打包成一個獨立的 Jar 包,因為傳統的 Java 專案打包成 Jar 包之後,需要通過 -classpath 屬性來指定依賴,才能夠執行。我們今天就來分析講解一下 SpringBoot 的啟動原理。
Spring Boot 打包外掛
Spring Boot 提供了一個名叫 spring-boot-maven-plugin
的 maven 專案打包外掛,如下:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
可以方便的將 Spring Boot 專案打成 jar 包。 這樣我們就不再需要部署 Tomcat 、Jetty等之類的 Web 伺服器容器啦。
我們先看一下 Spring Boot 打包後的結構是什麼樣的,開啟 target 目錄我們發現有兩個jar包:
其中,springboot-0.0.1-SNAPSHOT.jar
是通過 Spring Boot 提供的打包外掛採用新的格式打成 Fat Jar,包含了所有的依賴;
而 springboot-0.0.1-SNAPSHOT.jar.original
則是Java原生的打包方式生成的,僅僅只包含了專案本身的內容。
SpringBoot FatJar 的組織結構
我們將 Spring Boot 打的可執行 Jar 展開後的結構如下所示:
- BOOT-INF目錄:包含了我們的專案程式碼(classes目錄),以及所需要的依賴(lib 目錄);
- META-INF目錄:通過
MANIFEST.MF
檔案提供 Jar包的後設資料,宣告瞭 jar 的啟動類; org.springframework.boot.loader
:Spring Boot 的載入器程式碼,實現的 Jar in Jar 載入的魔法源。
我們看到,如果去掉BOOT-INF
目錄,這將是一個非常普通且標準的Jar包,包括元資訊以及可執行的程式碼部分,其/META-INF/MAINFEST.MF
指定了Jar包的啟動元資訊,org.springframework.boot.loader
執行對應的邏輯操作。
MAINFEST.MF 元資訊
元資訊內容如下所示:
Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Implementation-Title: springboot
Implementation-Version: 0.0.1-SNAPSHOT
Spring-Boot-Layers-Index: BOOT-INF/layers.idx
Start-Class: com.listenvision.SpringbootApplication
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Build-Jdk-Spec: 1.8
Spring-Boot-Version: 2.5.6
Created-By: Maven Jar Plugin 3.2.0
Main-Class: org.springframework.boot.loader.JarLauncher
它相當於一個 Properties 配置檔案,每一行都是一個配置專案。重點來看看兩個配置項:
- Main-Class 配置項:Java 規定的 jar 包的啟動類,這裡設定為 spring-boot-loader 專案的 JarLauncher 類,進行 Spring Boot 應用的啟動。
- Start-Class 配置項:Spring Boot 規定的主啟動類,這裡設定為我們定義的 Application 類。
- Spring-Boot-Classes 配置項:指定載入應用類的入口。
- Spring-Boot-Lib 配置項: 指定載入應用依賴的庫。
啟動原理
Spring Boot 的啟動原理如下圖所示:
原始碼分析
JarLauncher
JarLauncher 類是針對 Spring Boot jar 包的啟動類, 完整的類圖如下所示:
其中的 WarLauncher 類,是針對 Spring Boot war 包的啟動類。 啟動類 org.springframework.boot.loader.JarLauncher
並非為專案中引入類,而是 spring-boot-maven-plugin
外掛 repackage 追加進去的。
接下來我們先來看一下 JarLauncher 的原始碼,比較簡單,如下圖所示:
public class JarLauncher extends ExecutableArchiveLauncher {
private static final String DEFAULT_CLASSPATH_INDEX_LOCATION = "BOOT-INF/classpath.idx";
static final EntryFilter NESTED_ARCHIVE_ENTRY_FILTER = (entry) -> {
if (entry.isDirectory()) {
return entry.getName().equals("BOOT-INF/classes/");
}
return entry.getName().startsWith("BOOT-INF/lib/");
};
public JarLauncher() {
}
protected JarLauncher(Archive archive) {
super(archive);
}
@Override
protected ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException {
// Only needed for exploded archives, regular ones already have a defined order
if (archive instanceof ExplodedArchive) {
String location = getClassPathIndexFileLocation(archive);
return ClassPathIndexFile.loadIfPossible(archive.getUrl(), location);
}
return super.getClassPathIndex(archive);
}
private String getClassPathIndexFileLocation(Archive archive) throws IOException {
Manifest manifest = archive.getManifest();
Attributes attributes = (manifest != null) ? manifest.getMainAttributes() : null;
String location = (attributes != null) ? attributes.getValue(BOOT_CLASSPATH_INDEX_ATTRIBUTE) : null;
return (location != null) ? location : DEFAULT_CLASSPATH_INDEX_LOCATION;
}
@Override
protected boolean isPostProcessingClassPathArchives() {
return false;
}
@Override
protected boolean isSearchCandidate(Archive.Entry entry) {
return entry.getName().startsWith("BOOT-INF/");
}
@Override
protected boolean isNestedArchive(Archive.Entry entry) {
return NESTED_ARCHIVE_ENTRY_FILTER.matches(entry);
}
public static void main(String[] args) throws Exception {
//呼叫基類 Launcher 定義的 launch 方法
new JarLauncher().launch(args);
}
}
主要看它的 main 方法,呼叫的是基類 Launcher 定義的 launch 方法,而 Launcher 是ExecutableArchiveLauncher
的父類。下面我們來看看Launcher
基類原始碼:
Launcher
public abstract class Launcher {
private static final String JAR_MODE_LAUNCHER = "org.springframework.boot.loader.jarmode.JarModeLauncher";
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);
}
@Deprecated
protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {
return createClassLoader(archives.iterator());
}
protected ClassLoader createClassLoader(Iterator<Archive> archives) throws Exception {
List<URL> urls = new ArrayList<>(50);
while (archives.hasNext()) {
urls.add(archives.next().getUrl());
}
return createClassLoader(urls.toArray(new URL[0]));
}
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
return new LaunchedURLClassLoader(isExploded(), getArchive(), urls, getClass().getClassLoader());
}
protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception {
Thread.currentThread().setContextClassLoader(classLoader);
createMainMethodRunner(launchClass, args, classLoader).run();
}
protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
return new MainMethodRunner(mainClass, args);
}
protected abstract String getMainClass() throws Exception;
protected Iterator<Archive> getClassPathArchivesIterator() throws Exception {
return getClassPathArchives().iterator();
}
@Deprecated
protected List<Archive> getClassPathArchives() throws Exception {
throw new IllegalStateException("Unexpected call to getClassPathArchives()");
}
protected final Archive createArchive() throws Exception {
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));
}
protected boolean isExploded() {
return false;
}
protected Archive getArchive() {
return null;
}
}
- launch 方法會首先建立類載入器,而後判斷是否 jar 是否在
MANIFEST.MF
檔案中設定了jarmode
屬性。 - 如果沒有設定,launchClass 的值就來自
getMainClass()
返回,該方法由PropertiesLauncher
子類實現,返回 MANIFEST.MF 中配置的Start-Class
屬性值。 - 呼叫
createMainMethodRunner
方法,構建一個MainMethodRunner
物件並呼叫其 run 方法。
PropertiesLauncher
@Override
protected String getMainClass() throws Exception {
//載入 jar包 target目錄下的 MANIFEST.MF 檔案中 Start-Class配置,找到springboot的啟動類
String mainClass = getProperty(MAIN, "Start-Class");
if (mainClass == null) {
throw new IllegalStateException("No '" + MAIN + "' or 'Start-Class' specified");
}
return mainClass;
}
MainMethodRunner
目標類main方法的執行器,此時的 mainClassName 被賦值為 MANIFEST.MF 中配置的 Start-Class 屬性值,也就是 com.listenvision.SpringbootApplication
,之後便是通過反射執行 SpringbootApplication 的 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 {
Class<?> mainClass = Class.forName(this.mainClassName, false, Thread.currentThread().getContextClassLoader());
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
mainMethod.setAccessible(true);
mainMethod.invoke(null, new Object[] { this.args });
}
}
總結
- jar 包類似於 zip 壓縮檔案,只不過相比 zip 檔案多了一個
META-INF/MANIFEST.MF
檔案,該檔案在構建 jar 包時自動建立。 - Spring Boot 提供了一個外掛 spring-boot-maven-plugin ,用於把程式打包成一個可執行的jar包。
- 使用 java -jar 啟動 Spring Boot 的 jar 包,首先呼叫的入口類是
JarLauncher
,內部呼叫Launcher
的 launch 後構建MainMethodRunner
物件,最終通過反射呼叫 SpringbootApplication 的 main 方法實現啟動效果。