🗂

Scala のビルドツール mill を紹介する.

2022/12/10に公開

この記事は Scala アドベントカレンダー 10 日目の記事です. もともとは bleep というビルドツールを紹介する予定でしたが、 mill の方がユーザー数が多く、いくつかの有名なプロジェクトは mill でビルドされているので mill を紹介することにします.

15 日目の atty303 さんごめんなさいm(_ _)m

ちなみに bleep は sbt・millとはまた別のビルドツールです. まだ開発段階のようですが GraalVM で生成したネイティブバイナリで実行可能だったり、 sbt のプラグインとの互換性があったり既にいくつかの IDE サポートが動いていたり、など将来が期待できます.(yet another HOGE HOGE が大量にあるのはどうにかならんのか、とは思うが...🙃)

https://bleep.build/docs/

mill について

さて、Scala のデファクトのビルドツールは sbt だが、mill というビルドツールも存在する.

sbt は便利だが、インタラクティブなシェルアプリケーションの上でビルドタスクを実行するという珍しいモデルなのでしばしば sbt は使いにくい・遅いといった誹りを受けることがある.

実際、メジャーな言語である nodejs の npm も今流行りの Rust の cargo もコマンドラインからビルドタスクのコマンドを打てば実行できる.

// npm
npm run <task name>
// cargo
cargo <cmd>

こういったコマンドラインツールの挙動に慣れた人は mill を試してみるといいかもしれない.

sbt と違って、mill は軽量で sbt サーバーの立ち上げを待つ必要が無いので、コマンドラインからシュッと使うのに向いている.

インストール

mill は公式のドキュメントにあるように様々な方法でインストールできるが、ここでは coursier を使ってインストールする.

cs install mill

インストール後に表示された PATH を通す.

export PATH="$PATH:/path/to/Coursier/bin"

インストールされたか確認する.

mill --version

下のような結果が表示されたら成功.

Mill Build Tool version 0.10.10
Java version: 11.0.11, vendor: Azul Systems, Inc., runtime: /nix/store/0qygs0nqk9r193096fmgn7ffpa8bwrv7-zulu11.48.21-ca-jdk-11.0.11/zulu-11.jdk/Contents/Home
Default locale: en_JP, platform encoding: UTF-8

sbt の場合は

sbt version
[info] Updated file /path/to/project/build.properties: set sbt.version to 1.6.2
// サーバーの起動
[info] welcome to sbt 1.6.2 (Azul Systems, Inc. Java 17.0.3)
// ローディング
[info] loading project definition from /path/to/project
[info] set current project to trymill (in build file:/path/to/dir/)
// 結果
[info] 0.1.0-SNAPSHOT

と、ちょっとしたコマンドを実行するにもローディングが入る.

(もちろん普段開発する際は先に sbt コマンドで sbt shell に入っておいて、そこでコマンドを実行するから毎回ローディングが発生するわけではない)

mill を試してみる

次のようなファイル・ディレクトリを作る.

├── app
│   └── src
│       └── Main.scala
└── build.sc
build.sc
import mill._, scalalib._

object app extends ScalaLib { // note: app はディレクトリ名と一致させる.
  def scalaVersion = "3.2.1"
}
app/src/Main.scala
@main def run() = println("Hello, mill!")

コンパイルしてみよう.

勘で下のコマンドを打ってみた.

mill app
[33/46] app.compile 
[info] compiling 1 Scala source to /path/to/out/app/compile.dest/classes ...
[info] done compiling
[46/46] app.run 
Hello, mill!

コンパイルされたファイルは out ディレクトリ以下にあるようだ.

しかし run コマンドが走ってしまった.

調べてみると mill resolve コマンドがあるようだ🤔

mill resolve app
[1/1] resolve 
app

ふむ.
調べてみたら、こう使うらしい.

mill resolve _
[1/1] resolve 
all
app
clean
init
inspect
par
path
plan
resolve
show
showNamed
shutdown
shutdown
version
version
visualize
visualizePlan

ということは

mill resolve app._

もできそう.

実際できた.

mill resolve app._

app.allIvyDeps
app.allScalacOptions
app.allSourceFiles
app.allSources
app.ammoniteReplClasspath
app.ammoniteVersion
app.artifactId
app.artifactName
app.artifactScalaVersion
app.artifactSuffix
app.assembly
app.bspCompileClassesPath
app.bspCompileClasspath
app.bspCompiledClassesAndSemanticDbFiles
app.bspLocal
...

再度コンパイルしてみましょう.

mill app.compile

コンパイルできたようだ. 他の言語のビルドツールの感覚に近い. sbt の立ち上げを待つ必要もない.

ドキュメントをじっとみてみると inspect コマンドを使えばビルドタスクの詳細がわかるようだ. 便利である.

mill inspect _.compile
[1/1] inspect 
app.compile(ScalaModule.scala:198)
    Compiles the current module to generate compiled classfiles/bytecode.
    
    When you override this, you probably also want to override [[bspCompileClassesPath]].

Inputs:
    app.scalaVersion
    app.upstreamCompileOutput
    app.allSourceFiles
    app.compileClasspath
    app.javacOptions
    app.scalaOrganization
    app.allScalacOptions
    app.scalaCompilerClasspath
    app.scalacPluginClasspath

自分自身も inspect できる.

mill inspect inspect
inspect(MainModule.scala:198)
    Displays metadata about the given task without actually running it.

ひとまず

  • ライブラリのフェッチ
  • クロスビルド
  • localでの publish

を順番に試してみよう.

ライブラリのフェッチ

build.sc
import mill._, scalalib._

object app extends ScalaModule {
  def scalaVersion = "3.2.1"
  def ivyDeps = Agg(
    ivy"org.typelevel::cats-effect:3.4.1"
  )
}
app/src/Main.scala
import cats.effect._
import cats.effect.unsafe.implicits.global
@main def run() = IO.println("Hello, mill!").unsafeRunSync()

動いた.

mill app
[46/46] app.run 
Hello, mill!

クロスビルド

Scala Native にビルドしてみよう.

M1 Mac だと cats effect がリンクに失敗してなぜか動かなかったので普通の println にしている.

app/src/Main.scala
@main def run()  = println("Hello, mill!")

まずは Scala Native 単体でビルドする.

sbt の %%%%% と違ってクロスビルドでもライブラリの :: はそのままでいい.

build.sc
import mill._, scalalib._,scalanativelib._

object app extends ScalaNativeModule {
  def scalaVersion = "3.2.1"
  def scalaNativeVersion = "0.4.9"
  def ivyDeps = Agg(
    ivy"org.typelevel::cats-effect:3.4.2"
  )
}
mill app
[info] Linking (802 ms)
[info] Discovered 668 classes and 3700 methods
[info] Optimizing (debug mode) (613 ms)
[info] Generating intermediate code (549 ms)
[info] Produced 10 files
[info] Compiling to native code (1306 ms)
[info] Total (3383 ms)
[80/80] app.run 
Hello, mill!

通った.

クロスビルドには Cross[T] を使うらしい.

失敗その1

build.sc
import mill._, scalalib._,scalanativelib._


object app extends mill.Cross[AppModule](("3.2.1","native"),("3.2.1","jvm"))

class AppModule(crossVersion:String, platform:String) extends Module {
  def suffix = T { crossVersion + "_" + platform }
}
resolve app._
[1/1] resolve 
app[3.2.1,jvm]
app[3.2.1,native]

おやおや

mill resolve app[3.2.1,native]._
zsh: no matches found: app[3.2.1,native]._

zsh ではエスケープがいるみたいだ. 奇妙なことに suffix 以外のコマンドがなくなってしまった.
どうやら Module はかなりジェネリックな型で、コンパイルなどの個別の処理は ScalaModule, ScalaJsModule, ScalaNativeModule に入っているようだ.

mill resolve app\[3.2.1,native\]._
[1/1] resolve 
app[3.2.1,native].suffix

こうだろうか.

失敗その2

build.sc
import mill._, scalalib._,scalanativelib._


object app extends mill.Cross[AppModule](("3.2.1","native"),("3.2.1","jvm"))

class AppModule(crossVersion:String, platform:String) extends ScalaModule {
  def scalaVersion = crossVersion
  def suffix = T { crossVersion + "_" + platform }
}

ずらっとコマンドが出てきたが native で javacOptions はなんというかナンセンス.

mill resolve app\[3.2.1,native\]._  
app[3.2.1,native].allIvyDeps
app[3.2.1,native].allScalacOptions
app[3.2.1,native].allSourceFiles
app[3.2.1,native].allSources
app[3.2.1,native].ammoniteReplClasspath
app[3.2.1,native].ammoniteVersion
app[3.2.1,native].artifactId

...
app[3.2.1,native].jar
app[3.2.1,native].javacOptions
app[3.2.1,native].javadocOptions
app[3.2.1,native].launcher
app[3.2.1,native].localClasspath
app[3.2.1,native].mainClass

どうやら jvm と native で異なるモジュールを作るメンタルモデルらしい. sbt-cross-project で一つのプロジェクトにクロスビルドの軸を追加するのとは少し勝手が違う.

build.sc
import mill._, scalalib._,scalanativelib._

// ここで共通の設定をする
trait AppModule extends CrossSbtModule {
  def sources = T.sources (
    build.millSourcePath / "app" / "src"
  )
}

// platform ごとに異なるモジュールを設定する
// sbt では
//
// oneProject
//  .settings(...)
//  .nativeSettings(settings)
//  .jvmSettings(settings)
//
// としていた.


object appJVM extends mill.Cross[appJVMModule]("3.2.1")

class appJVMModule(val crossScalaVersion:String) extends AppModule with ScalaModule {
  def scalaVersion = "3.2.1"
}

object appNative extends mill.Cross[appNativeModule]("3.2.1")

class appNativeModule(val crossScalaVersion: String) extends AppModule  with ScalaNativeModule {
  def scalaVersion = "3.2.1"
  def scalaNativeVersion = "0.4.9"
}

mill resolve _
all
appJVM
appNative
clean
init
...

appJVM と appNative が見つかったのでそれぞれ実行してみる.

mill appJVM\[3.2.1\].run  
Hello, mill! 
mill appNative\[3.2.1\].run
Hello, mill!

動いた.

ライブラリの (local)publish

mill resolve appJVM\[3.2.1\]._ | grep publish

では publish は見当たらない.

sbt では publishTo := None としていたのを思い出すと、mill では明示的に publish を指定しないと publish できないようになっているようだ.

もし JVM モジュールだけ publish 系のタスクを使うなら次のように書けばいいらしい.

こうだろうか.

build.sc
class appJVMModule(val crossScalaVersion:String) extends AppModule 
   with ScalaModule 
+  with PublishModule {
    def scalaVersion = "3.2.1"
}

動くかどうか試すため mill resolve _ を実行してみる.

Missing implementations for 2 members of trait PublishModule.
  def pomSettings: mill.T[mill.scalalib.publish.PomSettings] = ???
  def publishVersion: mill.T[String] = ???

object appJVM extends mill.Cross[appJVMModule]("3.2.1")
                           ^
build.sc:12: class appJVMModule needs to be abstract.
Missing implementations for 2 members of trait PublishModule.
  def pomSettings: mill.T[mill.scalalib.publish.PomSettings] = ???
  def publishVersion: mill.T[String] = ???

class appJVMModule(val crossScalaVersion:String) extends AppModule with ScalaModule with PublishModule {
      ^
Compilation Failed

どうやら、pomSettingspublishVersion が必要なようだ.
これでどうだ.

build.sc
class appJVMModule(val crossScalaVersion:String) extends AppModule 
   with ScalaModule 
+  with PublishModule {
+    def publishVersion = "0.1.0-SNAPSHOT"
+    def pomSettings = PomSettings(
+      description = artifactName(),
+      organization = "com.example",
+      url = "https://github.com/404",
+      licenses = Seq(License.MIT),
+      versionControl = VersionControl.github("404", ""),
+      developers = Seq(
+        Developer("i10416", "110416", "https://github.com/i10416")
+      )
+    )
    def scalaVersion = "3.2.1"
}

だめだ.

mill resolve _
build.sc:14: not found: value PomSettings
  def pomSettings = PomSettings(
                    ^
build.sc:15: not found: value description
    description = artifactName(),
    ^
build.sc:16: not found: value organization
    organization = "com.example",
    ^
build.sc:17: not found: value url
....
....
Compilation Failed

not found: value XXX とあるので namespace の問題っぽい. import を入れることで解決した✌️


+ import scalalib.publish._
class appJVMModule(val crossScalaVersion:String) extends AppModule 
   with ScalaModule 
+  with PublishModule {
+    def publishVersion = "0.1.0-SNAPSHOT"
+    def pomSettings = PomSettings(
+      description = artifactName(),
+      organization = "com.example",
+      url = "https://github.com/404",
+      licenses = Seq(License.MIT),
+      versionControl = VersionControl.github("404", ""),
+      developers = Seq(
+        Developer("i10416", "110416", "https://github.com/i10416")
+      )
+    )
    def scalaVersion = "3.2.1"
}

publish 系のコマンドがちゃんと見つかった.

mill resolve appJVM\[3.2.1\]._ | grep publish
[1/1] resolve 
appJVM[3.2.1].publish
appJVM[3.2.1].publishArtifacts
appJVM[3.2.1].publishLocal
appJVM[3.2.1].publishM2Local
appJVM[3.2.1].publishProperties
appJVM[3.2.1].publishSelfDependency
appJVM[3.2.1].publishVersion

実行するとちゃんとパッケージをローカルのレポジトリに配信してくれた.

mill appJVM\[3.2.1\].publishLocal
[61/61] appJVM[3.2.1].publishLocal 
Publishing Artifact(com.example,pkgname_3,0.1.0-SNAPSHOT) to ivy repo /path/to/.ivy2/local

所感

ライブラリ開発で複数のモジュール、scala のバージョンを 2.12・2.13・3.1・3.2, platform を JVM・JS・Native などと複雑にクロスビルドしないといけないケースでは sbt の方がプラグインが充実していて使いやすそうだが、ちょっとした特定のバージョン・プラットフォーム向けのアプリケーションを書くケースでは mill のほうがシンプル&直感的に書けそうだ. sbt の立ち上げの「よっこらせ」感がないのは悪くない.
また、watch コマンドで変更を監視してタスクを実行したり repl モードでインタラクティブに操作したりもできるので、雑に実行しながらトライアンドエラーでデバッグするのにもよさそうだ.

自分は普段は sbt を使っているが、他の言語の経験者や Scala のライトユーザーに Scala 向けのビルドツールを紹介する際には mill を紹介するといいかもしれない.

Discussion