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 使用宏将这些提升到任务主体之外。无论在主体中出现在哪一行,在任务引擎评估 scalacOptions 的开头 { 之前,updateclean 任务都会完成。

查看以下示例

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 调用是内联的,还是放置在任务主体中的任何位置,它们仍然在进入任务主体之前进行评估。

检查任务 

在上面的示例中,scalacOptions 依赖于 updateclean 任务。如果您将上述内容放在 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,而 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,例如。它 Just Works,因为 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 设为 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

这是一个现实的例子。这将 Compile / scalaSource 键重新连接到一个不同的目录,但仅当 scalaBinaryVersion"2.11" 时。

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 列出了两个目标: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,其中边表示 happens-before 关系。build.sbt 是一种 DSL,用于表达依赖关系编程,或基于流的编程,类似于 MakefileRakefile

基于流编程的主要动机是去重、并行处理和可定制性。