1. タスクグラフ

タスクグラフ 

ビルド定義の続きとして、このページでは 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 の後に cleanclean の後に update、または両方を並行して実行する可能性があります。

.value 呼び出しのインライン化 

上記で説明したように、.value は、他のタスクや設定への依存関係を表現するために使用される特別なメソッドです。build.sbt に慣れるまでは、すべての .value 呼び出しをタスク本体の先頭に配置することをお勧めします。

ただし、より慣れてくると、タスク/設定をより簡潔にできるため、.value 呼び出しをインライン化したい場合があります。これにより、変数名を考える必要がなくなります。

いくつかの例をインライン化しました

scalacOptions := {
  val x = clean.value
  update.value.allConfigurations.take(3)
}

.value 呼び出しがインライン化されているか、タスク本体のどこかに配置されているかに関係なく、タスク本体に入る前に評価されることに注意してください。

タスクの検査 

上記の例では、scalacOptionsupdate および 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.")

: scalacOptionschecksums は互いに何も関係ありません。それらは単に値の型が同じ 2 つのキーであり、1 つはタスクです。

scalacOptionschecksums にエイリアスする 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 のポイントは何ですか? 

build.sbt ドメイン固有言語 (DSL) を使用して、設定とタスクの DAG を構築します。設定式は、設定、タスク、およびそれらの間の依存関係をエンコードします。

この構造は、Make (1976年)、Ant (2000年)、Rake (2003年) に共通しています。

Make入門 

基本的なMakefileの構文は次のようになります。

target: dependencies
[tab] system command1
[tab] system command2

ターゲット(デフォルトのターゲットはallという名前です)が与えられたとすると、

  1. Makeはターゲットの依存関係がビルド済みかどうかを確認し、まだビルドされていない依存関係をビルドします。
  2. Makeはシステムコマンドを順番に実行します。

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.ohello.oをリストしています。最後のパターンマッチングルールを使用してこれらのターゲットが作成された後、main.ohello.ohelloにリンクするシステムコマンドが実行されます。

単にmakeを実行しているだけの場合、ターゲットとして何を求めているかに集中すればよく、中間生成物をビルドするために必要な正確なタイミングとコマンドはMakeによって計算されます。これは、依存関係指向プログラミング、またはフローベースプログラミングとして考えることができます。Makeは、DSLがタスクの依存関係を記述する一方で、アクションがシステムコマンドに委ねられるため、実際にはハイブリッドシステムと見なされます。

Rake 

このハイブリッド性は、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は、MakefileRakefileと同様に、依存関係指向プログラミング、つまりフローベースプログラミングを表現するように設計されたDSLです。

フローベースプログラミングの主な動機は、重複排除、並列処理、およびカスタマイズ性です。