徹底透析SpringBoot jar可執行原理

請叫我紅領巾!發表於2019-07-16

​ 文章篇幅較長,但是包含了SpringBoot 可執行jar包從頭到尾的原理,請讀者耐心觀看。同時文章是基於SpringBoot-2.1.3進行分析。涉及的知識點主要包括Maven的生命週期以及自定義外掛,JDK提供關於jar包的工具類以及Springboot如何擴充套件,最後是自定義類載入器

spring-boot-maven-plugin

​ SpringBoot 的可執行jar包又稱fat jar ,是包含所有依賴的 jar 包,jar 包中嵌入了除 java 虛擬機器以外的所有依賴,是一個 all-in-one jar 包。普通外掛maven-jar-plugin生成的包和spring-boot-maven-plugin生成的包之間的直接區別,是fat jar中主要增加了兩部分,第一部分是lib目錄,存放的是Maven依賴的jar包檔案,第二部分是spring boot loader相關的類。

fat jar 目錄結構
├─BOOT-INF
│  ├─classes
│  └─lib
├─META-INF
│  ├─maven
│  ├─app.properties
│  ├─MANIFEST.MF      
└─org
    └─springframework
        └─boot
            └─loader
                ├─archive
                ├─data
                ├─jar
                └─util

​ 也就是說想要知道fat jar是如何生成的,就必須知道spring-boot-maven-plugin工作機制,而spring-boot-maven-plugin屬於自定義外掛,因此我們又必須知道,Maven的自定義外掛是如何工作的

Maven的自定義外掛

​ Maven 擁有三套相互獨立的生命週期: cleandefaultsite, 而每個生命週期包含一些phase階段, 階段是有順序的, 並且後面的階段依賴於前面的階段。生命週期的階段phase與外掛的目標goal相互繫結,用以完成實際的構建任務。

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>repackage</goal>
            </goals>
        </execution>
    </executions>
</plugin>

repackage目標對應的將執行到org.springframework.boot.maven.RepackageMojo#execute,該方法的主要邏輯是呼叫了org.springframework.boot.maven.RepackageMojo#repackage

private void repackage() throws MojoExecutionException {
     //獲取使用maven-jar-plugin生成的jar,最終的命名將加上.orignal字尾
   Artifact source = getSourceArtifact();
    //最終檔案,即Fat jar
   File target = getTargetFile();
    //獲取重新打包器,將重新打包成可執行jar檔案
   Repackager repackager = getRepackager(source.getFile());
    //查詢並過濾專案執行時依賴的jar
   Set<Artifact> artifacts = filterDependencies(this.project.getArtifacts(),
         getFilters(getAdditionalFilters()));
    //將artifacts轉換成libraries
   Libraries libraries = new ArtifactsLibraries(artifacts, this.requiresUnpack,
         getLog());
   try {
       //提供Spring Boot啟動指令碼
      LaunchScript launchScript = getLaunchScript();
       //執行重新打包邏輯,生成最後fat jar
      repackager.repackage(target, libraries, launchScript);
   }
   catch (IOException ex) {
      throw new MojoExecutionException(ex.getMessage(), ex);
   }
    //將source更新成 xxx.jar.orignal檔案
   updateArtifact(source, target, repackager.getBackupFile());
}

​ 我們關心一下org.springframework.boot.maven.RepackageMojo#getRepackager這個方法,知道Repackager是如何生成的,也就大致能夠推測出內在的打包邏輯。

private Repackager getRepackager(File source) {
   Repackager repackager = new Repackager(source, this.layoutFactory);
   repackager.addMainClassTimeoutWarningListener(
         new LoggingMainClassTimeoutWarningListener());
    //設定main class的名稱,如果不指定的話則會查詢第一個包含main方法的類,repacke最後將會設定org.springframework.boot.loader.JarLauncher
   repackager.setMainClass(this.mainClass);
   if (this.layout != null) {
      getLog().info("Layout: " + this.layout);
       //重點關心下layout 最終返回了 org.springframework.boot.loader.tools.Layouts.Jar
      repackager.setLayout(this.layout.layout());
   }
   return repackager;
}
/**
 * Executable JAR layout.
 */
public static class Jar implements RepackagingLayout {
   @Override
   public String getLauncherClassName() {
      return "org.springframework.boot.loader.JarLauncher";
   }
   @Override
   public String getLibraryDestination(String libraryName, LibraryScope scope) {
      return "BOOT-INF/lib/";
   }
   @Override
   public String getClassesLocation() {
      return "";
   }
   @Override
   public String getRepackagedClassesLocation() {
      return "BOOT-INF/classes/";
   }
   @Override
   public boolean isExecutable() {
      return true;
   }
}

layout我們可以將之翻譯為檔案佈局,或者目錄佈局,程式碼一看清晰明瞭,同時我們需要關注,也是下一個重點關注物件org.springframework.boot.loader.JarLauncher,從名字推斷,這很可能是返回可執行jar檔案的啟動類。

MANIFEST.MF檔案內容
Manifest-Version: 1.0
Implementation-Title: oneday-auth-server
Implementation-Version: 1.0.0-SNAPSHOT
Archiver-Version: Plexus Archiver
Built-By: oneday
Implementation-Vendor-Id: com.oneday
Spring-Boot-Version: 2.1.3.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.oneday.auth.Application
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Created-By: Apache Maven 3.3.9
Build-Jdk: 1.8.0_171

repackager生成的MANIFEST.MF檔案為以上資訊,可以看到兩個關鍵資訊Main-ClassStart-Class。我們可以進一步,程式的啟動入口並不是我們SpringBoot中定義的main,而是JarLauncher#main,而再在其中利用反射呼叫定義好的Start-Classmain方法

JarLauncher

重點類介紹
  • java.util.jar.JarFile JDK工具類提供的讀取jar檔案
  • org.springframework.boot.loader.jar.JarFileSpringboot-loader 繼承JDK提供JarFile
  • java.util.jar.JarEntryDK工具類提供的``jar```檔案條目
  • org.springframework.boot.loader.jar.JarEntry Springboot-loader 繼承JDK提供JarEntry
  • org.springframework.boot.loader.archive.Archive Springboot抽象出來的統一訪問資源的層
    • JarFileArchivejar包檔案的抽象
    • ExplodedArchive檔案目錄

​ 這裡重點描述一下JarFile的作用,每個JarFileArchive都會對應一個JarFile。在構造的時候會解析內部結構,去獲取jar包裡的各個檔案資料夾類。我們可以看一下該類的註釋。

/* Extended variant of {@link java.util.jar.JarFile} that behaves in the same way but
* offers the following additional functionality.
* <ul>
* <li>A nested {@link JarFile} can be {@link #getNestedJarFile(ZipEntry) obtained} based
* on any directory entry.</li>
* <li>A nested {@link JarFile} can be {@link #getNestedJarFile(ZipEntry) obtained} for
* embedded JAR files (as long as their entry is not compressed).</li>
**/ </ul>

jar裡的資源分隔符是!/,在JDK提供的JarFile URL只支援一個’!/‘,而Spring boot擴充套件了這個協議,讓它支援多個’!/‘,就可以表示jar in jar、jar in directory、fat jar的資源了。

自定義類載入機制
  • ​ 最基礎:Bootstrap ClassLoader(載入JDK的/lib目錄下的類)
  • ​ 次基礎:Extension ClassLoader(載入JDK的/lib/ext目錄下的類)
  • ​ 普通:Application ClassLoader(程式自己classpath下的類)

​ 首先需要關注雙親委派機制很重要的一點是,如果一個類可以被委派最基礎的ClassLoader載入,就不能讓高層的ClassLoader載入,這樣是為了範圍錯誤的引入了非JDK下但是類名一樣的類。其二,如果在這個機制下,由於fat jar中依賴的各個jar檔案,並不在程式自己classpath下,也就是說,如果我們採用雙親委派機制的話,根本獲取不到我們所依賴的jar包,因此我們需要修改雙親委派機制的查詢class的方法,自定義類載入機制

​ 先簡單的介紹Springboot2中LaunchedURLClassLoader,該類繼承了java.net.URLClassLoader,重寫了java.lang.ClassLoader#loadClass(java.lang.String, boolean),然後我們再探討他是如何修改雙親委派機制。

​ 在上面我們講到Spring boot支援多個’!/‘以表示多個jar,而我們的問題在於,如何解決查詢到這多個jar包。我們看一下LaunchedURLClassLoader的構造方法。

public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
   super(urls, parent);
}

urls註釋解釋道the URLs from which to load classes and resources,即fat jar包依賴的所有類和資源,將該urls引數傳遞給父類java.net.URLClassLoader,由父類的java.net.URLClassLoader#findClass執行查詢類方法,該類的查詢來源即構造方法傳遞進來的urls引數

//LaunchedURLClassLoader的實現
protected Class<?> loadClass(String name, boolean resolve)
      throws ClassNotFoundException {
   Handler.setUseFastConnectionExceptions(true);
   try {
      try {
          //嘗試根據類名去定義類所在的包,即java.lang.Package,確保jar in jar裡匹配的manifest能夠和關聯            //的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.
             
            //這裡異常表明,definePackageIfNecessary方法的作用實際上是預先過濾掉查詢不到的包
            throw new AssertionError("Package " + name + " has already been "
                  + "defined but it could not be found");
         }
      }
      return super.loadClass(name, resolve);
   }
   finally {
      Handler.setUseFastConnectionExceptions(false);
   }
}

方法super.loadClass(name, resolve)實際上會回到了java.lang.ClassLoader#loadClass(java.lang.String, boolean),遵循雙親委派機制進行查詢類,而Bootstrap ClassLoaderExtension ClassLoader將會查詢不到fat jar依賴的類,最終會來到Application ClassLoader,呼叫java.net.URLClassLoader#findClass

如何真正的啟動

​ Springboot2和Springboot1的最大區別在於,Springboo1會新起一個執行緒,來執行相應的反射呼叫邏輯,而SpringBoot2則去掉了構建新的執行緒這一步。方法是org.springframework.boot.loader.Launcher#launch(java.lang.String[], java.lang.String, java.lang.ClassLoader)反射呼叫邏輯比較簡單,這裡就不再分析,比較關鍵的一點是,在呼叫main方法之前,將當前執行緒的上下文類載入器設定成LaunchedURLClassLoader

protected void launch(String[] args, String mainClass, ClassLoader classLoader)
      throws Exception {
   Thread.currentThread().setContextClassLoader(classLoader);
   createMainMethodRunner(mainClass, args, classLoader).run();
}

Demo

public static void main(String[] args) throws ClassNotFoundException, MalformedURLException {
        JarFile.registerUrlProtocolHandler();
// 構造LaunchedURLClassLoader類載入器,這裡使用了2個URL,分別對應jar包中依賴包spring-boot-loader和spring-boot,使用 "!/" 分開,需要org.springframework.boot.loader.jar.Handler處理器處理
        LaunchedURLClassLoader classLoader = new LaunchedURLClassLoader(
                new URL[] {
                        new URL("jar:file:/E:/IdeaProjects/oneday-auth/oneday-auth-server/target/oneday-auth-server-1.0.0-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-loader-1.2.3.RELEASE.jar!/")
                        , new URL("jar:file:/E:/IdeaProjects/oneday-auth/oneday-auth-server/target/oneday-auth-server-1.0.0-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-2.1.3.RELEASE.jar!/")
                },
                Application.class.getClassLoader());
// 載入類
// 這2個類都會在第二步本地查詢中被找出(URLClassLoader的findClass方法)
        classLoader.loadClass("org.springframework.boot.loader.JarLauncher");
        classLoader.loadClass("org.springframework.boot.SpringApplication");
// 在第三步使用預設的載入順序在ApplicationClassLoader中被找出
   classLoader.loadClass("org.springframework.boot.autoconfigure.web.DispatcherServletAutoConfiguration");

//        SpringApplication.run(Application.class, args);
    }

總結

​ 對於原始碼分析,這次的較大收穫則是不能一下子去追求弄懂原始碼中的每一步程式碼的邏輯,即便我知道該方法的作用。我們需要搞懂的是關鍵程式碼,以及涉及到的知識點。我從Maven的自定義外掛開始進行追蹤,鞏固了對Maven的知識點,在這個過程中甚至瞭解到JDK對jar的讀取是有提供對應的工具類。最後最重要的知識點則是自定義類載入器。整個程式碼下來並不是說程式碼究竟有多優秀,而是要學習他因何而優秀。

​ 作者:plz叫我紅領巾

​ 出處:juejin.im/post/5d15c4…

本部落格歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。碼字不易,您的點贊是我寫作的最大動力。

參考資料:
[SpringBoot原始碼分析之SpringBoot可執行檔案解析]: https://fangjian0423.github.io/2017/05/31/springboot-executable-jar

相關文章