SBT
https://www.scala-sbt.org/index.html
言わずもがなscalaで作られたbuildツール
scala以外の言語のプロジェクトにも使えるようだが、当記事ではscalaのbuildに使うことを前提に進めていく
version 1.3.6
を前提に進めていく
入門記事だが、対象読者はsbtを一通りの触ってみたぐらいのユーザを想定している(サブプロジェクトの定義ができればベストだ)
記事の目標としては読者が独自のTaskやCommandを定義できることを目指した
DSL
sbtのbuild設定はprojectルートにある build.sbt
ファイルと project/*.scala
project/*.sbt
に分割して記載することができる
build.sbt
含め *.sbt
は自動でロードされ、 *.scala
(厳密に書くと, そこに記載されている class, object
)は *.sbt
ファイルより呼び出されることでロードされる
その他の違いについては以下で記載する
*.sbt
と *.scala
の違い
-
*.scala
の特徴
*.scala
は普通のscalaファイルだ、ライブラリ依存を除き、通常のscalaとして行えることは(おそらく)全て行える
*.scala
にbuild設定を記載する場合はimport sbt._; import Keys._
をすればよい、これらは*.sbt
の場合、暗黙的にimportされているものになる
余談だが、過去に存在していたBuild.scala
は deprecated になったらしい -
*.sbt
の特徴
*.sbt
はトップレベルにsetting式(:=
や+=
を使ったsettingKeyの変更やtaskの定義など)を記述することができる, 後述するが各KeyにはScopeというものが存在しており、Scope指定をせずトップレベルに記載された場合、任意のScopeに対して値が紐づいていない場合のフォールバック値として利用される(つまり、デフォルト値のような挙動を持たすことができる)
build.sbt
は特殊な*.sbt
ファイルで必ず一番最後にロードされる、root projectの定義やglobalなtaskの定義などはここ書くといい
Scope
sbtのbuild設定は、簡単に言うとScope(+settingKey)に紐づく値の集合だ
Scopeには三つの軸( project
configuration
task
)が存在しており、これはTuple3で表現される e.g. (root, Compile, compile)
(わかりにくいが、Compileとcompileは別のものを指しており、前者は Compile configuration, 後者はcompile taskだ)
scopeに対する値の紐付けはsetting式で行える
例えば以下の場合Test configuration, すなわち、 test:compile
を行った時のみ -Xfatal-warnings
オプションを有効にする設定だ
scalacOptions in (yourProject, Test, compile) += "-Xfatal-warnings"
// yourProject / Test / compile / scalacOptions += "-Xfatal-warnings" と書いても同じだ
Scopeの記述は省略可能で省略した場合は、sbtまたはsbt plugin側で自動で設定される
たとえば lazy val core = (project in file("./core")).settings(...)
の中でScopeを省略した場合は、自動でそのprojectに紐づく
もちろん部分的に指定するのも可能だ
lazy val core = (project in file("core"))
.settings(
scalacOptions in Test += "-Xfatal-warnings"
)
*.sbt
でScope指定なしでglobalに宣言したものはデフォルト値のように働く、ただし、明示的にデフォルト値として使いたい場合は ThisBuild
Scopeに紐づけておくほうがいい、 ThisBuild
はフォールバックを前提に用意されているScopeだからだ
https://www.scala-sbt.org/1.x/docs/ja/Scopes.html#%E3%83%93%E3%83%AB%E3%83%89%E3%83%AC%E3%83%99%E3%83%AB%E3%83%BB%E3%82%BB%E3%83%83%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0
SettingKey
ユーザは基本的にはsbt apiやpluginに存在するSettingKey(+Scope)に値をマッピングするだけだが
settingKeyを定義することで、projectに対して独自の設定を付与することができる
定義方法は以下だ
lazy val strictBuild = settingKey[Boolean]("add cats library use base settings")
lazy val baseSettings = Def.settings(
scalacOptions ++= (if (strictBuild.value) Seq("-Xfatal-warnings") else Nil)
)
lazy val proj1 = (project in file("./proj1"))
.settings(
strictBuild := true
)
.settings(baseSettings)
lazy val proj2 = (project in file("./proj2"))
.settings(
strictBuild := false
)
.settings(baseSettings)
SettingKeyの定義は settingKey[T](description: String)
関数で行い、型パラメタ T
がそのキーに紐づけることのできる値の型だ
SettingKeyの値は SettingKey#value
メソッドで取得することができる, ただしこれはsetting式の中でのみ使え、取得Scopeがコンテキストにより変動する(今回の例だと最終的にproject定義の中に入っているので各projectに紐づいた値が取得される)
もちろん value
メソッドで明示的にScopeを指定することも可能だ
lazy val proj1 = (project in file("./proj1"))
.settings(
strictBuild := true
)
.settings(baseSettings)
lazy val proj2 = (project in file("./proj2"))
.settings(
strictBuild := false,
scalacOptions ++= (if ((strictBuild in proj1).value) Seq("-Xfatal-warnings") else Nil)
)
この場合、proj2もproj1のsetting値(つまりtrueだ)を拝借することができる, この例のように作るのは混乱を招くだけだが、Scopeを完全指定できること自体は役に立つので覚えておくといい
Command
Commandを定義することで独自のsbtコマンドを定義することができる
これは逐次実行するコマンドの列などを定義するときに役に立つ
コマンドの本体は State => State
のFunction1だ、Stateは実行時のsbtの状態を表し、settingの一覧なども含まれる
以下のように定義する
commands += Command.command("coreCompile") { st =>
println("==+ switch core +==")
val st1 = Command.process("project core", st)
println("==+ compile +==")
val st2 = Command.process("compile", st1)
println("==+ switch root +==")
val st3 = Command.process("project root", st2)
st3
}
これは引数を取らないコマンドの定義だ、引数を一つとるものは Command.single
, 複数とるものは Command.args
で定義できる
Command.process
で他のCommandを呼び出すことができる、他のコマンドも状態を返すのでそのStateをバケツリレーすることで逐次状態を変更している, sbtより外の世界に対する副作用のみに興味がある場合は、このバケツリレーは不要だ
commands
は標準で用意してくれているSettingKeyのひとつだ、つまり、コマンドの一覧もScopeに紐づく値のひとつにすぎない
(余談だが、このような単純な逐次呼び出しであれば addCommandAlias
を使うのも手だ)
addCommandAlias("coreCompile", ";project core ;compile ;project root")
次で紹介するTaskとCommandの大きな違いは呼び出し元を何に置いているか、という点だ、Commandはsbt shellからinteractiveに呼び出されることを想定している
Task
TaskはCommandのようにshellからinteractiveに実行もできるし、他のTaskからも呼び出すこともできる
Taskは戻り値を持ち、以下のように定義できる
import xsbti.compile.CompileAnalysis // これはTaskの定義とは直接関係ないimport
lazy val aggregateCompile = taskKey[Seq[CompileAnalysis]]("compile with all project.")
aggregateCompile := {
val currentState = state.value
val extracted = Project.extract(currentState)
import extracted._
structure.allProjectPairs
.filter { case (res, _) => res.aggregate.isEmpty }
.map { case (_, ref) => runTask(compile in (ref, Compile), currentState)._2 }
}
これはaggregate projectを除く全てのsub projectに対するコンパイルとその結果を返すタスクだ
タスクの戻り値は以下のように他の新しいタスクから参照できる
lazy val dump = taskKey[Unit]("dump all compiled files")
dump := {
aggregateCompile.value.map { an =>
println(
an.readSourceInfos().getAllSourceInfos.keySet()
)
}
}
Taskを利用することで他のTaskに依存した新しい独自タスクを再定義できるのが特徴だ
おそらくこれだけの基本的な知識があれば、独自タスクの定義ができると思う
sbt DSLは(主にスコープとマクロによって)記述箇所によって挙動が変わるため、初心者にとってあまり直感的ではない
ただこれはスコープの概念を理解すれば無駄な記述量を減らせる嬉しい機能なので是非とも覚えて使いこなして欲しい
(といっても筆者もここに書いたこと以上のことは知らないし、間違っている可能性もあるので、指摘いただけると助かります)