1. 追蹤檔案輸入與輸出

追蹤檔案輸入與輸出 

許多 sbt 任務都依賴於檔案集合。例如,package 任務會產生一個包含資源和類別檔案的 jar 檔案,而這些檔案是由專案的 compile 任務所產生。從 1.3.0 版本開始,sbt 提供了一個檔案管理系統,可以追蹤任何任務的輸入和輸出。任務可以查詢自上次任務完成以來,其檔案依賴項中哪些已變更,從而使其可以增量重建僅修改過的檔案。此系統與觸發執行整合,因此會在持續建置中自動監控任務的檔案依賴項。

為了最好地說明檔案追蹤系統,我們建構了一個 build.sbt,其中說明了所有基本功能。該範例將是一個可以使用 gcc 在 c 中建置共用函式庫的專案。這將透過兩個任務完成: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 物件檔案。這會建立一個建置管線:如果在呼叫 linkLibrary 之間未修改 buildObjects 的任何輸入來源,則不應進行編譯和連結。相反地,當偵測到輸入來源變更時,sbt 應同時產生與修改後的來源檔案相對應的新物件檔案,並連結共用函式庫。

檔案輸入 

任務指定其依賴的輸入是很自然的。這些是使用 fileInputs 鍵設定的,其類型為:Seq[Glob](請參閱Globs)。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 指定的 glob 所符合的任何檔案。在此情況下,在 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 可以讓我們編寫增量任務,而無需手動追蹤輸入檔案。它是一個由三個 case 類別實作的密封 trait

  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 實作需要檢查目標目錄以查看存在哪些輸出的原因。在該範例中,輸入和輸出之間存在 1:1 的對應關係,但一般情況下不一定是這樣。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 的語法糖。

篩選器 

除了 Glob 模式所指定的範圍外,fileInputsfileOutputs 還可以進行額外的篩選。sbt 提供了四種 sbt.nio.file.PathFilter 類型的設定:1. fileInputIncludeFilter — 只包含符合此篩選器的檔案輸入。2. fileInputExcludeFilter — 排除符合此篩選器的所有檔案輸入。3. fileOutputIncludeFilter — 只包含符合此篩選器的檔案輸出。4. fileOutputExcludeFilter — 排除符合此篩選器的所有檔案輸出。

預設情況下,sbt 將 scala fileInputExcludeFilter := HiddenFileFilter.toNio || DirectoryFilter 設定為排除隱藏檔案和目錄。 fileInputIncludeFilterfileInputOutputFilter 都設定為 AllPassFilter.toNiofileOutputExcludeFilter 則設定為 NothingFilter.toNio

若要從 buildObjects 中排除名稱包含 test 的檔案,請寫入:

buildObjects / fileInputExcludeFilter := "*test*"

若要保留先前排除的隱藏檔案和目錄,請寫入:

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

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

在大多數情況下,由於路徑名稱的篩選應該由 fileInputs 本身處理,因此不應該需要設定 fileInputIncludeFilter。通常也不需要篩選輸出。

清理輸出 

當 sbt 生成 allOutputFiles 任務時,也會自動生成範圍限定於 foo 任務的 clean 實作。呼叫 foo / clean 將會移除 先前foo 生成的所有檔案。它不會重新評估 foo。例如,呼叫 buildObjects / clean 將會移除先前呼叫 buildObjects 所生成的所有物件檔。產生的 clean 任務不是可傳遞的。呼叫 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 收集的檔案輸出變更不會觸發重新建置。

部分管道評估/錯誤處理 

每個檔案的時間戳記都是根據每個任務追蹤的。只有在增量任務本身成功時才會更新。在上面的範例中,這表示只有在 linkLibrary 成功時,才會由 linkLibrary 任務儲存 buildObjects 的當前檔案最後修改時間。這表示在呼叫 linkLibrary 之間,可以多次執行 buildObjects,並且 linkLibrary 將會看到 buildObjects 輸出的累計變更。

如果 linkLibrary 無法完成,sbt 也會跳過更新與 linkLibrary 對應的 buildObjects 輸出的最後修改時間,因為通常無法知道哪些檔案已成功處理。