继续从 构建定义,本页将更详细地解释 build.sbt
定义。
与其将 settings
视为键值对,不如将其类比为一个有向无环图 (DAG) 的任务,其中边表示先于关系。我们称之为任务图。
在我们深入之前,让我们回顾一下关键术语。
.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 使用宏将这些提升到任务主体之外。无论在主体中出现在哪一行,在任务引擎评估 scalacOptions
的开头 {
之前,update
和 clean
任务都会完成。
查看以下示例
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)
中。
另一件需要注意的重要事情是,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
,而 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.")
注意:scalacOptions
和 checksums
之间没有关系。它们只是两个具有相同值类型的键,其中一个是任务。
可以编译一个将 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) 来构建一个设置和任务的 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
列出了两个目标:main.o
和 hello.o
。一旦使用最后一个模式匹配规则创建了这些目标,系统命令才会执行,将 main.o
和 hello.o
链接到 hello
。
如果您只是运行 make
,您可以专注于您想要的目标,而构建中间产品的确切时间和命令将由 Make 确定。我们可以将其视为依赖项导向编程或基于流的编程。Make 实际上被认为是一个混合系统,因为虽然 DSL 描述了任务依赖关系,但操作被委托给系统命令。
这种混合性在 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,用于表达依赖关系编程,或基于流的编程,类似于 Makefile
和 Rakefile
。
基于流编程的主要动机是去重、并行处理和可定制性。