Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

ScalaのビルドツールMillが生成するUberjarはZIPファイルの面白仕様を使ってシェルから直接実行できるようになっている

プログラミング言語Scalaで最もよく使われているビルドツールといえばsbtだ。起動しっぱなしのインタラクティブ仕様なsbtがやや鈍重なきらいがある一方、もう1つのビルドツールであるMillが開発されており、こちらではnpmのようなシェル単位の操作体系を指向している。

mill-build.com

単にコマンド体系のみならず、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")が来ることになっている。

https://en.wikipedia.org/wiki/ZIP_(file_format)#/media/File:ZIP-64_Internal_Layout.svg

どうやら、一番最初の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を付けた形でも動作する。とても面白いし、よくできた仕組みだと思う。

追記

なんとsbtでもはるか昔から同様の機能があるとのことだった。全く知りませんでした。ありがとうございます🙇

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?