Categories
程式開發

如何借助Graalvm和Picocli構建Java編寫的原生CLI應用


本文要點:

  • 開發人員想要以單個原生可執行性文件的形式分發其命令行應用。
  • GraalVM可以將Java應用編譯為單個原生鏡像,但是它有一些限制。
  • Picocli是一個在JVM之上編寫CLI應用的現代庫,它有助於克服GraalVM的局限性,包括在Windows上的局限性。
  • 在Windows上搭建GraalVM工具鏈以創建原生鏡像的過程並沒有很完善的文檔。

如何借助Graalvm和Picocli構建Java編寫的原生CLI應用 1

夢想:可執行的Java

Go已經成為編寫命令行應用的流行編程語言。這可能會有很多的原因,但是Go特別棒的一點在於它能夠將一個程序編譯為單個原生的可執行文件。這使得程序更加易於分發。

長期以來,Java程序都難以分發,這是因為它們需要在目標機器上安裝一個Java虛擬機。我們可以將最新的JVM與應用捆綁在一起,但是這樣的話,包的大小會增加近200MB。但是,事情正在往好的方向發展:Java 9引入了Java模塊系統(Java Module System,JPMS),其中包括了jlink工具,它允許應用創建自定義的、最小化的JRE,最小可以到30至40MB。 Java 14將會包括jpackage工具,借助該工具可以創建一個安裝器(installer),它能夠將這個最小化的JRE和應用包含在一起。

不過,對於命令行應用來講,安裝器並不理想。理想情況下,我們希望將CLI工具分發為“真正的”原生可執行文件,而不需要打包運行時。借助GraalVM,我們可以讓Java編寫的程序也實現這一點。

GraalVM

GraalVM是一個通用的虛擬機,可以運行JavaScript、Python、Ruby、R、基於JVM的語言(如Java、Scala、Clojure、Kotlin)和基於LLVM的語言(如C和C++)所編寫的應用程序。 GraalVM很有意思的一個方面在於,它允許我們混合多種編程語言:程序中的一部分可能會使用JavaScript、R、Python或Ruby編寫,這些組成部分可以通過Java進行調用,並且可以彼此共享數據。另一個特性是創建原生鏡像的能力,這也是我們將在本文中探討的內容。

GraalVM原生鏡像

GraalVM原生鏡像允許我們提前將Java代碼編譯為一個獨立的可執行文件,稱為原生鏡像。這個可執行文件包含了應用、庫、JDK,該文件不會運行在Java VM上,而是包含了來自另一個不同虛擬機的必要組件,如內存管理和線程調度,該虛擬機被稱為“ Substrate VM”。 Substrate VM是運行時組件的名稱(如deoptimizer、垃圾收集器、線程調度等等)。相對於Java VM,這樣形成的程序會有更快的啟動時間和更低的運行時內存佔用。

原生鏡像的限制

為了保證實現的小巧和簡潔,並實現積極的前期優化,原生鏡像不支持Java的所有特性。完整的限制可以參考項目的GitHub頁面。

其中,有兩個限制需要特別注意:

簡單來講,為了創建一個自包含的二進製文件,原生鏡像編譯器需要預先知道應用的所有類、它們的依賴以及它們所使用的資源。反射和資源通常需要配置。我們隨後將會看到一個這樣的例子。

Picocli

Picocli是一個現代庫和框架,適用於在JVM上構建命令行應用。它支持Java、Groovy、Kotlin和Scala。它推出的時間還不到3年,但是非常受歡迎,每月的下載量超過了50萬次。 Groovy語言使用它來實現其CliBuilder DSL。

Picocli致力於提供“最簡便的方式來創建富命令行應用,這種應用可以在JVM上和JVM之外運行”。它提供了彩色輸出、TAB鍵自動完成、子命令,與其他的JVM CLI相比,它還提供了一些獨特的特性,比如可否定選項、重複複合參數組、重複子命令和對引用參數的複雜處理。它的源代碼在單個文件中,因此我們可以選擇將其作為源代碼包含進來,避免添加依賴項。 Picocli對其豐富和細緻的文檔頗感自豪。

img

Picocli用到了反射,因此很容易受到GraalVM的Java原生鏡像的限制,但是它提供了一個註解處理器,該處理器會生成配置文件,從而解決了編譯期的限制。

具體的例子

接下來,我們看一個命令行工具的具體樣例,它將會使用Java編寫並編譯為單個原生可執行文件。在這個過程中,我們將會看到picocli庫的一些特性,它們有助於讓我們的工具更易於使用。

我們將會構建一個checksum CLI工具,它能夠接收一個名為-a--algorithm的選項和一個表示位置的參數,該參數表示要計算校驗和的文件。

我們希望用戶能夠像使用C++或其他語言編寫的應用那樣來使用我們的Java checksum工具。大致如下所示:

$ echo hi > hi.txt
$ checksum -a md5 hi.txt
764efa883dda1e11db47671c4a3bbd9e
$ checksum -a sha1 hi.txt
55ca6286e3e4f4fba5d0448333fa99fc5a404a73

這是對命令行應用的最低期望,但是我們不會滿足於這種最小公分母式的應用,而是會創建一個讓用戶滿意的出色CLI應用。那這意味著什麼,我們又該如何實現呢?

出色的應用是有幫助的

我們做出了一些權衡:選擇了命令行界面(command line interface,CLI),而不是圖形化用戶界面(graphical user interface,GUI),這意味著應用對新用戶來說並不太易於學習如何使用。我們可以通過提供良好的在線幫助來部分彌補這一不足。

在用戶通過-h--help選項請求幫助或者使用了非法的用戶輸入時,我們的應用應該展示幫助信息。當帶有V--version選項的時候,它應該展示版本信息。接下來,我們會看到picocli是如何簡化該過程的。

用戶體驗

通過在支持的平台上使用彩色輸出,我們可以讓應用對用戶更加友好。這不僅僅是看上去更漂亮,還能減少用戶的認知負擔:這種對比會使重要的信息,如命令、選項和參數,能夠從周圍的文本突出顯示出來。

基於picocli的應用所生成的幫助信息默認就會使用彩色輸出。我們的checksum樣例看上去如下所示:

如何借助Graalvm和Picocli構建Java編寫的原生CLI應用 2

一般而言,應用只有在交互式使用的時候才會輸出彩色文本,當執行腳本時,我們不希望日誌文件和ANSI轉義代碼混雜在一起。幸運的是,picocli會自動幫我們處理這個問題。這引入了下一個話題:好的CLI應用都設計為能夠與其他命令組合使用。

出色的CLI應用能夠與其他應用協作

Stdout與stderr

很多的CLI工具都使用標準I/O流,這樣的話,它們就可以與其他的工具進行組合。通常,細節決定成敗。當用戶請求幫助的時候,應用應該將使用幫助信息打印到標準輸出中。這允許用戶將輸出以管道的方式輸入到其他工具中,如grepless

另一方面,當遇到非法輸入的時候,錯誤信息和使用幫助信息應該打印到標準錯誤流中:如果我們程序的輸出作為其他程序的輸入的話,我們不希望錯誤信息破壞其他的程序。

退出碼

當程序結束的時候,它會返回一個退出狀態碼。通常來講,值為零的退出碼用來表示成功,而非零的退出碼則用來表示某種類型的失敗。

這就允許用戶通過使用&&將多個命令連接在一起,並且在這個序列中如果有命令失敗的話,整個序列都會停止。

默認情況下,對於非法的用戶輸入,picocli會返回2,而對於應用的業務邏輯中出現的異常則會返回1,否則返回零(一切正常)。當然,在應用中配置其他的退出碼是很容易的,但是對於checksum樣例來說,默認值就是可以的。

注意,picocli庫並不會調用System.exit,它只是返回一個整數,應用要負責確定是否調用System.exit

緊湊的代碼

在上面的章節中,我們描述了很多的功能。你可能會認為需要大量的代碼才能完成它們,但是大多數“標準的CLI行為”都是由picocli庫提供的。在我們的應用中,我們所需要做的就是定義選項和位置參數,並通過將類定義為CallableRunnable來實現我們的業務邏輯。在main方法中,我們用一行代碼就能啟動應用:

import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;
import picocli.CommandLine.Parameters;
import java.io.File;
import java.math.BigInteger;
import java.nio.file.Files;
import java.security.MessageDigest;
import java.util.concurrent.Callable;
@Command(name = "checksum", mixinStandardHelpOptions = true,
      version = "checksum 4.0",
  description = "Prints the checksum (MD5 by default) of a file to STDOUT.")
class CheckSum implements Callable {
  @Parameters(index = "0", arity = "1",
        description = "The file whose checksum to calculate.")
  private File file;
  @Option(names = {"-a", "--algorithm"},
    description = "MD5, SHA-1, SHA-256, ...")
  private String algorithm = "MD5";
  // 本样例实现了Callable,所以解析、错误处理以及对使用帮助或版本帮助的用户请求处理
  // 都可以在一行代码中实现。
  public static void main(String... args) {
    int exitCode = new CommandLine(new CheckSum()).execute(args);
    System.exit(exitCode);
  }
  @Override
  public Integer call() throws Exception { // the business logic...
    byte[] data = Files.readAllBytes(file.toPath());
    byte[] digest = MessageDigest.getInstance(algorithm).digest(data);
    String format = "%0" + (digest.length*2) + "x%n";
    System.out.printf(format, new BigInteger(1, digest));
    return 0;
  }
}

我們已經有了一個理想的Java工具程序。接下來,我們看一下如何將其轉換成一個原生的可執行文件。

原生鏡像

對於反射的配置

我們在前面提到過,原生鏡像編譯器有一些限制:支持反射,但是需要配置

這會影響基於picocli的應用:在運行時,picocli會使用反射去發現所有@Command註解標註的子命令,以及@Option@Parameters註解標註的命令選項和位置參數。

因此,我們需要向GraalVM提供一個配置文件,指明所有帶註解的類、方法和字段。這樣的配置文件如下所示:

[
  {
    "name" : "CheckSum",
    "allDeclaredConstructors" : true,
    "allPublicConstructors" : true,
    "allDeclaredMethods" : true,
    "allPublicMethods" : true,
    "fields" : [
      { "name" : "algorithm" },
      { "name" : "file" }
    ]
  },
  {
    "name" : "picocli.CommandLine$AutoHelpMixin",
    "allDeclaredConstructors" : true,
    "allPublicConstructors" : true,
    "allDeclaredMethods" : true,
    "allPublicMethods" : true,
    "fields" : [
      { "name" : "helpRequested" },
      { "name" : "versionRequested" }
    ]
  }
]

對於有很多選項的工具來說,這樣很快就會變得非常繁瑣,但幸運的是,我們並不需要手工來完成這項任務。

Picocli註解處理器

picocli-codegen模塊包含了一個註解處理器,它能夠根據picocli註解在編譯期就構建好一個模型,而不是在運行期。

在編譯時,註解處理器會在META-INF/native-image/picocli-generated/$project目錄生成Graal配置文件,它們會被包含在應用的jar中。其中,就包括 反射資源動態代理的配置文件。通過嵌入這些配置文件,我們的jar馬上就能支持Graal了。在生成原生鏡像時,大多數情況都不需要額外的配置了。

除此之外,註解處理器還會在編譯期立即將非法註解或屬性的錯誤展現出來,而不必等到運行時的測試階段,這樣會形成更短的反饋週期。

所以,我們需要做的就是使用類路徑下的picocli-codegen來編譯CheckSum.java源文件:

在Linux下,編譯CheckSum.java並創建一個checksum.jar。在Windows下,需要將這些命令的:路徑分隔符替換為;

mkdir classes
javac -cp .:picocli-4.2.0.jar:picocli-codegen-4.2.0.jar -d classes CheckSum.java
cd classes && jar -cvef CheckSum ../checksum.jar * && cd ..

在jar文件的META-INF/native-image/picocli-generated/目錄下,我們會看到生成的配置文件:

jar -tf checksum.jar
META-INF/
META-INF/MANIFEST.MF
CheckSum.class
META-INF/native-image/
META-INF/native-image/picocli-generated/
META-INF/native-image/picocli-generated/proxy-config.json
META-INF/native-image/picocli-generated/reflect-config.json
META-INF/native-image/picocli-generated/resource-config.json

我們的應用已經編寫完成了。下一步,我們要製作一個原生鏡像。

GraalVM原生鏡像工具鏈

要創建原生鏡像,我們需要安裝GraalVM,確保已安裝native-image工具,並根據構建所使用的OS安裝C/C++編譯器工具鏈。在這個過程中,我遇到了一些問題,希望下面的步驟能夠幫助其他開發人員解決相關的問題。

開發就是10%的靈感加上90%的環境搭建。

— 未知開發人員

安裝GraalVM

首先,安裝最新版本的GraalVM,在編寫本文的時候,版本為20.0。 GraalVM的起步指南頁面是掌握在各種操作系統和容器下安裝GraalVM最新指令的最佳地點。

安裝原生鏡像工具

GraalVM附帶了一個native-image生成器工具。在最近版本的GraalVM中,它需要預先下載並通過Graal Updater工具單獨進行安裝:

在Linux上為Java 11安裝native-image生成器工具。

gu install -L /path/to/native-image-installable-svm-java11-linux-amd64-20.0.0.jar

從20.0版本開始,對於Windows版本的GraalVM來說,這同樣是必要的。

關於更多細節,請參見GraalVM的參考指南原生鏡像章節。

安裝編譯器工具鏈

Linux和MacOS編譯器工具鏈

native-image的編譯依賴於本地工具鏈,所以在Linux和MacOS上,我們需要對應操作系統上可用的glibc-develzlib-devel(C庫和zlib的頭文件)和gcc。

為了在Linux完成安裝,需要執行 sudo dnf install gcc glibc-devel zlib-develsudo apt-get install build-essential libz-dev

在macOS上,需要執行xcode-select --install

針對Java 8的Windows編譯器工具鏈

從19.2.0版本開始,GraalVM開始為Windows原生鏡像提供了實驗性的支持。

對Windows的支持依然是實驗性的,官方文檔對Windows原生鏡像的詳細信息很少。從19.3版本開始,GraalVM同時支持Java 8和Java 11,在Windows上,它們需要不同的工具鏈。

要使用Java 8版本的GraalVM構建原生鏡像,我們需要Microsoft Windows SDK for Windows 7和.NET Framework 4,以及來自KB2519277的C編譯器。我們可以使用chocolatey來安裝它們:

choco install windows-sdk-7.1 kb2519277

然後(在cmd提示行中),激活sdk-7.1環境:

call "C:Program FilesMicrosoft SDKsWindowsv7.1BinSetEnv.cmd"

這首先會啟動一個新的命令提示行,並啟用了sdk-7.1環境,在這個命令行提示窗口中運行所有後續的命令。這適用於Java 8環境下從19.2.0到20.0版本的所有GraalVM。

針對Java 11的Windows編譯器工具鏈

要使用Java 11版本的GraalVM(19.3.0及以上),我們可以要么安裝Visual Studio 2017 IDE(確保要包含Visual C++ tools for CMake),要么可以通過chocolatey安裝Visual C Build Tools Workload for Visual Studio 2017 Build Tools:

choco install visualstudio2017-workload-vctools

安裝之後,在命令提示行中通過如下的命令搭建環境:

call "C:Program Files (x86)Microsoft Visual Studio2017BuildToolsVCAuxiliaryBuildvcvars64.bat"

提示:
如果你安裝了Visual Studio 2017 IDE,那麼需要在上面的命令中將BuildTools替換為CommunityEnterprise,這取決於你的Visual Studio版本。

然後,在命令行提示窗口中運行native-image

創建原生鏡像

native-image工具可以將Java應用編譯為原生鏡像,在進行編譯的平台上,該原生鏡像可以作為原生可執行文件來運行。在Linux上,如下所示:

在Linux上創建原生鏡像

$ /usr/lib/jvm/graalvm/bin/native-image 
    -cp classes:picocli-4.2.0.jar --no-server 
    --static -H:Name=checksum  CheckSum

在我的筆記本電腦上,native-image會耗費大約一分鐘的時間,並產生如下所示的輸出:

[checksum:1073]    classlist:   3,124.74 ms,  1.14 GB
[checksum:1073]        (cap):   2,885.31 ms,  1.14 GB
[checksum:1073]        setup:   4,767.19 ms,  1.14 GB
[checksum:1073]   (typeflow):   8,733.59 ms,  1.94 GB
[checksum:1073]    (objects):   6,073.44 ms,  1.94 GB
[checksum:1073]   (features):     313.28 ms,  1.94 GB
[checksum:1073]     analysis:  15,384.41 ms,  1.94 GB
[checksum:1073]     (clinit):     322.84 ms,  1.94 GB
[checksum:1073]     universe:     793.02 ms,  1.94 GB
[checksum:1073]      (parse):   2,191.69 ms,  1.94 GB
[checksum:1073]     (inline):   2,064.62 ms,  2.13 GB
[checksum:1073]    (compile):  14,960.43 ms,  2.73 GB
[checksum:1073]      compile:  20,040.78 ms,  2.73 GB
[checksum:1073]        image:   1,272.17 ms,  2.73 GB
[checksum:1073]        write:     722.20 ms,  2.73 GB
[checksum:1073]      [total]:  46,743.28 ms,  2.73 GB

最終,我們會得到一個原生Linux可執行文件。有趣的是,Java 11版本的GraalVM所創建的原生二進製文件要比Java 8版本的GraalVM所創建的文件更大一些:

-rwxrwxrwx 1 remko remko 14744296 Feb 19 09:51 java11-20.0/checksum*
-rwxrwxrwx 1 remko remko 12393600 Feb 19 09:48 java8-20.0/checksum*

我們可以看到文件是12.4MB和14.7MB。到底文件是大還是小,則取決於我們要和什麼進行對比。對我來講,這種大小的文件是可以接受的。

接下來,我們運行該應用,確保它是可用的。在這個過程中,我們還可以對比正常基於JIT的JVM與原生鏡像的啟動時間:

$ time java -cp classes:picocli-4.2.0.jar CheckSum hi.txt
764efa883dda1e11db47671c4a3bbd9e
real    0m0.415s   ← startup is 415 millis with normal Java
user    0m0.609s
sys     0m0.313s
$ time ./checksum hi.txt
764efa883dda1e11db47671c4a3bbd9e
real    0m0.004s   ← native image starts up in 4 millis
user    0m0.002s
sys     0m0.002s

至少在Linux上我們已經看到瞭如何將Java應用分發為單個原生可執行文件。那在Windows上又會怎樣呢?

Windows上的原生鏡像

原生鏡像對Windows的支持還有一些缺陷,所以我們會對此進行更詳細的討論。

在Windows上創建原生鏡像

創建原生鏡像本身並不是什麼問題。舉例來講:

在Windows上創建原生鏡像

C:appsgraalvm-ce-java8-20.0.0binnative-image ^
    -cp picocli-4.2.0.jar --static -jar checksum.jar

在Windows上,native-image.cmd工具的輸出和Linux類似,耗費的時間大致相當,所生成的可執行文件稍微小一些,Java 8版本的GraalVM對應的輸出是11.3MB,Java 11版本的GraalVM所輸出的二進製文件是14.2MB。

二進製文件能夠很好地運行,但是有一個差異:在控制台我們沒有看到ANSI的顏色,接下來,看一下如何修復它。

具有彩色輸出的Windows原生鏡像

為了在Windows命令提示行中實現ANSI彩色顯示,我們需要使用Jansi庫。但令人遺憾的是,Jansi(從1.18版本開始)有一些問題,這意味著在GraalVM原生鏡像中,它無法產生彩色的輸出。為了解決該問題,picocli提供了一個Jansi協作庫,即picocli-jansi-graalvm,它能夠讓Jansi庫在Windows的GraalVM原生鏡像上正確地運行。

我們要修改main方法,告訴Jansi在Windows上啟用渲染ANSI轉義碼的功能,如下所示:

    //...
import picocli.jansi.graalvm.AnsiConsole;
//...
public class CheckSum implements Callable {
  // ...
  public static void main(String[] args) {
    int exitCode = 0;
    // enable colors on Windows
    try (AnsiConsole ansi = AnsiConsole.windowsInstall()) {
      exitCode = new CommandLine(new CheckSum()).execute(args);
    }
    System.exit(exitCode);
  }
}

通過該命令構建新的原生鏡像(注意,從GraalVM 19.3開始,我們需要在類路徑中引用jar文件):

set GRAALVM_HOME=C:appsgraalvm-ce-java11-20.0.0
%GRAALVM_HOME%binnative-image ^
  -cp "picocli-4.2.0.jar;jansi-1.18.jar;picocli-jansi-graalvm-1.1.0.jar;checksum.jar" ^
  picocli.nativecli.demo.CheckSum checksum

在DOS控制台應用中,我們就能看到彩色的輸出的了:

如何借助Graalvm和Picocli構建Java編寫的原生CLI應用 3

這需要額外多花點功夫,但是現在我們的原生Windows CLI應用可以使用彩色對比了,提供了與Linux類似的用戶體驗。

添加Jansi庫對形成的二進製文件的大小並沒有什麼變化:使用Java 11 GraalVM構建的二進製文件是14.3MB,使用Java 8 GraalVM構建的二進製文件是11.3MB。

在Windows上運行原生鏡像

我們幾乎就要完工了,但是還有一個問題是尚未暴露的。

我們剛才創建的二進製文件在構建它的機器上能夠很好地運行,但是如果我們在另外一台Windows機器上運行的話,你可能會看到如下的錯誤:

如何借助Graalvm和Picocli構建Java編寫的原生CLI應用 4

它表明,我們的原生鏡像需要來自VS C++ Redistributable 2010的msvcr100.dll。這個dll文件可以放到與exe文件相同的目錄下,也可以放到C:WindowsSystem32中。有一項正在進行中的工作在試圖解決該問題。

在Java 11的GraalVM中,我們可以看到類似的錯誤,只不過它提示的是缺少不同的DLL,即VCRUNTIME140.dll

如何借助Graalvm和Picocli構建Java編寫的原生CLI應用 5

就現在來講,我們只能隨應用一起分發這些DLL,或者告訴用戶下載並安裝 Microsoft Visual C++ 2015 Redistributable Update 3 RC以便於獲取基於Java 11的原生鏡像所需的VCRUNTIME140.dll,或者安裝Microsoft Visual C++ 2010 SP1 Redistributable Package (x64)以獲取基於Java 8的原生鏡像所需的msvcr100.dll

GraalVM不支持交叉編譯(cross-compilation),不過將來可能會支持。現在,我們需要在Linux上編譯以獲得Linux可執行文件,在MacOS上編譯以獲得MacOS可執行文件,在Windows上編譯以獲得Windows可執行文件。

結論

命令行應用程序是GraalVM原生鏡像的典型使用場景:我們現在可以使用Java(或另外的JVM語言)進行開發,並將CLI應用程序作為單個的、相對較小的原生可執行文件進行分發。 (只不過在Windows上,我們可能需要分發一個額外的運行時DLL。)最大的好處就是快速啟動和減少內存佔用。

GraalVM原生鏡像有一些限制,應用程序可能需要做一些工作才能轉換成原生鏡像。

Picocli使得借助眾多JVM語言編寫命令行應用程序變得很容易,並且提供了一些附加功能,可以輕鬆地將CLI應用程序轉換為原生鏡像。

在你的下一個命令行應用程序中,嘗試一下Picocli和GraalVM吧!

作者介紹:

Remko Popma白天在SMBC Nikko Securities做Algo開發,晚上則是picocli的作者。 Popma也是Log4j 2的性能黑客。他使Log4j 2免受垃圾回收之苦,並貢獻了Async Loggers,與當前市場領先的日誌包log4j-1.x和logback相比,Async Loggers在多線程場景中具有10倍的吞吐量和多個數量級的低延遲。 Popma負責了Log4j 2.0-beta4版本以來大部分的性能改進。出於興趣,他喜歡學習新東西:性能、並發性、可伸縮性、低延遲、人工智能、機器學習、密碼學、比特幣和加密貨幣技術。你可以通過@RemkoPopma找到Popma。

原文鏈接:

Build Great Native CLI Apps in Java with Graalvm and Picocli