任务

描述

任务是 sbt 构建中的工作单元。任务可以 compile 代码、增量运行 testpackage JAR 文件,或执行构建所需的任何其他操作。

任务的一个有趣之处在于,它们支持并行编程,类似于 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 的异步计算。

设置与任务

设置和任务都会产生值,但两者之间有一个重大区别:

  • 设置在构建加载时求值。任务按需执行,通常是响应用户从 shell 中调用。

功能

任务系统具有以下几个特点:

  1. 通过与设置系统集成,任务可以像设置一样方便地添加、删除和修改。
  2. 任务会产生值。其他任务可以在任务定义中对其调用 value 来访问该任务的值。
  3. 任务的值会根据其输入自动缓存。详情参见缓存任务
  4. 输入任务使用解析器组合子定义参数语法,从而与命令一样支持灵活的语法和 Tab 补全。
  5. 可以动态更改任务图的结构。任务可以根据另一个任务的结果注入执行图。
  6. 有多种方式处理任务失败,类似于 try/catch/finally
  7. 每个任务都可以访问自己的 Logger,默认情况下,该 Logger 会以比最初打印到屏幕更详细的级别保留该任务的日志。

以下各节将详细讨论这些功能。

定义任务

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 中的有效条目。

实现任务

定义键之后,实现任务主要分为三个部分:

  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 的结果加一并返回的任务。

sampleTask := intTask.value + 1

多个设置的处理方式类似:

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

任务作用域

与设置一样,任务也可以在特定作用域中定义。例如,CompileTest 作用域各有单独的 compile 任务。

在以下示例中,Test/sampleTask 使用了 Compile/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] 相同,即有两个子类型:

  • 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 方法获取 Logger:

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 是用于定义半顺序任务的辅助函数,与动态任务类似,但更易定义。为演示顺序任务,我们创建一个名为 compilecheck 的自定义任务,依次运行 Compile / compileCompile / scalastyle

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 方法被隐式添加到任务和设置中,接受用于选择 ScopesScopeFilter。结果类型为 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
    }
  )