mike-neckのブログ

Java or Groovy or Swift or Golang

Picocli + Kotlin + graalvm-native-image plugin でネイティブツールを作る

ちょっと Java(Kotlin) でツールでも作ろうと思ったので、勉強のためにチュートリアルやってみた。やってみたという話なので何も深いことは書いてない。

3 行でまとめると

  • Picocli のドキュメントの introduction にある checksum コマンドを作ってみた
  • GraalVM の native-image コマンドでネイティブバイナリーにした
  • Gradle の graalvm-native-image というプラグインがよく出来てる

JVM ベースのコマンドラインアプリを作りたくなったので、前々から気になっていた Picocli を試してみることにした。 Picocli のページの先頭にある checksum コマンドを写経することで雰囲気を確かめる。

まず build.gradle を作る。 gradle init --type kotlin-application --dsl groovy でできた build.gradle に Picocli を追加する。

plugins {
    id 'org.jetbrains.kotlin.jvm' version '1.3.72'
    id 'org.jetbrains.kotlin.kapt' version "1.3.72"
    id 'application'
}

repositories {
    mavenCentral()
    jcenter()
}

dependencies {
    kapt 'info.picocli:picocli-codegen:4.2.0'

    implementation platform('org.jetbrains.kotlin:kotlin-bom')
    implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8'
    implementation 'info.picocli:picocli:4.2.0'

    testImplementation 'run.ktcheck:ktcheck:v0.1.0'
}

次に checksum のコードを写経する。

import picocli.CommandLine
import java.io.File
import java.math.BigInteger
import java.nio.file.Files
import java.security.MessageDigest
import java.util.concurrent.*

@CommandLine.Command(name = "checksum", mixinStandardHelpOptions = true, version = ["0.0"])
class App: Callable<Int> {

    @CommandLine.Option(names = ["-a", "--algorithm"], description = ["MD5 SHA-1, SHA-256"])
    var algorithm: String = "MD5"

    @CommandLine.Parameters(index = "0", description = ["target file"])
    lateinit var file: File

    override fun call(): Int =
        file.toPath()
            .let { Files.readAllBytes(it) }
            .let { it to MessageDigest.getInstance(algorithm) }
            .let { it.second.digest(it.first) }
            .let { "%0${it.size * 2}x" to BigInteger(1, it) }
            .let { it.first.format(it.second) }
            .let { println(it) }
            .let { 0 }
}

@Suppress("ReplaceJavaStaticMethodWithKotlinAnalog")
fun main(args: Array<String>) =
    System.exit(CommandLine(App()).execute(*args))

少しむずかしいと感じたのは、 CommandLineコンストラクターに渡すのが Any であったため、何を渡せるのかわからなかった。 コードを読むと、 Runnable あるいは Callable<Int> が渡せるとのことで、上のようなコードになっているが、 Kotlin の関数いけるだろうと思って渡してみたところ、次のような例外が発生した。

picocli.CommandLine$ExecutionException: Parsed command (org.mikeneck.instant.App@5d6f64b1) is not a Method, Runnable or Callable
        at picocli.CommandLine.executeUserObject(CommandLine.java:1822)
        at picocli.CommandLine.access$900(CommandLine.java:145)
        at picocli.CommandLine$RunLast.executeUserObjectOfLastSubcommandWithSameParent(CommandLine.java:2150)
        at picocli.CommandLine$RunLast.handle(CommandLine.java:2144)
        at picocli.CommandLine$RunLast.handle(CommandLine.java:2108)
        at picocli.CommandLine$AbstractParseResultHandler.execute(CommandLine.java:1975)
        at picocli.CommandLine.execute(CommandLine.java:1904)
        at org.mikeneck.instant.AppKt.main(App.kt:32)

まあ、当然であるといえば、当然である。 CommandLine にドキュメントに書いてあるとおりの Callable<Integer> を受け取るコンストラクターRunnable を受け取るコンストラクターがあると良さそうだと思いましたが、多分誰もが思っていて、特にイシューとしてあげていない様子を見ると、おそらくこれがメインの使い方ではないのだろうなと思う。

話はもとに戻して、まずはこれを Java のアプリケーションとして起動する。 Gradle に application プラグインを入れてはあるが、パラメーターを渡しづらいので、 java コマンドを表示するタスクを追加する。

task appCopy(type: Copy, group: 'build') {
    from configurations.runtimeClasspath.asFileTree
    from jar
    destinationDir file("$buildDir/jars")
}

task showCommand(dependsOn: 'appCopy', group: 'application') {
    doLast {
        println("java -cp ${project.fileTree("$buildDir/jars").asPath} ${application.mainClassName}")
    }
}

雑なので、全部のライブラリーとビルドしたアプリケーションの jar を一旦コピーして、コピー先にある jar の一覧をパス形式で表示するだけにしてみた。

これを実行すると、次のように表示されるので、コピペする

java -cp /Users/mike/ghq/github.com/mike-neck/instant/build/jars/picocli-4.2.0.jar:/Users/mike/ghq/github.com/mike-neck/instant/build/jars/annotations-13.0.jar:/Users/mike/ghq/github.com/mike-neck/instant/build/jars/kotlin-stdlib-jdk7-1.3.72.jar:/Users/mike/ghq/github.com/mike-neck/instant/build/jars/kotlin-stdlib-common-1.3.72.jar:/Users/mike/ghq/github.com/mike-neck/instant/build/jars/instant-0.jar:/Users/mike/ghq/github.com/mike-neck/instant/build/jars/kotlin-stdlib-1.3.72.jar:/Users/mike/ghq/github.com/mike-neck/instant/build/jars/kotlin-stdlib-jdk8-1.3.72.jar org.mikeneck.instant.AppKt

で、実行した結果がこちら

f:id:mike_neck:20200424003904p:plain


次に、これを GraalVM で native-image コマンドでネイティブバイナリーにしていく。

まずは GraalVM の最新版をインストールする。 Java のバージョンは 11 とする。

sdk i java `sdk l java | grep grl | head -n 1 | awk '{print $NF}'`

2020/04/24 0:42 (Asia/Tokyo) だと、 20.0.0.r11-grl がインストールされる。なお、graalVM のダウンロードには数時間かかる。

GraalVM では native-image はオプションのツールなので、 gu(GraalVM updater) でインストールする必要がある。

gu install native-image

これはすぐインストールできる。


ここで native-image コマンドを叩いてとなるが、やっぱり面倒なので、そのあたりのコマンドをラップしている Gradle のプラグインがないか探してみると…ちょうど今のユースケースにピッタリのプラグインがあるようです。

plugins.gradle.org

github.com

では README に書いてあるとおりにやってみましょう。

次のような記述を build.gradle に追加します(ほぼコピペ)。

plugins {
// 追加部分のみ
    id 'org.mikeneck.graal-native-image' version '0.3.0'
}

nativeImage {
    graalVmHome = "${System.getProperty('java.home')}"
    mainClass = application.mainClassName
    executableName = 'instant'
    arguments(
            '--no-fallback'
    )
}

さて、 Gradle を実行する Java を 先程インストールした GraalVM のものに変更して、 Gradle を実行します。

Build file '/Users/mike/ghq/github.com/mike-neck/instant/build.gradle' line: 5

Plugin [id: 'org.mikeneck.graal-native-image', version: '0.3.0'] was not found in any of the following sources:

おっと、なんだぁ、そんなプラグインがないとか言われているぞ!?

もう一度、 Gradle Plugins repository の記述を比較しながら確認してみると、どうやら README に書いてある内容が間違えているようです。 graal-native-image プラグインではなく、 graalvm-native-image プラグインですね…あとで他の人が間違えないように、このレポジトリーに PR を出してみました。

github.com

というわけで、記述を修正して次のようなスクリプトになりました。

plugins {
// 追加部分のみ
    id 'org.mikeneck.graalvm-native-image' version '0.3.0'
}

// application ブロックの下に追記する
nativeImage {
    graalVmHome = "${System.getProperty('java.home')}"
    mainClass = application.mainClassName
    executableName = 'instant'
    arguments(
            '--no-fallback'
    )
}

実行すると次のような出力が得られる。

$ ./gradlew nativeImage

> Task :kaptGenerateStubsKotlin UP-TO-DATE
> Task :kaptKotlin UP-TO-DATE
> Task :compileKotlin UP-TO-DATE
> Task :compileJava NO-SOURCE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :inspectClassesForKotlinIC UP-TO-DATE
> Task :jar UP-TO-DATE

> Task :nativeImage
Build on Server(pid: 30651, port: 61202)*
[instant:30651]    classlist:   1,386.61 ms,  1.42 GB
[instant:30651]        (cap):   3,010.14 ms,  1.42 GB
[instant:30651]        setup:   4,103.60 ms,  1.42 GB
[instant:30651]   (typeflow):   6,363.44 ms,  2.83 GB
[instant:30651]    (objects):   6,652.70 ms,  2.83 GB
[instant:30651]   (features):     313.80 ms,  2.83 GB
[instant:30651]     analysis:  13,661.96 ms,  2.83 GB
[instant:30651]     (clinit):     312.94 ms,  2.83 GB
[instant:30651]     universe:     725.00 ms,  2.83 GB
[instant:30651]      (parse):   1,243.97 ms,  2.83 GB
[instant:30651]     (inline):   2,551.83 ms,  2.83 GB
[instant:30651]    (compile):  10,601.32 ms,  3.77 GB
[instant:30651]      compile:  15,247.51 ms,  3.77 GB
[instant:30651]        image:   1,996.63 ms,  3.77 GB
[instant:30651]        write:     513.38 ms,  3.77 GB
[instant:30651]      [total]:  37,844.85 ms,  3.77 GB

BUILD SUCCESSFUL in 39s
6 actionable tasks: 1 executed, 5 up-to-date

README によると成功すると、 build/native-image ディレクトリーに指定した executableName の値の名前を持つファイルができているとのこと。

$ ls -l build/native-image/
total 25280
-rwxr-xr-x  1 mike  staff  12943244 Apr 24 01:02 instant

ではこれを実行してみると…

f:id:mike_neck:20200424010642p:plain

という感じで上手く実行できているようです。


以上、 Picocli と GraalVM native-image(Gradle graalvm-native-image プラグイン) を用いて Kotlin で CLI アプリケーションを作ってみました