摘要: 神奇的SpringBoot。java
Fundebug經受權轉載,版權歸原做者全部。程序員
不得不說 SpringBoot 太複雜了,我原本只想研究一下 SpringBoot 最簡單的 HelloWorld 程序是如何從 main 方法一步一步跑起來的,可是這倒是一個至關深的坑。你能夠試着沿着調用棧代碼一層一層的深刻進去,若是你不打斷點,你根本不知道接下來程序會往哪裏流動。這個不一樣於我研究過去的 Go 語言、Python 語言框架,它們一般都很是直接了當,設計上清晰易懂,代碼寫起來簡單,裏面的實現一樣也很簡單。可是 SpringBoot 不是,它的外表輕巧簡單,可是它的裏面就像一隻巨大的怪獸,這隻怪獸有千百隻腳把本身纏繞在一塊兒,把愛研究源碼的讀者繞的暈頭轉向。可是這 Java 編程的世界 SpringBoot 就是老大哥,你卻不得不服。即便你的心中有千萬頭草泥馬在奔跑,可是它就是天下第一。若是你是一個學院派的程序員,看到這種現象你會懷疑人生,你不得不接受一個規則 —— 受市場最歡迎的未必就是設計的最好的,裏面夾雜着太多其它的非理性因素。web
通過了一番痛苦的折磨,我仍是把 SpringBoot 的運行原理摸清楚了,這裏分享給你們。spring
首先咱們看看 SpringBoot 簡單的 Hello World 代碼,就兩個文件 HelloControll.java 和 Application.java,運行 Application.java 就能夠跑起來一個簡單的 RESTFul Web 服務器了。express
// 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。json
還有一個讓人不爽的地方是 SpringBoot 啓動太慢了,一個簡單的 Hello World 啓動竟然還須要長達 5 秒,要是再複雜一些的項目這樣龜漫的啓動速度那真是很差想象了。api
再抱怨一下,這個簡單的 HelloWorld 雖然 pom 裏只配置了一個 maven 依賴,可是傳遞下去,它一共依賴了 36 個 jar 包,其中以 spring 開頭的 jar 包有 15 個。說這是依賴地獄真一點不爲過。瀏覽器
批評到這裏就差很少了,下面就要正是進入主題了,看看 SpringBoot 的 main 方法究竟是如何跑起來的。緩存
瞭解 SpringBoot 運行的最簡單的方法就是看它的調用堆棧,下面這個啓動調用堆棧還不是太深,我沒什麼可抱怨的。
public class TomcatServer { @Override public void start() throws WebServerException { ... } }
接下來再看看運行時堆棧,看看一個 HTTP 請求的調用棧有多深。不看不知道一看嚇了一大跳!
我經過將 IDE 窗口全屏化,並將其它的控制檯窗口源碼窗口通通最小化,總算勉強一個屏幕裝下了整個調用堆棧。
不過轉念一想,這也不怪 SpringBoot,絕大多數都是 Tomcat 的調用堆棧,跟 SpringBoot 相關的只有不到 10 層。
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 沒有被代碼引用,它是如何註冊到 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 這隻怪獸,可是很無奈,這個世界人人都在使用它。這就比如老人們經常告誡年輕人的那句話:若是你改變不了世界,那就先適應這個世界吧!