タスク

説明

タスクは sbt ビルドにおける作業の単位だ。タスクはコードを compile し、test を差分実行し、JAR ファイルを package するなど、ビルドに必要なことを何でも行う。

タスクの興味深い点は、Future[A]IO[A] と同様に並列プログラミングを可能にすることだ。FutureIO はしばしば for 内包表記で合成されるが、sbt はダイレクトスタイルの build.sbt DSL によってタスクの structured concurrency(構造化並行性)を提供する。

lazy val intTaskA = taskKey[Int]("")
lazy val intTaskB = taskKey[Int]("")
lazy val intTaskC = taskKey[Int]("")

intTaskA := 1
intTaskB := 2

// intTaskA と intTaskB は並列で実行される
intTaskC := {
  val a = intTaskA.value
  val b = intTaskB.value
  a + b
}

Def.Initialized[Task[A]]

内部的には、タスクキーを含むすべてのタスクは Def.Initialized[Task[A]] として型付けられている。

  1. Def.Initialized[A]。これはセッティングと同じ型であり、ロード時にセッティング/タスクグラフを構築するために使われる。A の初期化済みの値を表すためにこう命名されている。
  2. sbt.Task[A]。これは繰り返し実行可能な非同期計算を表すデータ型だ。sbt のタスクエンジンによって実行される部分だ。

これらを入れ子にすることで、タスクは A の非同期計算を生成するセッティング/タスクグラフのノードだと解釈できる。

セッティングとタスク

セッティングとタスクはどちらも値を生成するが、両者には大きな違いがある:

  • セッティングはビルドのロード時に評価される。タスクは必要に応じて実行され、多くの場合ユーザーがシェルから呼び出したときに応答する。

機能

タスクシステムにはいくつかの機能がある:

  1. セッティングシステムと統合することで、タスクをセッティングと同じくらい簡単に追加、削除、変更できる。
  2. タスクは値を生成する。他のタスクはタスク定義内でそのタスクに .value を呼び出すことで値にアクセスできる。
  3. タスクの値はその入力に基づいて自動的にキャッシュ化される。詳細はキャッシュ化タスク を参照。
  4. インプットタスクパーサーコンビネーター を使用して引数の構文を定義する。これによりコマンド と同様に柔軟な構文とタブ補完が可能になる。
  5. タスクグラフの構造を動的に変更できる。別のタスクの結果に基づいて、タスクを実行グラフに挿入できる。
  6. try/catch/finally と同様に、タスクの失敗を処理する方法がある。
  7. 各タスクは独自のロガーにアクセスできる。デフォルトでは、そのタスクのログを最初に画面に出力されるより詳細なレベルで保持する。

これらの機能については、以下のセクションで詳しく説明する。

タスクの定義

Hello world の例

build.sbt

lazy val hello = taskKey[Unit]("prints 'hello world'")

hello := println("hello world!")

コマンドラインから sbt hello を実行してタスクを呼び出す。sbt tasks を実行するとこのタスクが一覧に表示される。

キーを定義する

新しいタスクを宣言するには、TaskKey 型の lazy val を定義する:

lazy val sampleTask = taskKey[Int]("A sample task.")

val の名前は build.sbt やコマンドラインでタスクを参照するときに使われる。taskKey メソッドに渡す文字列はタスクの説明だ。taskKey に渡す型パラメーター(ここでは Int)はタスクが生成する値の型だ。

例として他にいくつかのキーを定義する:

build.sbt

lazy val intTask = taskKey[Int]("An int task")
lazy val stringTask = taskKey[String]("A string task")

これらの例はそのまま build.sbt の有効なエントリーだ。

タスクの実装

キーを定義した後にタスクを実装する主な部分は 3 つある:

  1. タスクに必要なセッティングや他のタスクを決める。これらはタスクの入力となる。
  2. これらの入力を基にタスクを実装するコードを定義する。
  3. タスクが属するスコープを決める。

これらの部分は、セッティングの各部分を組み合わせるのと同じように組み合わせる。

基本的なタスクの定義

タスクは := を使って定義する

build.sbt

lazy val intTask = taskKey[Int]("An int task")
lazy val stringTask = taskKey[String]("A string task")

intTask := 1 + 2

stringTask := Def.uncached {
  sys.props("user.name")
}

入力を持つタスク

他のタスクやセッティングを入力として持つタスクも := を使って定義する。入力の値は value メソッドで参照する。このメソッドは特殊な構文であり、:= の引数の中など、タスクを定義するときにのみ呼び出せる。以下は intTask が生成する値に 1 を加えて結果を返すタスクを定義する例だ。

sampleTask := intTask.value + 1

複数のセッティングも同様に扱う:

stringTask := {
  val x = sampleTask.value
  val i = intTask.value
  s"sample: $x, int: $i"
}

タスクのスコープ

セッティングと同様に、タスクも特定のスコープで定義できる。例えば、Compile スコープと Test スコープにはそれぞれ別の compile タスクがある。

次の例では、Test/sampleTaskCompile/intTask の結果を使う:

Test / sampleTask := (Compile / intTask).value * 3

演算子の優先順位について

中置メソッドの優先順位はメソッドの名前によって決まる。

  1. 代入メソッドの優先順位は最も低い。これは !=<=>= および = で始まる名前を除き、名前が = で終わるメソッドだ。
  2. 文字で始まるメソッドは次に高い優先順位を持つ。
  3. 記号で始まり 1 に含まれないメソッドは最も高い優先順位を持つ。 (このカテゴリーは先頭文字に応じてさらに細分化される。詳細は Scala 仕様を参照。)

したがって、前の例は以下と等価だ:

(Test / sampleTask).:=((Compile / intTask).value * 3)

実装の分離

タスクの実装をバインディングから分離できる。例えば、基本的な分離定義は次のようになる:

// 独立したタスクの実装を新規定義する
lazy val intTaskImpl: Initialize[Task[Int]] =
   Def.cachedTask { sampleTask.value - 3 }

// 実装を特定のキーにバインドする
intTask := intTaskImpl.value

.value はタスク定義内、たとえば上記の Def.task の中や := の引数の中でのみ使える点に注意。

既存タスクの変更

一般的には、以前のタスクを入力として宣言することでタスクを変更する。

// 初期定義
intTask := 3

// 前の定義を参照するオーバーライド定義
intTask := intTask.value + 1

以前のタスクを入力として宣言しないことで、タスクを完全にオーバーライドできる。次の例の各定義は前の定義を完全にオーバーライドする。つまり、intTask を実行すると #3 だけが出力される。

intTask := {
  println("#1")
  3
}

intTask := {
  println("#2")
  5
}

intTask :=  {
  println("#3")
  sampleTask.value - 3
}

エラー処理

タスク内のエラー

タスク内でエラー状態を表現するには、例外をスローする。例えば:

build.sbt

lazy val intTask = taskKey[Int]("An int task")

intTask := sys.error("failed")

sbt のタスクエンジンは sbt をクラッシュさせることなくエラーをキャッチして処理する:

sbt:aaa> intTask
[error] stack trace is suppressed; run last intTask for the full output
[error] (intTask) boom
[error] elapsed time: 0 s, cache 0%, 1 error

スタックトレースは last intTask を使って表示できる。

result

result メソッドは元のタスクの完全な Result[A1] 値を返す新しいタスクを作る。Result は型 A1 のタスク結果に対して Either[Incomplete, A1] と同じ構造を持つ。つまり、2 つのサブタイプがある:

  • Result.Inc:失敗時に Incomplete をラップする
  • Result.Value:成功時にタスクの結果をラップする。

したがって、result によって作られたタスクは元のタスクが成功しても失敗しても実行される。

build.sbt

lazy val intTask = taskKey[Int]("An int task")

intTask := sys.error("boom")

intTask := Def.uncached {
  intTask.result.value match
    case Result.Inc(inc: Incomplete) =>
      println("Ignoring failure: " + inc)
      3
    case Result.Value(v) =>
      println("Using successful result: " + v)
      v
}

これは元の intTask 定義を書き換え、元のタスクが失敗した場合は例外を出力して定数 3 を返し、成功した場合は値を出力して返すようにする。

failure

failure メソッドは元のタスクが正常に完了しなかったときに Incomplete 値を返す新しいタスクを作る。元のタスクが成功した場合、新しいタスクは失敗する。

build.sbt

lazy val intTask = taskKey[Int]("An int task")

intTask := sys.error("boom")

intTask := Def.uncached {
  println("Ignoring failure: " + intTask.failure.value)
  3
}

これは intTask を書き換え、元の例外を出力して定数 3 を返すようにする。

タスクの応用機能

Streams:タスクごとのログ記録

タスクごとのロガーは、Streams と呼ばれるタスク固有データのより一般的なシステムの一部だ。

Streams を使うには、streams タスクの値を取得する。ロガーは log メソッドで取得できる:

foo := {
  val s = streams.value
  s.log.debug("Saying hi...")
  s.log.info("Hello!")
}

特定のタスクのスコープでログセッティングをスコープ化できる:

foo / logLevel := Level.Debug
foo / traceLevel := 5

タスクの最後のログ出力を取得するには、last コマンドを使う:

$ last foo
[debug] Saying hi...
[info] Hello!

ログの保持レベルは persistLogLevelpersistTraceLevel セッティングで制御する。last コマンドはこれらのレベルに従ってログに記録された内容を表示する。これらのレベルはすでに記録されたログには影響しない。

条件付きタスク

タスクがトップレベルで if 式で構成されている場合、条件付きタスクが自動的に作られる:

bar := {
  if number.value < 0 then negAction.value
  else if number.value == 0 then zeroAction.value
  else posAction.value
}

通常の(アプリカティブな)タスク合成とは異なり、条件付きタスクは if 式として自然に期待されるように then 節と else 節の評価を遅延する。これは Def.taskDyn { ... } でも可能だが、ダイナミックタスクとは異なり、条件付きタスクは inspect コマンドで動作する。

Def.taskDyn による動的計算

タスクの結果を使って次に評価するタスクを決定することが有用な場合がある。これは Def.taskDyn を使って行う。

taskDyn の結果はランタイムに依存関係を導入するため、ダイナミックタスクと呼ばれる。taskDyn メソッドは Def.task:= と同じ構文をサポートするが、プレーンな値の代わりにタスクを返す点が異なる。

例えば、

build.sbt

lazy val stringTask = taskKey[String]("A string task")
lazy val intTask = taskKey[Int]("An int task")
lazy val foo = taskKey[Int]("foo")

val dynamic = Def.taskDyn {
  // `stringTask` の値に基づいて評価するものを決める
  if stringTask.value == "dev" then
    // dev モードのタスクを作る:stringTask の値が "dev" の場合にのみ評価される
    Def.task {
      3
    }
  else
    // 本番タスクを作る:stringTask の値が "dev" でない場合にのみ評価される
    Def.task {
      intTask.value + 5
    }
}

stringTask := "test"
intTask := 1

foo := Def.uncached {
  val num = dynamic.value
  println(s"number selected was $num")
  num
}

foo の唯一の静的な依存関係は stringTask だ。intTask への依存関係は非 dev モードでのみ導入される。

Warning

ダイナミックタスクは自分自身を参照できない。そうすると循環依存が発生する。 上の例では、taskDyn に渡されたコードが foo を参照すると循環依存になる。

Def.sequential の使用

Def.sequential はセミシーケンシャルなタスクを定義するヘルパー関数だ。ダイナミックタスクに似ているが、より簡単に定義できる。シーケンシャルタスクを説明するために、Compile / compile を実行した後に Compile / scalastyle タスクを実行する compilecheck というカスタムタスクを作ろう。

lazy val compilecheck = taskKey[Unit]("compile and then scalastyle")

lazy val root = (project in file("."))
  .settings(
    Compile / compilecheck := Def.sequential(
      Compile / compile,
      (Compile / scalastyle).toTask("")
    ).value
  )

完全にシーケンシャルな実行には、代わりに コマンド を使う。

複数のスコープから値を取得する

複数のスコープから値を取得する式の一般的な形式は次のとおりだ:

<setting-or-task>.all(<scope-filter>).value

Warning

ScopeFilter は必ず val として代入すること!これは .all マクロの 実装上の要件だ。

all メソッドはタスクとセッティングに暗黙的に追加される。Scopes を選択する ScopeFilter を受け取る。結果の型は Seq[A] で、A はキーの基底型だ。

よくあるシナリオは、scaladoc に渡すなど、すべてのサブプロジェクトのソースを一括処理するために取得することだ。値を取得したいタスクは sources であり、すべての非ルートプロジェクトの Compile コンフィギュレーションから値を取得する。これは次のようになる:

lazy val core = project

lazy val util = project

val filter = ScopeFilter(inProjects(core, util), inConfigurations(Compile))

lazy val root = rootProject
  .settings(
    sources := {
      // sources の各定義は Seq[File] 型であり、
      //   Seq[Seq[File]] が得られ、それを Seq[File] にフラット化する
      val allSources: Seq[Seq[File]] = sources.all(filter).value
      allSources.flatten
    }
  )