不得不說 SpringBoot 太複雜了,我本來只想研究一下 SpringBoot 最簡單的 HelloWorld 程式是如何從 main 方法一步一步跑起來的,但是這卻是一個相當深的坑。你可以試著沿著呼叫棧程式碼一層一層的深入進去,如果你不打斷點,你根本不知道接下來程式會往哪裡流動。這個不同於我研究過去的 Go 語言、Python 語言框架,它們通常都非常直接了當,設計上清晰易懂,程式碼寫起來簡單,裡面的實現同樣也很簡單。但是 SpringBoot 不是,它的外表輕巧簡單,但是它的裡面就像一隻巨大的怪獸,這隻怪獸有千百隻腳把自己纏繞在一起,把愛研究原始碼的讀者繞的暈頭轉向。但是這 Java 程式設計的世界 SpringBoot 就是老大哥,你卻不得不服。即使你的心中有千萬頭草泥馬在奔跑,但是它就是天下第一。如果你是一個學院派的程式設計師,看到這種現象你會懷疑人生,你不得不接受一個規則 —— 受市場最歡迎的未必就是設計的最好的,裡面夾雜著太多其它的非理性因素。
經過了一番痛苦的折磨,我還是把 SpringBoot 的執行原理摸清楚了,這裡分享給大家。
Hello World
首先我們看看 SpringBoot 簡單的 Hello World 程式碼,就兩個檔案 HelloControll.java 和 Application.java,執行 Application.java 就可以跑起來一個簡單的 RESTFul Web 伺服器了。
// HelloController.java
package hello;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
@RestController
public class HelloController {
@RequestMapping("/")
public String index() {
return "Greetings from Spring Boot!";
}
}
// Application.java
package hello;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
複製程式碼
當我開啟瀏覽器看到伺服器正常地將輸出呈現在瀏覽器的時候,我不禁大呼 —— SpringBoot 真他媽太簡單了。
但是問題來了,在 Application 的 main 方法裡我壓根沒有任何地方引用 HelloController 類,那麼它的程式碼又是如何被伺服器呼叫起來的呢?這就需要深入到 SpringApplication.run() 方法中看個究竟了。不過即使不看程式碼,我們也很容易有這樣的猜想,SpringBoot 肯定是在某個地方掃描了當前的 package,將帶有 RestController 註解的類作為 MVC 層的 Controller 自動註冊進了 Tomcat Server。
還有一個讓人不爽的地方是 SpringBoot 啟動太慢了,一個簡單的 Hello World 啟動居然還需要長達 5 秒,要是再複雜一些的專案這樣龜漫的啟動速度那真是不好想象了。
再抱怨一下,這個簡單的 HelloWorld 雖然 pom 裡只配置了一個 maven 依賴,但是傳遞下去,它一共依賴了 36 個 jar 包,其中以 spring 開頭的 jar 包有 15 個。說這是依賴地獄真一點不為過。
批評到這裡就差不多了,下面就要正是進入主題了,看看 SpringBoot 的 main 方法到底是如何跑起來的。
SpringBoot 的堆疊
瞭解 SpringBoot 執行的最簡單的方法就是看它的呼叫堆疊,下面這個啟動呼叫堆疊還不是太深,我沒什麼可抱怨的。
public class TomcatServer {
@Override
public void start() throws WebServerException {
...
}
}
複製程式碼
接下來再看看執行時堆疊,看看一個 HTTP 請求的呼叫棧有多深。不看不知道一看嚇了一大跳!
我通過將 IDE 視窗全屏化,並將其它的控制檯視窗原始碼視窗統統最小化,總算勉強一個螢幕裝下了整個呼叫堆疊。不過轉念一想,這也不怪 SpringBoot,絕大多數都是 Tomcat 的呼叫堆疊,跟 SpringBoot 相關的只有不到 10 層。
探索 ClassLoader
SpringBoot 還有一個特色的地方在於打包時它使用了 FatJar 技術將所有的依賴 jar 包一起放進了最終的 jar 包中的 BOOT-INF/lib 目錄中,當前專案的 class 被統一放到了 BOOT-INF/classes 目錄中。
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
複製程式碼
這不同於我們平時經常使用的 maven shade 外掛,將所有的依賴 jar 包中的 class 檔案解包出來後再密密麻麻的塞進統一的 jar 包中。下面我們將 springboot 打包的 jar 包解壓出來看看它的目錄結構。
├── BOOT-INF
│ ├── classes
│ │ └── hello
│ └── lib
│ ├── classmate-1.3.4.jar
│ ├── hibernate-validator-6.0.12.Final.jar
│ ├── jackson-annotations-2.9.0.jar
│ ├── jackson-core-2.9.6.jar
│ ├── jackson-databind-2.9.6.jar
│ ├── jackson-datatype-jdk8-2.9.6.jar
│ ├── jackson-datatype-jsr310-2.9.6.jar
│ ├── jackson-module-parameter-names-2.9.6.jar
│ ├── javax.annotation-api-1.3.2.jar
│ ├── jboss-logging-3.3.2.Final.jar
│ ├── jul-to-slf4j-1.7.25.jar
│ ├── log4j-api-2.10.0.jar
│ ├── log4j-to-slf4j-2.10.0.jar
│ ├── logback-classic-1.2.3.jar
│ ├── logback-core-1.2.3.jar
│ ├── slf4j-api-1.7.25.jar
│ ├── snakeyaml-1.19.jar
│ ├── spring-aop-5.0.9.RELEASE.jar
│ ├── spring-beans-5.0.9.RELEASE.jar
│ ├── spring-boot-2.0.5.RELEASE.jar
│ ├── spring-boot-autoconfigure-2.0.5.RELEASE.jar
│ ├── spring-boot-starter-2.0.5.RELEASE.jar
│ ├── spring-boot-starter-json-2.0.5.RELEASE.jar
│ ├── spring-boot-starter-logging-2.0.5.RELEASE.jar
│ ├── spring-boot-starter-tomcat-2.0.5.RELEASE.jar
│ ├── spring-boot-starter-web-2.0.5.RELEASE.jar
│ ├── spring-context-5.0.9.RELEASE.jar
│ ├── spring-core-5.0.9.RELEASE.jar
│ ├── spring-expression-5.0.9.RELEASE.jar
│ ├── spring-jcl-5.0.9.RELEASE.jar
│ ├── spring-web-5.0.9.RELEASE.jar
│ ├── spring-webmvc-5.0.9.RELEASE.jar
│ ├── tomcat-embed-core-8.5.34.jar
│ ├── tomcat-embed-el-8.5.34.jar
│ ├── tomcat-embed-websocket-8.5.34.jar
│ └── validation-api-2.0.1.Final.jar
├── META-INF
│ ├── MANIFEST.MF
│ └── maven
│ └── org.springframework
└── org
└── springframework
└── boot
複製程式碼
這種打包方式的優勢在於最終的 jar 包結構很清晰,所有的依賴一目瞭然。如果使用 maven shade 會將所有的 class 檔案混亂堆積在一起,是無法看清其中的依賴。而最終生成的 jar 包在體積上兩也者幾乎是相等的。
在執行機制上,使用 FatJar 技術執行程式是需要對 jar 包進行改造的,它還需要自定義自己的 ClassLoader 來載入 jar 包裡面 lib 目錄中巢狀的 jar 包中的類。我們可以對比一下兩者的 MANIFEST 檔案就可以看出明顯差異
// Generated by Maven Shade Plugin
Manifest-Version: 1.0
Implementation-Title: gs-spring-boot
Implementation-Version: 0.1.0
Built-By: qianwp
Implementation-Vendor-Id: org.springframework
Created-By: Apache Maven 3.5.4
Build-Jdk: 1.8.0_191
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-bo
ot-starter-parent/gs-spring-boot
Main-Class: hello.Application
// Generated by SpringBootLoader Plugin
Manifest-Version: 1.0
Implementation-Title: gs-spring-boot
Implementation-Version: 0.1.0
Built-By: qianwp
Implementation-Vendor-Id: org.springframework
Spring-Boot-Version: 2.0.5.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: hello.Application
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Created-By: Apache Maven 3.5.4
Build-Jdk: 1.8.0_191
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-bo
ot-starter-parent/gs-spring-boot
複製程式碼
SpringBoot 將 jar 包中的 Main-Class 進行了替換,換成了 JarLauncher。還增加了一個 Start-Class 引數,這個引數對應的類才是真正的業務 main 方法入口。我們再看看這個 JarLaucher 具體幹了什麼
public class JarLauncher{
...
static void main(String[] args) {
new JarLauncher().launch(args);
}
protected void launch(String[] args) {
try {
JarFile.registerUrlProtocolHandler();
ClassLoader cl = createClassLoader(getClassPathArchives());
launch(args, getMainClass(), cl);
}
catch (Exception ex) {
ex.printStackTrace();
System.exit(1);
}
}
protected void launch(String[] args, String mcls, ClassLoader cl) {
Runnable runner = createMainMethodRunner(mcls, args, cl);
Thread runnerThread = new Thread(runner);
runnerThread.setContextClassLoader(classLoader);
runnerThread.setName(Thread.currentThread().getName());
runnerThread.start();
}
}
class MainMethodRunner {
@Override
public void run() {
try {
Thread th = Thread.currentThread();
ClassLoader cl = th.getContextClassLoader();
Class<?> mc = cl.loadClass(this.mainClassName);
Method mm = mc.getDeclaredMethod("main", String[].class);
if (mm == null) {
throw new IllegalStateException(this.mainClassName
+ " does not have a main method");
}
mm.invoke(null, new Object[] { this.args });
} catch (Exception ex) {
ex.printStackTrace();
System.exit(1);
}
}
}
複製程式碼
從原始碼中可以看出 JarLaucher 建立了一個特殊的 ClassLoader,然後由這個 ClassLoader 來另啟一個單獨的執行緒來載入 MainClass 並執行。
又一個問題來了,當 JVM 遇到一個不認識的類,BOOT-INF/lib 目錄裡又有那麼多 jar 包,它是如何知道去哪個 jar 包里載入呢?我們繼續看這個特別的 ClassLoader 的原始碼
class LaunchedURLClassLoader extends URLClassLoader {
...
private Class<?> doLoadClass(String name) {
if (this.rootClassLoader != null) {
return this.rootClassLoader.loadClass(name);
}
findPackage(name);
Class<?> cls = findClass(name);
return cls;
}
}
複製程式碼
這裡的 rootClassLoader 就是雙親委派模型裡的 ExtensionClassLoader ,JVM 內建的類會優先使用它來載入。如果不是內建的就去查詢這個類對應的 Package。
private void findPackage(final String name) {
int lastDot = name.lastIndexOf('.');
if (lastDot != -1) {
String packageName = name.substring(0, lastDot);
if (getPackage(packageName) == null) {
try {
definePackage(name, packageName);
} catch (Exception ex) {
// Swallow and continue
}
}
}
}
private final HashMap<String, Package> packages = new HashMap<>();
protected Package getPackage(String name) {
Package pkg;
synchronized (packages) {
pkg = packages.get(name);
}
if (pkg == null) {
if (parent != null) {
pkg = parent.getPackage(name);
} else {
pkg = Package.getSystemPackage(name);
}
if (pkg != null) {
synchronized (packages) {
Package pkg2 = packages.get(name);
if (pkg2 == null) {
packages.put(name, pkg);
} else {
pkg = pkg2;
}
}
}
}
return pkg;
}
private void definePackage(String name, String packageName) {
String path = name.replace('.', '/').concat(".class");
for (URL url : getURLs()) {
try {
if (url.getContent() instanceof JarFile) {
JarFile jf= (JarFile) url.getContent();
if (jf.getJarEntryData(path) != null && jf.getManifest() != null) {
definePackage(packageName, jf.getManifest(), url);
return null;
}
}
} catch (IOException ex) {
// Ignore
}
}
return null;
}
複製程式碼
ClassLoader 會在本地快取包名和 jar包路徑的對映關係,如果快取中找不到對應的包名,就必須去 jar 包中挨個遍歷搜尋,這個就比較緩慢了。不過同一個包名只會搜尋一次,下一次就可以直接從快取中得到對應的內嵌 jar 包路徑。
深層 jar 包的內嵌 class 的 URL 路徑長下面這樣,使用感嘆號 ! 分割
jar:file:/workspace/springboot-demo/target/application.jar!/BOOT-INF/lib/snakeyaml-1.19.jar!/org/yaml/snakeyaml/Yaml.class
複製程式碼
不過這個定製的 ClassLoader 只會用於打包執行時,在 IDE 開發環境中 main 方法還是直接使用系統類載入器載入執行的。
不得不說,SpringbootLoader 的設計還是很有意思的,它本身很輕量級,程式碼邏輯很獨立沒有其它依賴,它也是 SpringBoot 值得欣賞的點之一。
HelloController 自動註冊
還剩下最後一個問題,那就是 HelloController 沒有被程式碼引用,它是如何註冊到 Tomcat 服務中去的?它靠的是註解傳遞機制。
SpringBoot 深度依賴註解來完成配置的自動裝配工作,它自己發明了幾十個註解,確實嚴重增加了開發者的心智負擔,你需要仔細閱讀文件才能知道它是用來幹嘛的。Java 註解的形式和功能是分離的,它不同於 Python 的裝飾器是功能性的,Java 的註解就好比程式碼註釋,本身只有屬性,沒有邏輯,註解相應的功能由散落在其它地方的程式碼來完成,需要分析被註解的類結構才可以得到相應註解的屬性。那註解是又是如何傳遞的呢?
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@ComponentScan
public @interface SpringBootApplication {
...
}
public @interface ComponentScan {
String[] basePackages() default {};
}
複製程式碼
首先 main 方法可以看到的註解是 SpringBootApplication,這個註解又是由ComponentScan 註解來定義的,ComponentScan 註解會定義一個被掃描的包名稱,如果沒有顯示定義那就是當前的包路徑。SpringBoot 在遇到 ComponentScan 註解時會掃描對應包路徑下面的所有 Class,根據這些 Class 上標註的其它註解繼續進行後續處理。當它掃到 HelloController 類時發現它標註了 RestController 註解。
@RestController
public class HelloController {
...
}
@Controller
public @interface RestController {
}
複製程式碼
而 RestController 註解又標註了 Controller 註解。SpringBoot 對 Controller 註解進行了特殊處理,它會將 Controller 註解的類當成 URL 處理器註冊到 Servlet 的請求處理器中,在建立 Tomcat Server 時,會將請求處理器傳遞進去。HelloController 就是如此被自動裝配進 Tomcat 的。
掃描處理註解是一個非常繁瑣骯髒的活計,特別是這種用註解來註解註解(繞口)的高階使用方法,這種方法要少用慎用。SpringBoot 中有大量的註解相關程式碼,企圖理解這些程式碼是乏味無趣的沒有必要的,它只會把你的本來清醒的腦袋搞暈。SpringBoot 對於習慣使用的同學來說它是非常方便的,但是其內部實現程式碼不要輕易模仿,那絕對算不上模範 Java 程式碼。
最後老錢表示自己真的很討厭 SpringBoot 這隻怪獸,但是很無奈,這個世界人人都在使用它。這就好比老人們常常告誡年輕人的那句話:如果你改變不了世界,那就先適應這個世界吧!