Scala、Picocli、GraalVMによるネイティブCLIツールの作成

以前、Scalaを使ってCLIツールを作るという記事を書いたのですが、やはりJVMベースのアプリケーションには起動時間のオーバーヘッドが付き物なのでちょっとしたCLIツールをScalaで書こうという感じにはなかなかなりません。そこで今回はPicocliというJava向けのCLIツール用ライブラリとGraalVMを使ってネイティブCLIコマンドをScalaで作る方法を試してみました。

github.com

sbtプロジェクトの作成

今回はPicocliのドキュメントで例として掲載されていたchecksumコマンドをScalaで実装してみます。まずは以下の内容でbuild.sbtを作成します。

name := "picocli-scala-example"

version := "0.1"

scalaVersion := "2.12.7"

libraryDependencies ++= Seq(
  "info.picocli" % "picocli" % "4.2.0"
)

checksumコマンドのソースコードはこんな感じです。JavaコードをそのままScalaに書き換えただけです。コマンドやオプションの情報をアノテーションで宣言するのですが、フィールドをvarで宣言しておかないといけないのがちょっと微妙かもしれないですね。

package com.github.takezoe

import java.io.Fileimport picocli.CommandLine._
import java.math.BigInteger
import java.nio.file.Files
import java.security.MessageDigest
import java.util.concurrent.Callable
import picocli.CommandLine

@Command(name = "checksum", mixinStandardHelpOptions = true, version = Array("checksum 4.0"),
  description = Array("Prints the checksum (MD5 by default) of a file to STDOUT."))
class CheckSum extends Callable[Int] {

  @Parameters(index = "0", description = Array("The file whose checksum to calculate."))
  private var file: File = null

  @Option(names = Array("-a", "--algorithm"), description = Array("MD5, SHA-1, SHA-256, ..."))
  private var algorithm = "MD5"

  override def call(): Int = {
    val fileContents = Files.readAllBytes(file.toPath)
    val digest = MessageDigest.getInstance(algorithm).digest(fileContents)
    printf("%0" + (digest.length * 2) + "x%n", new BigInteger(1, digest))
    0
  }
}

object CheckSum extends App {
  val exitCode = new CommandLine(new CheckSum()).execute(args:_*)
  System.exit(exitCode)
}

Annotation Processorの実行

PicoliではGraalVMによるnative-image対応のためにAnnotation Processorを使用したコード生成が必要です。Annotation Processorはpicoli-codegenという別ライブラリとして提供されているのでこれをprovidedで依存関係に追加します。

libraryDependencies ++= Seq(
  "info.picocli" % "picocli" % "4.2.0",
  // Add a line below
  "info.picocli" % "picocli-codegen" % "4.2.0" % "provided"
)

build.sbtに以下のような感じの設定を追加して、processAnnotationsタスクでAnnotation Processorが実行されるようにします。

lazy val processAnnotations = taskKey[Unit]("Process annotations")

processAnnotations := {
  val log = streams.value.log
  log.info("Processing annotations ...")

  val classpath = ((products in Compile).value ++ ((dependencyClasspath in Compile).value.files)) mkString ":"
  val destinationDirectory = (classDirectory in Compile).value
  val processor = "picocli.codegen.aot.graalvm.processor.NativeImageConfigGeneratorProcessor"
  val classesToProcess = Seq("com.github.takezoe.CheckSum") mkString " "

  val command = s"javac -cp $classpath -proc:only -processor $processor -XprintRounds -d $destinationDirectory $classesToProcess"
  runCommand(command, "Failed to process annotations.", log)

  log.info("Done processing annotations.")
}

def runCommand(command: String, message: => String, log: Logger) = {
  import scala.sys.process._
  val result = command !if (result != 0) {
    log.error(message)
    sys.error("Failed running command: " + command)
  }
}

なお、Picocliにはアノテーションを使わないAPIも用意されており、そちらを使用すればAnnotation Processorを使わなくてもネイティブイメージの生成に対応できます。

GraalVMのインストール

続いてGraalVMをインストールします。以下からGraalVMのCommunity Editionをダウンロードして適当なディレクトリに展開し、binディレクトリを環境変数PATHに追加しておきます。

github.com

ネイティブイメージの作成に必要なnative-imageコマンドは以下のようにして別途インストールする必要があります。

$ gu install native-image

ネイティブイメージの作成

sbtでのネイティブイメージの作成にはsbt-native-packagerを使用しました。

github.com

project/plugins.sbtに以下の設定を追加します。

addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.6.1")

また、build.sbtに以下の設定を追加し、GraalVMNativeImagePluginを有効にするとともにパッケージ作成時にprocessAnnotationsタスクが実行されるようにしておきます。

packageBin in Compile := (packageBin in Compile dependsOn (processAnnotations in Compile)).value

enablePlugins(GraalVMNativeImagePlugin)

これで準備は完了です。sbtを以下のように実行するとネイティブイメージが作成されます。

$ sbt graalvm-native-image:packageBin

ネイティブイメージはtarget/graalvm-native-imageディレクトリにプロジェクトと同じ名前で生成されます。試しに実行してみましょう。

$ ./target/graalvm-native-image/picocli-scala-example
Missing required parameter: <file>
Usage: checksum [-hV] [-a=<algorithm>] <file>
Prints the checksum (MD5 by default) of a file to STDOUT.
      <file>      The file whose checksum to calculate.
  -a, --algorithm=<algorithm>
                  MD5, SHA-1, SHA-256, ...
  -h, --help      Show this help message and exit.
  -V, --version   Print version information and exit.

こんな感じで動作します。

まとめ

ScalaとPicocliを使用してGraalVMでネイティブイメージを作成できることを確認できました。ScalaCLIツールを書きたいけどJVMの起動時間が気になるという場合には選択肢になるのではないかと思います。また、以下の記事ではdeclineなどScala製のライブラリを活用したGraalVMによるネイティブCLIアプリケーションの作成方法を解説されています。ライブラリの選択など参考になります。

msitko.pl

Scalaでのネイティブアプリケーションの作成にはscala-nativeという選択肢もありますが、活発に開発が行われているとは言い難い状況で現時点ではScala 2.11のサポートに留まっています。また、利用可能なライブラリもかなり限られており、実用にはちょっと厳しいかなという感じがあります。

この記事で試したプロジェクトは以下のGitHubリポジトリに置いてあります。

github.com