Java打包FatJar方法小結

倚賢發表於2018-08-30

在函式計算(Aliyun FC)中釋出一個 Java 函式,往往需要將函式打包成一個 all-in-one 的 zip 包或者 jar 包。Java 中這種打包 all-in-one 的技術常稱之為 Fatjar 技術。本文小結一下 Java 裡打包 FatJar 的若干種方法。

什麼是 FatJar

FatJar 又稱作 uber-Jar,是包含所有依賴的 Jar 包。Jar 包中嵌入了除 java 虛擬機器以外的所有依賴。我們知道 Java 的依賴分為兩種, 零散的 .class 檔案和把多個 .class 檔案以 zip 格式打包而成 jar 檔案。FatJar 是一個 all-in-one Jar 包。FatJar 技術可以讓那些用於最終釋出的 Jar 便於部署和執行。

三種打包方法

我們知道 .java 原始碼檔案會被編譯器編譯成位元組碼.class 檔案。Java 虛擬機器執行的是 .class 檔案。一個 java 程式可以有很多個 .class檔案。這些 .class 檔案可以由 java 虛擬機器的類裝載器執行期裝載到記憶體裡。java 虛擬機器可以從某個目錄裝載所有的 .class 檔案,但是這些零散的.class 檔案並不便於分發。所有 java 支援把零散的.class 檔案打包成 zip 格式的 .jar 檔案,並且虛擬機器的類裝載器支援直接裝載 .jar 檔案。

一個正常的 java 程式會有若干個.class 檔案和所依賴的第三方庫的 jar 檔案組成。

1. 非遮蔽方法(Unshaded)

非遮蔽是相對於遮蔽而說的,可以理解為一種樸素的辦法。解壓所有 jar 檔案,再重新打包成一個新的單獨的 jar 檔案。

藉助 Maven Assembly Plugin 都可以輕鬆實現非遮蔽方法的打包。

Maven Assembly Plugin

Maven Assembly Plugin 是一個打包聚合外掛,其主要功能是把專案的編譯輸出協同依賴,模組,文件和其他檔案打包成一個獨立的釋出包。使用描述符(descriptor)來配置需要打包的物料組合。並預定義了常用的描述符,可供直接使用。

預定義描述符如下

  • bin 只打包編譯結果,幷包含 README, LICENSE 和 NOTICE 檔案,輸出檔案格式為 tar.gz, tar.bz2 和 zip。
  • jar-with-dependencies 打包編譯結果,並帶上所有的依賴,如果依賴的是 jar 包,jar 包會被解壓開,平鋪到最終的 uber-jar 裡去。輸出格式為 jar。
  • src 打包原始碼檔案。輸出格式為 tar.gz, tar.bz2 和 zip。
  • project 打包整個專案,除了部署輸出目錄 target 以外的所有檔案和目錄都會被打包。輸出格式為 tar.gz, tar.bz2 和 zip。

除了預定義的描述符,使用者也可以指定描述符,以滿足不同的打包需求。

打包成 uber-jar,需要使用預定義的 jar-with-dependencies 描述符:

在 pom.xml 中加入如下配置

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>CHOOSE LATEST VERSION HERE</version>
    <configuration>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
    </configuration>
    <executions>
        <execution>
            <id>assemble-all</id>
            <phase>package</phase>
            <goals>
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Gradle Java plugin

gradle 下打包一個非遮蔽的 jar 包,有不少外掛可以用,但是由於 gradle 自身的靈活性,可以直接用 groove 的 dsl 實現。

apply plugin: `java`

jar {
    from {
        (configurations.runtime).collect {
            it.isDirectory() ? it : zipTree(it)
        }
    }
}

非遮蔽方法會把所有的 jar 包裡的檔案都解壓到一個目錄裡,然後在打包同一個 fatjar 中。對於複雜應用很可能會碰到同名類相互覆蓋問題。

2. 遮蔽方法(Shaded)

遮蔽方法會把依賴包裡的類路徑進行修改到某個子路徑下,這樣可以一定程度上避免同名類相互覆蓋的問題。最終釋出的 jar 也不會帶入傳遞依賴衝突問題給下游。

Maven Shade Plugin

在 pom.xml 中加入如下配置

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>3.1.1</version>
    <configuration>
        <!-- put your configurations here -->
    </configuration>
    <executions>
        <execution>
        <phase>package</phase>
        <goals>
            <goal>shade</goal>
        </goals>
        </execution>
    </executions>
 </plugin>

Gradle Shadow plugin

Gradle shadow plugin 使用非常簡單,簡單宣告外掛後就可以生效。

plugins {
  id `com.github.johnrengelman.shadow` version `2.0.4`
  id `java`
}

shadowJar {
   include `*.jar`
   include `*.properties`
   exclude `a2.properties`
}

遮蔽方法依賴修改 class 的位元組碼,更新依賴檔案的包路徑達到規避同名同包類衝突的問題,但是改名也會帶來其他問題,比如程式碼中使用 Class.forName 或 ClassLoader.loadClass 裝載的類,Shade Plugin 是感知不到的。同名檔案覆蓋問題也沒法杜絕,比如META-INF/services/javax.script.ScriptEngineFactory不屬於類檔案,但是被覆蓋後會出現問題。

3. 巢狀方法(Jar of Jars)

還是一種辦法就是在 jar 包裡巢狀其他 jar,這個方法可以徹底避免解壓同名覆蓋的問題,但是這個方法不被 JVM 原生支援,因為 JDK 提供的 ClassLoader 僅支援裝載巢狀 jar 包的 class 檔案。所以這種方法需要自定義 ClassLoader 以支援巢狀 jar。

Onejar Maven Plugin

One-JAR 就是一個基於上面巢狀 jar 實現的工具。onejar-maven-plugin 是社群基於 onejar 實現的 maven 外掛。

<plugin>
    <groupId>com.jolira</groupId>
    <artifactId>onejar-maven-plugin</artifactId>
    <version>1.4.4</version>
    <executions>
        <execution>
            <goals>
                <goal>one-jar</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Spring boot plugin

One-JAR 有點年久失修,好久沒有維護了,Spring Boot 提供的 Maven Plugin 也可以打包 Fatjar,支援非遮蔽和巢狀的混合模式,並且支援 maven 和 gradle 。

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
        <layout>ZIP</layout>
        <requiresUnpack>
            <dependency>
                <groupId>org.jruby</groupId>
                <artifactId>jruby-complete</artifactId>
            </dependency>
        </requiresUnpack>
    </configuration>
</plugin>
plugins {
    id `org.springframework.boot` version `2.0.4.RELEASE`
}

bootJar {
    requiresUnpack `**/jruby-complete-*.jar`
}

requiresUnpack 引數可以定製那些 jar 不希望被解壓,採用巢狀的方式打包到 Fatjar 內部。

其打包後的內部結構為

example.jar
 |
 +-META-INF
 |  +-MANIFEST.MF
 +-org
 |  +-springframework
 |     +-boot
 |        +-loader
 |           +-<spring boot loader classes>
 +-BOOT-INF
    +-classes
    |  +-mycompany
    |     +-project
    |        +-YourClasses.class
    +-lib
       +-dependency1.jar
       +-dependency2.jar

應用的類檔案被防止到 BOOT-INF/classes 目錄,依賴包被放置到 BOOT-INF/lib 目錄。

檢視 META-INF/MANIFEST.MF 檔案,其內容為

Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.mycompany.project.MyApplication

啟動類是固定的 org.springframework.boot.loader.JarLauncher,應用程式的入口類需要配置成 Start-Class。這樣做的目的主要是為了支援巢狀 jar 包的類裝載,替換掉預設的 ClassLoader。

但是函式計算需要的 jar 包是一種打包結構,在服務端執行時會解壓開,不會呼叫 Main-Class。所以自定義 ClassLoader 是不生效的,所以不要使用巢狀 jar 結構,除非在入口函式指定重新定義 ClassLoader 或者 Classpath 以支援 BOOT-INF/classes 和 BOOT-INF/lib 這樣的定製化的類路徑。

小結

外掛 構建平臺 工作機制
maven-assembly-plugin maven Unshaded
Gradle Java plugin gradle Unshaded
maven-shade-plugin maven Shaded
com.github.johnrengelman.shadow gradle Shaded
Onejar ant, maven Jar of Jars
Spring boot plugin maven, gradle Unshaded, Jar of Jars

單從 Fatjar 的角度看, Spring boot maven/gradle 做得最精緻。但是 jar 包內部的自定義路徑解壓開以後和函式計算是不相容的。所以如果用於函式計算打包,建議使用 Unshaded 或者 Shared 的打包方式,但是需要自己注意檔案覆蓋問題。

參考閱讀

  1. https://imagej.net/Uber-JAR
  2. https://softwareengineering.stackexchange.com/questions/297276/what-is-a-shaded-java-dependency
  3. https://docs.spring.io/spring-boot/docs/current/reference/html/executable-jar.html


相關文章