1. 任務圖

任務圖 

接續建置定義,此頁面將更詳細地說明 build.sbt 定義。

與其將 settings 視為鍵值對,不如將其視為任務的有向無環圖 (DAG),其中邊緣表示先發生。我們稱此為任務圖

術語 

在我們深入探討之前,我們先複習一下關鍵術語。

  • 設定/任務運算式:.settings(...) 內的條目。
  • 鍵:設定運算式的左側。它可以是 SettingKey[A]TaskKey[A]InputKey[A]
  • 設定:由 SettingKey[A] 的設定運算式定義。該值在載入期間計算一次。
  • 任務:由 TaskKey[A] 的任務運算式定義。該值在每次調用時計算。

宣告對其他任務的相依性 

build.sbt DSL 中,我們使用 .value 方法來表達對另一個任務或設定的相依性。value 方法很特別,只能在 := (或 +=++=,我們稍後會看到)的參數中呼叫。

作為第一個範例,請考慮定義取決於 updateclean 任務的 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.valueclean.value 宣告任務相依性,而 ur.allConfigurations.take(3) 是任務的主體。

.value 不是一般的 Scala 方法呼叫。build.sbt DSL 使用巨集將它們提升到任務主體之外。無論 updateclean 任務出現在主體中的哪一行,任務引擎都會在評估 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 shell 輸入 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 shell 輸入 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) 內也已執行。

另一個需要注意的重要事項是,不保證 updateclean 任務的順序。它們可能會先執行 update 再執行 clean、先執行 clean 再執行 update,或兩者同時平行執行。

內嵌 .value 呼叫 

如上所述,.value 是一種特殊的方法,用於表達對其他任務和設定的相依性。在您熟悉 build.sbt 之前,我們建議您將所有 .value 呼叫放在任務主體的頂部。

但是,當您越來越熟悉時,您可能希望內嵌 .value 呼叫,因為它可以使任務/設定更簡潔,並且您不必想出變數名稱。

我們已經內嵌了一些範例

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

請注意,無論 .value 呼叫是內嵌還是在任務主體中的任何位置,它們仍然會在進入任務主體之前進行評估。

檢查任務 

在上面的範例中,scalacOptionsupdateclean 任務有相依性。如果您將上述內容放在 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 shell 上的樣子

> 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

接下來,採用這兩個鍵(來自 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 彼此無關。它們只是兩個具有相同值類型的鍵,其中一個是任務。

可以編譯一個將 scalacOptions 別名為 checksumsbuild.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. 檢查目標的相依性是否已建置,並建置任何尚未建置的相依性。
  2. 依序執行系統命令。

讓我們來看一個 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 列為其相依性,而 hello 尚未建置,因此 Make 會建置 hello

接著,Make 會檢查 hello 目標的相依性是否已建置。hello 列出了兩個目標:main.ohello.o。一旦使用最後一個模式匹配規則建立這些目標後,才會執行系統命令將 main.ohello.o 連結到 hello

如果您只是執行 make,您可以專注於您想要的目標,而建置中間產品所需的確切時機和命令則由 Make 決定。我們可以將其視為以相依性為導向的程式設計或以流程為基礎的程式設計。Make 實際上被認為是一個混合系統,因為雖然 DSL 描述了任務相依性,但動作會委派給系統命令。

Rake 

這種混合性延續到 Make 的後繼者,例如 Ant、Rake 和 sbt。看看 Rakefile 的基本語法

task name: [:prereq1, :prereq2] do |t|
  # actions (may reference prereq as t.name etc)
end

Rake 的突破在於它使用程式語言來描述動作,而不是系統命令。

混合流程基礎程式設計的優點 

以這種方式組織建置有幾個動機。

首先是去重複化。使用流程基礎程式設計,即使一個任務被多個任務依賴,也只會執行一次。例如,即使任務圖中的多個任務都依賴 Compile / compile,編譯也只會執行一次。

其次是平行處理。透過使用任務圖,任務引擎可以平行排程彼此不相依的任務。

第三是關注點分離和靈活性。任務圖讓建置使用者可以以不同的方式將任務連接在一起,而 sbt 和外掛程式可以提供各種功能,例如編譯和函式庫相依性管理,這些功能可以重複使用。

總結 

建置定義的核心資料結構是任務的 DAG (有向無環圖),其中邊表示先後關係。build.sbt 是一個 DSL,旨在表達以相依性為導向的程式設計或以流程為基礎的程式設計,類似於 MakefileRakefile

流程基礎程式設計的主要動機是去重複化、平行處理和可自訂性。