Scala-CLIをいじっていたらかなり良さそうな機能を見付けたので紹介する。
Scala-CLI
あらかじめ説明しておくと、Scala-CLIとはScalaをLLライクに扱うための便利ツールチェイン。REPLでコードを試したり、スクリプトを実行できたりする。
1ファイルで完結したスクリプトを書くと、依存性解決から実行まで全部やってくれるというのが非常にありがたい。RubyやPythonなどといったLLのように、強力な型安全性はそのまま、Scalaを書くことができる。
本当にこれは革命的ツールチェインだ。
インストールも簡単で、JVMが動く環境でシェルで1行書けばよい。
$ curl -sSLf https://virtuslab.github.io/scala-cli-packages/scala-setup.sh | sh
MacOSではbrewを使ってもインストールできる:
$ brew install Virtuslab/scala-cli/scala-cli
パッケージング
さて、ドキュメントを読んでいるとpackage
サブコマンドを使うことで、ScalaスクリプトをJVM環境で実行可能な状態にパッケージングしてくれる。(内部的には、ブートストラップ用の小さなシェルスクリプトとJARファイルとが結合された形式になっているようだ)
例えばこのようなScalaスクリプトをfoo.scala.sc
として保存する:
println("Hello, World!")
そして、package
サブコマンドで単一ファイルにパッケージできる:
$ scala-cli package -o executable foo.scala.sc
...
Wrote /foo/bar/executable, run it with
./executable
$ ./executable
Hello, World!
これはすごいことだ。普段sbtを使ったプロジェクトを構成する場合は、パッケージングのためには色々とプラグインを入れなければならないが、Scala-CLIを使うことでワンストップで(JVMを前提とした)実行可能ファイルを作成できるのだ。
ちなみにファイルサイズは自分の環境で171KiBだった。かなり小さい秘訣は、初回起動時に必要な依存性を自ら拾ってくる親切仕様になっているからだと思われる。
この挙動を回避し、全ての依存関係を最初からまとめておきたい場合は --assembly
オプションもつけると良い。
するとファイルサイズは7.2MiBになった。Golangのシングルバイナリと比べれば大きいが、まあ許容できると思う。
ネイティブパッケージング
驚くべきはここからで、--native
オプションを付けるとネイティブバイナリが生成される(!!)。事前にLLVMのビルド環境が整っている必要があるので、それらのライブラリをインストールしておく必要はある。
$ scala-cli package -o executable --native foo.scala.sc ... Wrote /foo/bar/executable, run it with ./executable $ ./executable Hello, World!
ちゃんとバイナリになっている:
$ file ./scala_sc\` ./scala_sc`: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=2e34762891629416cc25a8ca8a0a858d92b0f3c9, for GNU/Linux 3.2.0, with debug_info, not stripped
ファイルサイズは2.4MiBで、依存する共有ライブラリは以下の通り:
$ ldd ./executable linux-vdso.so.1 (0x00007ffcf75a7000) libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007f0168000000) libm.so.6 => /lib64/libm.so.6 (0x00007f0167f1c000) libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007f016824d000) libc.so.6 => /lib64/libc.so.6 (0x00007f0167d21000) /lib64/ld-linux-x86-64.so.2 (0x00007f0168458000)
まあ一般的な感じだと思う。
ファイルサイズはさらに小さくできる。--native-mode release-fast
を追加で指定すると、ちょっとコンパイルタイムが伸びるかわりにファイルサイズが1.4MiBにまで小さくなった。
この機能はScala Nativeの機能を使っているので、Scala Native非対応のライブラリを使っている場合はコンパイルできないのが弱点。とはいえ最近の有名ライブラリだったらだいたい動くので、あまり心配はいらない。
GraalVMによるネイティブパッケージング
Scala Nativeはかなりローレベルで動作する仕組みだが、もうひとつのネイティブコードを吐き出すソリューションであるGraalVM Nativeも利用できる。そうするには、--native-image
を使えばよい:
$ scala-cli package -o executable --native-image foo.scala.sc
こちらはJVMバイトコードをネイティブコードにコンパイルする(つまりJVMが実行時にJITコンパイルしていく過程を最初からやっておく)というモデルで、GraalVMのランタイムの分?だけ実行ファイルのサイズは膨らむ傾向にある。今回は12MiBになった。
ただし依存の数は少ない:
$ ldd ./executable linux-vdso.so.1 (0x00007fff2cfb0000) libz.so.1 => /lib64/libz.so.1 (0x00007f7495416000) libc.so.6 => /lib64/libc.so.6 (0x00007f749521b000) /lib64/ld-linux-x86-64.so.2 (0x00007f749544d000)
JSへのコンパイル
ここでは割愛するが、なんとJavaScriptへの変換も可能(内部的にはscala.jsを使っている)。すごい!!
感想
- ちょっとしたコマンドの配布にはかなり有力な候補なのではないかと思う
- Scala-CLIでLLでやれる大抵のことはやれるし、依存性管理とかはこっちのほうが勝ってるとすら思う
- 複雑なプロジェクトになってきたらsbtプロジェクトとしてエクスポートすることも可能になっている(!!!)
- 本当に便利だったので、Scala大時代来そうという感じがある
- これでいいじゃん、みたいな気持ちになった
- 最近Akkaのライセンス変更でしょんぼりしていたけど、かなり元気になった
- ネイティブ化しない場合は起動時間がちょっと気になるかもしれないけど、起動すると速い。