ビルド定義の続きとして、このページでは build.sbt
の定義について詳しく説明します。
settings
をキーと値のペアとして考えるよりも、むしろタスクの有向非巡回グラフ (DAG) として考える方が良いでしょう。ここで、エッジは**happens-before**を表します。これをタスクグラフと呼びましょう。
詳しく説明する前に、主要な用語を復習しましょう。
.settings(...)
内のエントリ。SettingKey[A]
、TaskKey[A]
、または InputKey[A]
になります。SettingKey[A]
を持つ設定式によって定義されます。値はロード中に一度計算されます。TaskKey[A]
を持つタスク式によって定義されます。値は呼び出されるたびに計算されます。build.sbt
DSL では、.value
メソッドを使用して別のタスクまたは設定への依存関係を表現します。value メソッドは特殊であり、:=
(または +=
または ++=
。後で説明します) の引数でのみ呼び出すことができます。
最初の例として、update
および clean
タスクに依存する scalacOptions
を定義することを検討してください。これらのキーの定義を次に示します (Keys から)。
注: 以下で計算される値は scalaOptions
にとっては意味がなく、デモンストレーションのみを目的としています。
val scalacOptions = taskKey[Seq[String]]("Options for the Scala compiler.")
val update = taskKey[UpdateReport]("Resolves and optionally retrieves dependencies, producing a report.")
val clean = taskKey[Unit]("Deletes files produced by the build, such as generated sources, compiled classes, and task caches.")
scalacOptions
を書き換える方法は次のとおりです
scalacOptions := {
val ur = update.value // update task happens-before scalacOptions
val x = clean.value // clean task happens-before scalacOptions
// ---- scalacOptions begins here ----
ur.allConfigurations.take(3)
}
update.value
および clean.value
はタスク依存関係を宣言しますが、ur.allConfigurations.take(3)
はタスクの本体です。
.value
は通常の Scala メソッド呼び出しではありません。build.sbt
DSL は、マクロを使用してこれらをタスク本体の外に持ち上げます。update
タスクと clean
タスクの両方が、タスクエンジンが scalacOptions
の開始 {
を評価するまでに完了します。これは、本体内のどの行に出現するかに関係ありません。
次の例を参照してください
ThisBuild / organization := "com.example"
ThisBuild / scalaVersion := "2.12.18"
ThisBuild / version := "0.1.0-SNAPSHOT"
lazy val root = (project in file("."))
.settings(
name := "Hello",
scalacOptions := {
val out = streams.value // streams task happens-before scalacOptions
val log = out.log
log.info("123")
val ur = update.value // update task happens-before scalacOptions
log.info("456")
ur.allConfigurations.take(3)
}
)
次に、sbt シェルから scalacOptions
と入力します
> scalacOptions
[info] Updating {file:/xxx/}root...
[info] Resolving jline#jline;2.14.1 ...
[info] Done updating.
[info] 123
[info] 456
[success] Total time: 0 s, completed Jan 2, 2017 10:38:24 PM
val ur = ...
が log.info("123")
と log.info("456")
の間に表示されている場合でも、update
タスクの評価はそれらのいずれよりも前に発生します。
別の例を次に示します
ThisBuild / organization := "com.example"
ThisBuild / scalaVersion := "2.12.18"
ThisBuild / version := "0.1.0-SNAPSHOT"
lazy val root = (project in file("."))
.settings(
name := "Hello",
scalacOptions := {
val ur = update.value // update task happens-before scalacOptions
if (false) {
val x = clean.value // clean task happens-before scalacOptions
}
ur.allConfigurations.take(3)
}
)
次に、sbt シェルから run
を入力し、次に scalacOptions
を入力します
> run
[info] Updating {file:/xxx/}root...
[info] Resolving jline#jline;2.14.1 ...
[info] Done updating.
[info] Compiling 1 Scala source to /Users/eugene/work/quick-test/task-graph/target/scala-2.12/classes...
[info] Running example.Hello
hello
[success] Total time: 0 s, completed Jan 2, 2017 10:45:19 PM
> scalacOptions
[info] Updating {file:/xxx/}root...
[info] Resolving jline#jline;2.14.1 ...
[info] Done updating.
[success] Total time: 0 s, completed Jan 2, 2017 10:45:23 PM
target/scala-2.12/classes/
を確認すると、clean
タスクが if (false)
の内側にあるにもかかわらず、実行されたため、存在しません。
もう 1 つ重要な注意点は、update
タスクと clean
タスクの順序は保証されないことです。それらは、update
の後に clean
、clean
の後に update
、または両方を並行して実行する可能性があります。
上記で説明したように、.value
は、他のタスクや設定への依存関係を表現するために使用される特別なメソッドです。build.sbt に慣れるまでは、すべての .value
呼び出しをタスク本体の先頭に配置することをお勧めします。
ただし、より慣れてくると、タスク/設定をより簡潔にできるため、.value
呼び出しをインライン化したい場合があります。これにより、変数名を考える必要がなくなります。
いくつかの例をインライン化しました
scalacOptions := {
val x = clean.value
update.value.allConfigurations.take(3)
}
.value
呼び出しがインライン化されているか、タスク本体のどこかに配置されているかに関係なく、タスク本体に入る前に評価されることに注意してください。
上記の例では、scalacOptions
は update
および clean
タスクに依存関係があります。上記を build.sbt
に配置して、sbt インタラクティブコンソールを実行し、inspect scalacOptions
と入力すると、(一部) 次のように表示されるはずです
> inspect scalacOptions
[info] Task: scala.collection.Seq[java.lang.String]
[info] Description:
[info] Options for the Scala compiler.
....
[info] Dependencies:
[info] *:clean
[info] *:update
....
これは、sbt がどのタスクが他のどのタスクに依存しているかを知る方法です。
たとえば、inspect tree compile
を実行すると、別のキー incCompileSetup
に依存していることがわかります。これにより、dependencyClasspath
のような他のキーに依存します。依存関係チェーンを追っていくと、魔法が起こります。
> inspect tree compile
[info] compile:compile = Task[sbt.inc.Analysis]
[info] +-compile:incCompileSetup = Task[sbt.Compiler$IncSetup]
[info] | +-*/*:skip = Task[Boolean]
[info] | +-compile:compileAnalysisFilename = Task[java.lang.String]
[info] | | +-*/*:crossPaths = true
[info] | | +-{.}/*:scalaBinaryVersion = 2.12
[info] | |
[info] | +-*/*:compilerCache = Task[xsbti.compile.GlobalsCache]
[info] | +-*/*:definesClass = Task[scala.Function1[java.io.File, scala.Function1[java.lang.String, Boolean]]]
[info] | +-compile:dependencyClasspath = Task[scala.collection.Seq[sbt.Attributed[java.io.File]]]
[info] | | +-compile:dependencyClasspath::streams = Task[sbt.std.TaskStreams[sbt.Init$ScopedKey[_ <: Any]]]
[info] | | | +-*/*:streamsManager = Task[sbt.std.Streams[sbt.Init$ScopedKey[_ <: Any]]]
[info] | | |
[info] | | +-compile:externalDependencyClasspath = Task[scala.collection.Seq[sbt.Attributed[java.io.File]]]
[info] | | | +-compile:externalDependencyClasspath::streams = Task[sbt.std.TaskStreams[sbt.Init$ScopedKey[_ <: Any]]]
[info] | | | | +-*/*:streamsManager = Task[sbt.std.Streams[sbt.Init$ScopedKey[_ <: Any]]]
[info] | | | |
[info] | | | +-compile:managedClasspath = Task[scala.collection.Seq[sbt.Attributed[java.io.File]]]
[info] | | | | +-compile:classpathConfiguration = Task[sbt.Configuration]
[info] | | | | | +-compile:configuration = compile
[info] | | | | | +-*/*:internalConfigurationMap = <function1>
[info] | | | | | +-*:update = Task[sbt.UpdateReport]
[info] | | | | |
....
compile
と入力すると、sbt は自動的に update
を実行します。たとえば、それは単に機能します。これは、compile
計算への入力として必要な値は、sbt が最初に update
計算を実行する必要があるためです。
このように、sbt のすべてのビルド依存関係は、明示的に宣言されるのではなく、自動です。キーの値を別の計算で使用すると、計算はそのキーに依存します。
scalacOptions
はタスクキーです。既にいくつかの値に設定されているが、非 2.12 の場合は "-Xfatal-warnings"
および "-deprecation"
をフィルタリングするとします。
lazy val root = (project in file("."))
.settings(
name := "Hello",
organization := "com.example",
scalaVersion := "2.12.18",
version := "0.1.0-SNAPSHOT",
scalacOptions := List("-encoding", "utf8", "-Xfatal-warnings", "-deprecation", "-unchecked"),
scalacOptions := {
val old = scalacOptions.value
scalaBinaryVersion.value match {
case "2.12" => old
case _ => old filterNot (Set("-Xfatal-warnings", "-deprecation").apply)
}
}
)
sbt シェルでは、次のようになります
> show scalacOptions
[info] * -encoding
[info] * utf8
[info] * -Xfatal-warnings
[info] * -deprecation
[info] * -unchecked
[success] Total time: 0 s, completed Jan 2, 2017 11:44:44 PM
> ++2.11.8!
[info] Forcing Scala version to 2.11.8 on all projects.
[info] Reapplying settings...
[info] Set current project to Hello (in build file:/xxx/)
> show scalacOptions
[info] * -encoding
[info] * utf8
[info] * -unchecked
[success] Total time: 0 s, completed Jan 2, 2017 11:44:51 PM
次に、次の 2 つのキー (Keys から) を取得します
val scalacOptions = taskKey[Seq[String]]("Options for the Scala compiler.")
val checksums = settingKey[Seq[String]]("The list of checksums to generate and to verify for dependencies.")
注: scalacOptions
と checksums
は互いに何も関係ありません。それらは単に値の型が同じ 2 つのキーであり、1 つはタスクです。
scalacOptions
を checksums
にエイリアスする build.sbt
をコンパイルすることは可能ですが、逆はできません。たとえば、これは許可されています
// The scalacOptions task may be defined in terms of the checksums setting
scalacOptions := checksums.value
逆方向に行く方法はありません。つまり、設定キーはタスクキーに依存できません。これは、設定キーがプロジェクトのロード時に一度だけ計算されるため、タスクは毎回再実行されず、タスクは毎回再実行されることを期待しているためです。
// Bad example: The checksums setting cannot be defined in terms of the scalacOptions task!
checksums := scalacOptions.value
実行タイミングの観点から、設定はロード時に評価される特別なタスクと考えることができます。
プロジェクトの編成をプロジェクト名と同じになるように定義することを検討してください。
// name our organization after our project (both are SettingKey[String])
organization := name.value
次に現実的な例を示します。これは、scalaBinaryVersion
が "2.11"
の場合にのみ、Compile / scalaSource
キーを別のディレクトリに書き換えます。
Compile / scalaSource := {
val old = (Compile / scalaSource).value
scalaBinaryVersion.value match {
case "2.11" => baseDirectory.value / "src-2.11" / "main" / "scala"
case _ => old
}
}
build.sbt
ドメイン固有言語 (DSL) を使用して、設定とタスクの DAG を構築します。設定式は、設定、タスク、およびそれらの間の依存関係をエンコードします。
この構造は、Make (1976年)、Ant (2000年)、Rake (2003年) に共通しています。
基本的なMakefileの構文は次のようになります。
target: dependencies
[tab] system command1
[tab] system command2
ターゲット(デフォルトのターゲットはall
という名前です)が与えられたとすると、
Makefile
を見てみましょう。
CC=g++
CFLAGS=-Wall
all: hello
hello: main.o hello.o
$(CC) main.o hello.o -o hello
%.o: %.cpp
$(CC) $(CFLAGS) -c $< -o $@
make
を実行すると、デフォルトでall
という名前のターゲットが選択されます。このターゲットは依存関係としてhello
をリストしていますが、これはまだビルドされていないため、Makeはhello
をビルドします。
次に、Makeはhello
ターゲットの依存関係がビルド済みかどうかを確認します。hello
は2つのターゲット、main.o
とhello.o
をリストしています。最後のパターンマッチングルールを使用してこれらのターゲットが作成された後、main.o
とhello.o
をhello
にリンクするシステムコマンドが実行されます。
単にmake
を実行しているだけの場合、ターゲットとして何を求めているかに集中すればよく、中間生成物をビルドするために必要な正確なタイミングとコマンドはMakeによって計算されます。これは、依存関係指向プログラミング、またはフローベースプログラミングとして考えることができます。Makeは、DSLがタスクの依存関係を記述する一方で、アクションがシステムコマンドに委ねられるため、実際にはハイブリッドシステムと見なされます。
このハイブリッド性は、Ant、Rake、sbtなどのMakeの後継者にも引き継がれています。Rakefileの基本構文を見てみましょう。
task name: [:prereq1, :prereq2] do |t|
# actions (may reference prereq as t.name etc)
end
Rakeでなされたブレークスルーは、システムコマンドの代わりにプログラミング言語を使用してアクションを記述したことでした。
この方法でビルドを編成する動機はいくつかあります。
1つ目は重複排除です。フローベースプログラミングでは、タスクが複数のタスクによって依存されている場合でも、タスクは1回だけ実行されます。たとえば、タスクグラフに沿って複数のタスクがCompile / compile
に依存している場合でも、コンパイルは正確に1回だけ実行されます。
2つ目は並列処理です。タスクグラフを使用すると、タスクエンジンは相互に依存しないタスクを並行してスケジュールできます。
3つ目は関心の分離と柔軟性です。タスクグラフにより、ビルドユーザーはさまざまな方法でタスクを連携させることができ、sbtとプラグインはコンパイルやライブラリ依存関係管理などのさまざまな機能を再利用可能な関数として提供できます。
ビルド定義のコアデータ構造は、エッジが先行関係を示すタスクのDAGです。build.sbt
は、Makefile
やRakefile
と同様に、依存関係指向プログラミング、つまりフローベースプログラミングを表現するように設計されたDSLです。
フローベースプログラミングの主な動機は、重複排除、並列処理、およびカスタマイズ性です。