1. 跟踪文件输入和输出

跟踪文件输入和输出 

许多 sbt 任务依赖于一组文件。例如,package 任务会生成一个包含资源和类文件的 jar 文件,这些文件由 compile 任务为项目生成。从 1.3.0 版本开始,sbt 提供了一个文件管理系统,可以跟踪任何任务的输入和输出。任务可以查询其文件依赖项中自上次任务完成以来哪些文件发生了更改,从而允许其增量地仅重新构建已修改的文件。此系统与 触发执行 集成,以便在持续构建中自动监控任务的文件依赖项。

为了最好地说明文件跟踪系统,我们构建了一个 build.sbt,它说明了所有基本功能。该示例将是一个能够使用 gcc 构建共享库的项目。这将通过两个任务完成:buildObjects,它将 c 源文件编译为目标文件,以及 linkLibrary,它将目标文件链接到共享库中。这些可以使用以下代码定义:

import java.nio.file.Path
val buildObjects = taskKey[Seq[Path]]("Compiles c files into object files.")
val linkLibrary = taskKey[Path]("Links objects into a shared library.")

buildObjects 任务将依赖于 *.c 源文件输入。linkLibrary 任务依赖于 buildObjects 生成的输出 *.o 目标文件。这创建了一个构建管道:如果 buildObjects 的任何输入源在调用 linkLibrary 之间没有修改,那么编译和链接都不应该发生。相反,当检测到输入源更改时,sbt 应该同时生成与修改的源文件相对应的新目标文件,并将共享库链接起来。

文件输入 

任务自然需要指定其依赖的输入。这些使用 fileInputs 键设置,该键的类型为:Seq[Glob](请参见 通配符)。fileInputs 指定为 Seq[Glob],以便可以提供多个搜索查询,如果源文件位于多个目录中或同一任务需要不同的文件类型,则可能需要这样做。

当在给定作用域中设置 fileInputs 键时,sbt 会自动为该作用域生成一个名为 allInputFiles 的任务,该任务返回一个包含与 fileInputs 查询匹配的所有文件的 Seq[Path]。为了方便起见,为 Task[_] 定义了一个扩展方法,将 foo.inputFiles 转换为 (foo / allInputFiles).value。我们可以使用这些来编写 buildObjects 的简单实现:

import scala.sys.process._
import java.nio.file.{ Files, Path }
import sbt.nio._
import sbt.nio.Keys._

val buildObjects = taskKey[Seq[Path]]("Compiles c files into object files.")
buildObjects / fileInputs += baseDirectory.value.toGlob / "src" / "*.c"
buildObjects := {
  val outputDir = Files.createDirectories(streams.value.cacheDirectory.toPath)
  def outputPath(path: Path): Path =
    outputDir / path.getFileName.toString.replaceAll(".c$", ".o")
  val logger = streams.value.log
  buildObjects.inputFiles.map { path =>
    val output = outputPath(path)
    logger.info(s"Compiling $path to $output")
    Seq("gcc", "-c", path.toString, "-o", output.toString).!!
    output
  }
}

此实现将收集所有以 *.c 扩展名结尾的文件,并使用 gcc 对它们进行编译,并将它们编译到输出目录中。

sbt 会自动监控 fileInputs 指定的通配符匹配的任何文件。在本例中,修改 src 目录中的任何以 *.c 扩展名结尾的文件都会在持续构建中触发构建。

增量构建 

每次从 sbt shell 调用 buildObjects 时,它都会重新编译所有源文件。随着源文件数量的增加,这会变得很昂贵。除了 fileInputs 之外,sbt 还提供了另一个 api,inputFileChanges,它提供有关自上次任务成功完成以来哪些源文件发生了更改的信息。使用 inputFileChanges,我们可以使上面的构建增量化:

import scala.sys.process._
import java.nio.file.{ Files, Path }
import sbt.nio._
import sbt.nio.Keys._

val buildObjects = taskKey[Seq[Path]]("Generate object files from c sources")
buildObjects / fileInputs += baseDirectory.value.toGlob / "src" / "*.c"
buildObjects := {
  val outputDir = Files.createDirectories(streams.value.cacheDirectory.toPath)
  val logger = streams.value.log
  def outputPath(path: Path): Path =
    outputDir / path.getFileName.toString.replaceAll(".c$", ".o")
  def compile(path: Path): Path = {
    val output = outputPath(path)
    logger.info(s"Compiling $path to $output")
    Seq("gcc", "-fPIC", "-std=gnu99", "-c", s"$path", "-o", s"$output").!!
    output
  }
  val sourceMap = buildObjects.inputFiles.view.map(p => outputPath(p) -> p).toMap
  val existingTargets = fileTreeView.value.list(outputDir.toGlob / **).flatMap { case (p, _) =>
    if (!sourceMap.contains(p)) {
      Files.deleteIfExists(p)
      None
    } else {
      Some(p)
    }
  }.toSet
  val changes = buildObjects.inputFileChanges
  val updatedPaths = (changes.created ++ changes.modified).toSet
  val needCompile = updatedPaths ++ sourceMap.filterKeys(!existingTargets(_)).values
  needCompile.foreach(compile)
  sourceMap.keys.toVector
}

FileChangeReport 使得能够编写增量任务,而无需手动跟踪输入文件。它是一个密封的特征,由三个案例类实现:

  1. Changes - 表示一个或多个源文件已被修改。
  2. Unmodified - 自上次运行以来,没有一个源文件被修改。
  3. Fresh - 上次源文件哈希的缓存条目不存在。

有时在 inputFileChanges 的结果上进行模式匹配很方便:

foo.inputFileChanges match {
  case FileChanges(created, deleted, modified, unmodified)
    if created.nonEmpty || modified.nonEmpty =>
      build(created ++ modified)
      delete(deleted)
  case _ => // no changes
}

输入文件报告没有说明输出。这就是为什么 buildObjects 实现需要检查目标目录以查看哪些输出存在。在该示例中,输入和输出之间存在一对一映射,但在一般情况下并非如此。buildObjects 的实现可能在 fileInputs 中包含头文件。这些文件本身不会被编译,但它们可能会触发一个或多个 *.c 源文件的重新编译。

请注意,调用 buildObjects.inputFileChanges 也会在持续构建中自动监控 buildObjects / fileInputs

文件输出 

文件的输出通常最好指定为任务的结果。在上面的示例中,buildObjects 是一个 Task,它返回一个包含编译生成的目录文件的 Seq[Path]。sbt 会自动跟踪返回以下结果类型之一的任何任务的输出:PathSeq[Path]FileSeq[File]。我们可以使用它在 buildObjects 示例的基础上编写一个任务,将目标文件链接到共享库中:

val linkLibrary = taskKey[Path]("Links objects into a shared library.")
linkLibrary := {
  val outputDir = Files.createDirectories(streams.value.cacheDirectory.toPath)
  val logger = streams.value.log
  val isMac = scala.util.Properties.isMac
  val library = outputDir / s"mylib.${if (isMac) "dylib" else "so"}"
  val linkOpts = if (isMac) Seq("-dynamiclib") else Seq("-shared", "-fPIC")
  if (buildObjects.outputFileChanges.hasChanges || !Files.exists(library)) {
    logger.info(s"Linking $library")
    (Seq("gcc") ++ linkOpts ++ Seq("-o", s"$library") ++
      buildObjects.outputFiles.map(_.toString)).!!
  } else {
    logger.debug(s"Skipping linking of $library")
  }
  library
}

这里的跟踪比较简单,因为链接共享库不是增量式的。因此,如果 buildObjects 的任何输出发生了更改,或者库不存在,我们都必须重新构建。

fileInputs 类似,还有一个 fileOutputs 键。当输出具有已知模式时,可以使用它作为在任务中返回输出文件的替代方法。例如,buildObjects 可以定义为:

val buildObjects = taskKey[Unit]("Compiles c files into object files.")
buildObjects / fileOutputs := target.value / "objects" / ** / "*.o"

当使用不透明的外部工具时,这很有用,因为输入到输出的映射是未知的。

allInputFiles 类似,如果 foo 的返回类型是 Seq[Path]PathSeq[File]File 之一,则会自动为任务 foo 生成一个返回类型为 Seq[Path]allOutputFiles 任务。如果指定了 foo / outputFiles,也会生成它。当同时指定了 fileOutputs 并且返回类型表示文件或文件集合时,allOutputFiles 的结果是任务返回的文件和 ouputFiles 描述的文件的非重复并集。调用 foo.outputFiles(foo / allOutputFiles).value 的语法糖。

过滤器 

fileInputsfileOutputs 可以比其 Glob 模式指定的范围更窄地进行过滤。sbt 提供了四个类型为 sbt.nio.file.PathFilter 的设置:1. fileInputIncludeFilter - 仅包含也匹配此过滤器的文件输入 2. fileInputExcludeFilter - 排除也匹配此过滤器的任何文件输入 3. fileOutputIncludeFilter - 仅包含也匹配此过滤器的文件输入 4. fileOutputExcludeFilter - 排除任何也匹配此过滤器的文件输出

默认情况下,sbt 将 `scala fileInputExcludeFilter := HiddenFileFilter.toNio || DirectoryFilter ` Both fileInputIncludeFilter and fileInputOutputFilter are set to AllPassFilter.toNio. The fileOutputExcludeFilter is set to NothingFilter.toNio`.

要从 buildObjects 中排除名称中包含 test 的文件,请编写:

buildObjects / fileInputExcludeFilter := "*test*"

要保留以前对隐藏文件和目录的排除,请编写:

buildObjects / fileInputExcludeFilter :=
  (buildObjects / fileInputExcludeFilter).value || "*test*"

或者

buildObjects / fileInputExcludeFilter ~= { ef => ef || "*test*" }

在大多数情况下,不需要设置 fileInputIncludeFilter,因为路径名称过滤应该由 fileInputs 本身处理。同样,通常也不需要过滤输出。

清理输出 

sbt 会自动生成一个作用域为任务 fooclean 实现,只要它也生成了 allOutputFiles 任务。调用 foo / clean 将删除之前由 foo 生成的所有文件。它不会重新评估 foo。例如,调用 buildObjects / clean 将删除之前调用 buildObjects 生成的所有目标文件。生成的清理任务不是可传递的。调用 linkLibrary / clean 将删除共享库,但不会删除 buildObjects 生成的目标文件。

文件更改跟踪 

对于 sbt 跟踪的每个输入或输出文件,都有一个关联的 FileStamp。这可以是文件的最后修改时间或哈希值。默认情况下,输入使用哈希值跟踪,输出使用最后修改时间跟踪。要更改此设置,请设置 inputFileStamperoutputFileStamper

val generateSources = taskKey[Seq[Path]]("Generates source files from json schema.")
generateSources / fileInputs := baseDirectory.value.toGlob / "schema" / ** / "*.json"
generateSources / outputFileStamper := FileStamper.Hash

持续构建文件监控 

在持续构建 ~bar 中,对于任意任务 bar,给定某个任务 foo,在 bar 中对 foo.inputFilesfoo.inputFileChanges 的任何调用都将导致 foo / fileInputs 指定的所有 glob 在持续构建中被监控。可传递文件输入依赖项会自动被监控。例如,~linkLibrary 持续构建命令将监控为 buildObjects 定义的 *.c 源文件。

只有当输入文件哈希值发生更改时,才会触发重建。可以使用以下方法覆盖此行为:

Global / watchForceTriggerOnAnyChange := true

对使用 foo.outputFilesfoo.outputFileChanges 收集的文件输出的更改不会触发重建。

部分管道评估/错误处理 

每个文件的标记在每个任务的基础上进行跟踪。它们只有在增量任务本身成功时才会更新。在上面的示例中,这意味着当前文件最后修改时间 buildObjects 仅在 linkLibrary 任务成功时才由 linkLibrary 任务存储。这意味着 buildObjects 可以多次运行,并在调用 linkLibrarylinkLibrary 之间,linkLibrary 将看到对 buildObjects 输出的累积更改。

如果 linkLibrary 无法完成,sbt 还将跳过更新对应于 linkLibrarybuildObjects 输出的最后修改时间,因为通常无法知道哪些文件已成功处理。