以工作為基礎的建構系統

回報問題 查看原始碼 Nightly · 8.0 7.4 . 7.3 · 7.2 · 7.1 · 7.0 · 6.5

本頁面將介紹以工作為基礎的建構系統、其運作方式,以及以工作為基礎的系統可能發生的一些複雜情況。在殼層指令碼之後,以工作為基礎的建構系統是建構作業的下一個邏輯演進。

瞭解以工作為基礎的建構系統

在以工作為基礎的建構系統中,工作的基本單位是工作。每個工作都是可執行任何類型邏輯的指令碼,且工作會將其他工作指定為必須先執行的依附元件。目前使用的大多數主要建構系統 (例如 Ant、Maven、Gradle、Grunt 和 Rake) 都是以工作為基礎。大多數現代化建構系統都要求工程師建立建構檔案,說明如何執行建構作業,而非使用殼層指令碼。

請參考 Ant 手冊中的範例:

<project name="MyProject" default="dist" basedir=".">
   <description>
     simple example build file
   </description>
   <!-- set global properties for this build -->
   <property name="src" location="src"/>
   <property name="build" location="build"/>
   <property name="dist" location="dist"/>

   <target name="init">
     <!-- Create the time stamp -->
     <tstamp/>
     <!-- Create the build directory structure used by compile -->
     <mkdir dir="${build}"/>
   </target>
   <target name="compile" depends="init"
       description="compile the source">
     <!-- Compile the Java code from ${src} into ${build} -->
     <javac srcdir="${src}" destdir="${build}"/>
   </target>
   <target name="dist" depends="compile"
       description="generate the distribution">
     <!-- Create the distribution directory -->
     <mkdir dir="${dist}/lib"/>
     <!-- Put everything in ${build} into the MyProject-${DSTAMP}.jar file -->
     <jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
   </target>
   <target name="clean"
       description="clean up">
     <!-- Delete the ${build} and ${dist} directory trees -->
     <delete dir="${build}"/>
     <delete dir="${dist}"/>
   </target>
</project>

建構檔案是以 XML 編寫,並定義建構作業的簡單中繼資料,以及工作清單 (XML 中的 <target> 標記)。(Ant 使用「target」一詞代表「task」,並使用「task」一詞代表「command」)。每項工作都會執行 Ant 定義的可能指令清單,包括建立及刪除目錄、執行 javac 和建立 JAR 檔案。這組指令可透過使用者提供的外掛程式擴充,涵蓋任何類型的邏輯。每項工作也可以透過 depends 屬性定義其依附的工作。這些依附元件會形成無環圖,如圖 1 所示。

顯示依附元件的壓克力圖

圖 1. 顯示依附元件的非循環圖

使用者可透過提供工作給 Ant 指令列工具來執行建構作業。舉例來說,當使用者輸入 ant dist 時,Ant 會執行以下步驟:

  1. 載入目前目錄中名為 build.xml 的檔案,並剖析該檔案,建立圖 1 所示的圖表結構。
  2. 尋找指令列上提供的 dist 工作,並發現該工作依附於名為 compile 的工作。
  3. 尋找名為 compile 的工作,並發現該工作會依附名為 init 的工作。
  4. 尋找名為 init 的工作,並發現該工作沒有依附元件。
  5. 執行 init 工作中定義的指令。
  6. 在已執行該工作所有依附元件的情況下,執行 compile 工作中定義的指令。
  7. 在已執行該工作所有依附元件的情況下,執行 dist 工作中定義的指令。

最後,Ant 在執行 dist 工作時執行的程式碼,等同於下列 Shell 指令碼:

./createTimestamp.sh
mkdir build/
javac src/* -d build/
mkdir -p dist/lib/
jar cf dist/lib/MyProject-$(date --iso-8601).jar build/*

去除語法後,Buildfile 和 Build 指令碼其實沒有太大差異。但我們已經從中獲得許多收穫。我們可以在其他目錄中建立新的 buildfile,並將這些檔案連結在一起。我們可以輕鬆以任意複雜的方式,新增依賴現有工作的新工作。我們只需將單一工作名稱傳遞至 ant 指令列工具,即可決定需要執行的所有作業。

Ant 是一款舊軟體,最初於 2000 年發布。在過去幾年,Maven 和 Gradle 等其他工具已改善 Ant 的功能,並透過新增自動管理外部依附元件和不含任何 XML 的簡潔語法等功能,實際取代了 Ant。不過,這些新系統的本質仍相同:讓工程師以原則性和模組化的方式編寫建構指令碼,做為工作,並提供工具來執行這些工作,以及管理其中的依附元件。

以工作為基礎的建構系統的黑暗面

由於這些工具可讓工程師將任何指令碼定義為工作,因此功能非常強大,幾乎可執行任何您能想到的操作。但這項功能也有缺點,隨著建構指令碼變得越來越複雜,以工作為基礎的建構系統可能會變得難以使用。這種系統的問題在於,實際上會讓工程師擁有過多權力,而系統的權力不足。由於系統不知道指令碼的運作方式,因此必須非常謹慎地排定及執行建構步驟,因此效能會受到影響。而且系統無法確認每個指令碼是否正常運作,因此指令碼往往會變得越來越複雜,最後變成另一個需要偵錯的項目。

難以並行執行建構步驟

現代化的開發工作站功能強大,可透過多個核心並行執行多個建構步驟。但以工作為基礎的系統通常無法並行執行工作,即使看起來似乎可以並行執行也一樣。假設工作 A 依附於工作 B 和 C。由於工作 B 和 C 彼此之間沒有依賴關係,是否可以同時執行,讓系統更快執行工作 A?或許,如果不觸及任何相同的資源,但也許不是:也許兩者都使用相同的檔案追蹤狀態,同時執行時就會發生衝突。系統通常無法得知這類衝突,因此必須冒著發生這些衝突的風險 (導致發生很少見但很難偵錯的建構問題),或是限制整個建構作業在單一程序的單一執行緒上執行。這可能會造成強大開發人員機器的巨大浪費,而且完全排除在多部機器上分發版本的可能性。

難以執行漸進式建構

優質的建構系統可讓工程師執行可靠的增量建構作業,因此即使只進行小幅變更,也不需要從頭開始重建整個程式碼集。如果建構系統速度緩慢,且無法因上述原因並行執行建構步驟,這項功能就格外重要。但不幸的是,以工作為基礎的建構系統在這方面也遇到困難。由於工作可執行任何操作,因此一般無法檢查工作是否已完成。許多工作只需取得一組來源檔案,並執行編譯器來建立一組二進位檔,因此如果基礎來源檔案未變更,就不需要重新執行。不過,如果沒有其他資訊,系統無法確定這項資訊,因為工作可能會下載可能已變更的檔案,或者每次執行時寫入的時間戳記可能不同。為確保正確性,系統通常必須在每次建構期間重新執行每項工作。部分建構系統會讓工程師指定需要重新執行工作條件的情況,藉此啟用增量建構。有時這麼做是可行的,但通常這項問題比看起來複雜得多。舉例來說,在允許其他檔案直接納入檔案的 C++ 等語言中,如果不解析輸入來源,就無法判斷必須監控變更的整組檔案。工程師通常會使用捷徑,而這些捷徑可能會導致罕見且令人沮喪的問題,導致任務結果重複使用,即使不應重複使用也一樣。這種情況如果經常發生,工程師就會養成在每次建構前執行清除作業的習慣,以便取得新狀態,這完全違背了採用增量建構的初衷。判斷何時需要重新執行工作,其實相當複雜,而且比起人類,機器更能勝任這項工作。

難以維護及偵錯指令碼

最後,以工作為基礎的建構系統所強制執行的建構指令碼通常很難使用。雖然建構指令碼通常不受太多審查,但它們與建構系統一樣是程式碼,因此很容易隱藏錯誤。以下是使用以工作為基礎的建構系統時,常見的錯誤範例:

  • 工作 A 會依賴工作 B 產生特定檔案做為輸出內容。工作 B 的擁有者不知道其他工作會依賴該工作,因此將其變更為在不同位置產生輸出內容。只有在有人嘗試執行工作 A 並發現失敗時,才能偵測到這項問題。
  • 工作 A 依賴工作 B,而工作 B 依賴工作 C,後者會產生特定檔案,做為工作 A 所需的輸出內容。工作 B 的擁有者決定不再需要依賴工作 C,這會導致工作 A 失敗,即使工作 B 完全不關心工作 C 也一樣!
  • 新任務的開發人員不小心對執行任務的機器做出假設,例如工具的位置或特定環境變數的值。這項工作在他們的電腦上運作正常,但其他開發人員嘗試時卻失敗。
  • 工作包含非確定性元件,例如從網際網路下載檔案,或在版本中新增時間戳記。如今,使用者每次執行建構作業時,可能會得到不同的結果,這表示工程師不一定能重現並修正彼此的失敗情形,或自動化建構系統發生的失敗情形。
  • 具有多個依附元件的任務可能會產生競爭狀況。如果工作 A 同時依賴工作 B 和工作 C,且工作 B 和 C 都修改相同的檔案,則工作 A 會根據先完成的工作 (B 或 C) 取得不同的結果。

在本節所述的以工作為準的架構中,沒有任何通用方法可解決這些效能、正確性或可維護性問題。只要工程師可以編寫在建構期間執行的任意程式碼,系統就無法取得足夠資訊,無法一律快速且正確地執行建構作業。為解決這個問題,我們需要將部分權力從工程師手中移除,並交還給系統,並重新定義系統的角色,不以執行工作,而是以產生構件為主。

這種做法促成了 Blaze 和 Bazel 等以構件為基礎的建構系統。