プログラミング言語Scalaで最もよく使われているビルドツールといえばsbt
だ。起動しっぱなしのインタラクティブ仕様なsbtがやや鈍重なきらいがある一方、もう1つのビルドツールであるMillが開発されており、こちらではnpm
のようなシェル単位の操作体系を指向している。
単にコマンド体系のみならず、Millには他にも色々と面白い機能がある。例えばsbtと違って標準でassembly
、すなわちUberjarへのパッケージング機能が搭載されているという点がありがたい。このUberjarを作る機能に面白い仕組みが盛り込まれていたので紹介するというエントリ。
さて、JVM言語にあまり縁がない読者のために説明しておくと、Uberjarとは、依存する関連モジュールが1つのパッケージにまとめられ、JVMさえあれば単独で実行可能なJARファイルのことだ。そしてMillはassembly
サブコマンドを渡すことでUberjarを生成できる。
// build.sc // こんな感じのモジュールがあるとして・・・ import mill._, scalalib._ object app extends RootModule with ScalaModule { def scalaVersion = "3.3.0" def ivyDeps = Agg( ivy"org.typelevel::cats-core:2.10.0" ) }
// こんな感じのソースがあるとして・・・ package com.github.windymelt.millexercise import cats._ import cats.implicits._ object Main extends App { val m1: Map[Int, List[String]] = Map( 42 -> List("foo", "bar"), 43 -> List("buzz") ) val m2: Map[Int, List[String]] = Map( 42 -> List("hoge"), 44 -> List("fuga") ) val m3 = m1 |+| m2 println(m3) }
# これだけでよい $ mill assembly $ ls out/assembly.dest/out.jar out/assembly.dest/out.jar*
もちろん実行できる。
$ java -jar out/assembly.dest/out.jar Map(42 -> List(foo, bar, hoge), 44 -> List(fuga), 43 -> List(buzz)) $
sbtの場合はsbt-assembly
を依存プラグインとして用意しなければならなかったので、結構地味な差別化ポイントだ。個人的には結構嬉しかった。
謎の実行権限
ところで出力されたJARの様子を見ていると、なにやら奇異なポイントを発見した。勝手に実行権限(+x
)がついている。
$ ls -lah out/assembly.dest/out.jar Permissions Size User Date Modified Name .rwxr-xr-x 16M windymelt 28 8月 20:46 out/assembly.dest/out.jar*
普通Uberjarを実行するには、java -jar uberjar.jar
のようにjava
コマンドの引数として渡すのが定石、というかこれ以外に方法はない。当たり前だがシェルはJARを解釈しないから、実行できるはずはない。一体どういうこと?と思いシェルから直接実行してみる:
$ ./out/assembly.dest/out.jar Map(42 -> List(foo, bar, hoge), 44 -> List(fuga), 43 -> List(buzz)) $
動いちゃった!! どういうこと?当たり前だがsbt-assembly
で生成したUberjarではこのようなことは起こらない。
中身を覗く
Millが生成するUberjarには何らかの小細工があるのだろうか?素直にless
コマンドで中身を覗いてみると、驚くべきことに:
$ less out/assembly.dest/out.jar @ 2>/dev/null # 2>nul & echo off & goto BOF : exec java $JAVA_OPTS -cp "$0" 'com.github.windymelt.millexercise.Main' "$@" exit :BOF setlocal @echo off java %JAVA_OPTS% -cp "%~dpnx0" com.github.windymelt.millexercise.Main %* endlocal exit /B %errorlevel% PK..........(ここからはバイナリがずっと続いている)..........
なんとJARの冒頭がシェルスクリプトになっていた!しかも、見るからにWindowsにも対応してそうな雰囲気だ。
というか、教科書的にはJARファイルの実体はZIPファイルではなかったかしら?JARってこんなことして良いのだろうか?
zip
コマンドに渡したところ、ファイル構造がおかしいよと警告されてしまったが、読めはするみたいだ:
$ zip out/assembly.dest/out.jar zip warning: unexpected signature on disk 0 at 15223097 zip warning: archive not in correct format: out/assembly.dest/out.jar zip warning: (try -F to attempt recovery) zip error: Zip file structure invalid (out/assembly.dest/out.jar)
ちなみに実際の実装ではここで冒頭に付与するシェルスクリプトを作っている。
ZIPは任意の場所に任意のデータを封入できる
関連Issueによれば、ZIPは冒頭のテキストデータを無視するから問題ないらしい。ZIP内において、ファイルを表わすデータの直前にはlocal file header("PK\03\04"
)が来ることになっている。
どうやら、一番最初のlocal file header以前に妙なデータが入っている場合、たいていのZIPライブラリはこれを無視するらしい。
This allows arbitrary data to occur in the file both before and after the ZIP archive data, and for the archive to still be read by a ZIP application. A side-effect of this is that it is possible to author a file that is both a working ZIP archive and another format, provided that the other format tolerates arbitrary data at its end, beginning, or middle. https://en.wikipedia.org/wiki/ZIP_(file_format)
なんと、Wikipediaによればこれは仕様内の挙動だった!
この仕様を利用して、Millはブートストラップ用のシェルスクリプトをJARファイルに封入していたのだ。これによりjava -jar
コマンドを書く必要はなくなり、直接実行可能なUberjarが完成する、という仕組みだ。JARファイルとしても有効なので、そのままjava -jar
を付けた形でも動作する。とても面白いし、よくできた仕組みだと思う。
追記
https://t.co/u1ppcQtxGP
— Kenji Yoshida (@xuwei_k) 2023年8月28日
sbt-assemblyにも大昔からあります
なんとsbtでもはるか昔から同様の機能があるとのことだった。全く知りませんでした。ありがとうございます🙇