以工作為基礎的建構系統

回報問題 查看來源 Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

本頁面說明以工作為基礎的建構系統、這類系統的運作方式,以及可能發生的複雜情況。在 Shell 指令碼之後,以工作為基礎的建構系統是建構作業的下一個邏輯演進。

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

在以工作為基礎的建構系統中,基本工作單位是工作。每項工作都是指令碼,可執行任何類型的邏輯,且工作會指定其他工作做為依附元件,這些依附元件必須先執行。目前使用的大部分主要建構系統 (例如 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 使用「目標」一詞代表「工作」,並使用「工作」一詞代表「指令」)。每個工作都會執行 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 工作時執行的程式碼,相當於下列殼層指令碼:

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

如果去除語法,建構檔案和建構指令碼其實沒有太大差異。但我們已從中獲益良多。我們可以在其他目錄中建立新的建構檔案,並將這些檔案連結在一起。我們可以輕鬆新增任意複雜的任務,這些任務會依附在現有任務上。我們只需要將單一工作名稱傳遞至 ant 指令列工具,該工具就會判斷需要執行的所有項目。

Ant 是舊版軟體,最初於 2000 年發布。在 Ant 之後的幾年,Maven 和 Gradle 等其他工具都經過改良,並新增自動管理外部依附元件和更簡潔的語法 (不含任何 XML) 等功能,基本上已取代 Ant。但這些新系統的本質不變:工程師可透過這些系統,以有原則的模組化方式編寫建構指令碼,做為工作,並提供執行這些工作和管理工作間依附元件的工具。

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

因為工程師基本上可以將任何指令碼定義為工作,所以這些工具非常強大,幾乎能讓您完成任何想像得到的作業。但這項強大功能也有缺點,隨著建構指令碼越來越複雜,以工作為基礎的建構系統可能會變得難以使用。這類系統的問題在於,它們最終會提供過多電力給工程師,但系統電力不足。由於系統不知道指令碼的用途,因此必須非常保守地排定及執行建構步驟,導致效能不佳。系統也無法確認每個指令碼是否正常運作,因此指令碼往往會變得越來越複雜,最後又需要進行偵錯。

難以平行處理建構步驟

現代開發工作站功能強大,具備多個核心,可平行執行多個建構步驟。但即使看起來應該可以,以工作為基礎的系統通常也無法平行執行工作。假設任務 A 依附於任務 B 和 C。由於工作 B 和 C 彼此沒有依附元件,因此可以同時執行,讓系統更快完成工作 A 嗎?如果他們沒有觸及任何相同資源,或許可以。但可能不是,或許兩者都使用相同檔案追蹤狀態,同時執行會導致衝突。一般來說,系統無法得知這類衝突,因此必須冒著發生衝突的風險 (導致罕見但難以偵錯的建構問題),或是限制整個建構作業在單一程序中的單一執行緒上執行。這會造成強大的開發人員機器資源浪費,而且完全排除在多部機器上分配建構作業的可能性。

難以執行漸進式建構

好的建構系統可讓工程師執行可靠的增量建構作業,因此即使是小幅變更,也不需要從頭重建整個程式碼集。如果建構系統速度緩慢,且無法因上述原因平行處理建構步驟,這點就格外重要。但很遺憾,以工作為基礎的建構系統在這裡也面臨困難。由於工作可以執行任何動作,因此一般來說,無法檢查工作是否已完成。許多工作只需要一組來源檔案,並執行編譯器來建立一組二進位檔;因此,如果基礎來源檔案沒有變更,就不需要重新執行工作。但如果沒有額外資訊,系統就無法確定,因為工作可能會下載可能已變更的檔案,或寫入每次執行時可能不同的時間戳記。為確保正確性,系統通常必須在每次建構期間重新執行每項工作。部分建構系統會讓工程師指定工作需要重新執行的條件,藉此嘗試啟用增量建構。有時這可行,但通常比表面上看起來更棘手。舉例來說,在 C++ 等語言中,檔案可由其他檔案直接納入,因此如果不剖析輸入來源,就無法判斷必須監控變更的完整檔案集。工程師通常會採取捷徑,而這些捷徑可能會導致罕見且令人沮喪的問題,也就是即使不該重複使用工作結果,系統仍會重複使用。如果這種情況經常發生,工程師就會習慣在每次建構前執行清除作業,以取得全新狀態,完全失去增量建構的意義。判斷何時需要重新執行工作,是一項出乎意料的細微工作,而且這項工作由機器處理會比人類更有效率。

難以維護及偵錯指令碼

最後,以工作為基礎的建構系統所強制執行的建構指令碼通常難以使用。雖然建構指令碼通常較少受到審查,但建構指令碼與建構中的系統一樣都是程式碼,因此很容易隱藏錯誤。以下列舉幾個使用以工作為基礎的建構系統時非常常見的錯誤:

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

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

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