以構件為基礎的建構系統

本頁面說明以構件為基礎的建構系統,以及建立背後的理念。Bazel 是以構件為基礎的建構系統。雖然以工作為基礎的建構系統是比建構指令碼中的最佳步驟,但這些系統允許個別工程師定義自己的工作,藉此提供過多功能。

構件型建構系統中有許多由系統定義的任務,工程師可以有限度設定。工程師仍會告知系統「建構內容」,但建構系統會決定建構的「方式」。與以工作為基礎的建構系統一樣,構件型建構系統 (例如 Bazel) 仍會有建構檔案,但這些建構檔案的內容截然不同。Bazel 中的建構檔案並非用於說明產生輸出內容的命令式指令集,而是描述一組要建構的構件、其依附元件,以及會影響建構方式的有限選項組合。工程師在指令列上執行 bazel 時,會指定要建構的目標 (也就是「內容」),而 Bazel 負責設定、執行及排定編譯步驟 (即做法)。由於建構系統現在可以完全控管何時要執行的工具,因此能以更可靠的方式確保效能大幅提升,同時確保資料正確性。

功能觀點

您可以輕鬆在成果型建構系統和功能程式設計之間進行類比。傳統的命令式程式設計語言 (例如 Java、C 和 Python) 會指定要逐一執行的陳述式清單,方法與讓程式設計師定義一系列要執行的步驟相同。相反地,函式程式設計語言 (例如 Haskell 和 ML) 的結構較一系列數學方程式。在功能性語言中,程式設計師會描述要執行的運算,但會將運算執行時間和確切方式提供給編譯器的詳細資料。

這個做法對應到在以構件為基礎的建構系統中宣告資訊清單,並讓系統瞭解如何執行建構作業的概念。許多問題無法透過功能性程式輕鬆表達,但之所以能創造最大好處,那就是語言通常能大幅平行地平行處理這類程式,並嚴格保證其正確性,以命令式語言來說無法呈現。使用函式程式設計來表達的問題最簡單,就是僅使用一系列規則或函式將一項資料轉換為另一個資料。這就是建構系統的運作方式:整個系統實際上是數學函式,可將來源檔案 (以及編譯器等工具) 做為輸入內容,並產生二進位檔做為輸出內容。因此,以功能程式設計原則為基礎建構建構系統並不令人意外。

瞭解以構件為基礎的建構系統

Blaze 是 Google 的建構系統,是首款以構件為基礎的建構系統。Bazel 是 Blaze 的開放原始碼版本。

以下是 Bazel 中 buildfile (通常命名為 BUILD) 的外觀:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

在 Bazel 中,BUILD 檔案會定義目標,而本文中的兩種目標類型為 java_binaryjava_library。每個目標都會對應至可由系統建立的成果:二進位檔目標會產生可直接執行的二進位檔,而程式庫目標則會產生可供二進位檔或其他程式庫使用的程式庫。每個目標都包含:

  • name:在指令列和其他目標中參照目標的方式
  • srcs:要編譯以為目標建立構件的來源檔案
  • deps:這個目標必須在這個目標之前建構,並連結到這個目標

依附元件可以位於同一個套件中 (例如 MyBinary:mylib 依附元件),或位於相同來源階層的其他套件 (例如 mylib//java/com/example/common 上的依附元件)。

與以工作為基礎的建構系統一樣,您可以使用 Bazel 的指令列工具執行建構作業。如要建構 MyBinary 目標,請執行 bazel build :MyBinary。在乾淨的存放區中首次輸入該指令後,Bazel:

  1. 剖析工作區中的每個 BUILD 檔案,建立依附元件圖表。
  2. 使用圖形判斷 MyBinary 的遞移依附元件;也就是說,MyBinary 依附的每個目標以及這些目標依附的每個目標。
  3. 依序建構各個依附元件。Bazel 會先建構無需其他依附元件的每個目標,並持續追蹤仍須為每個目標建構哪些依附元件。當目標的所有依附元件建構完畢之後,Bazel 就會開始建構該目標。此程序會持續,直到每個 MyBinary 遞移依附元件建構完成為止。
  4. 建構 MyBinary 以產生最終的可執行二進位檔,然後在步驟 3 建構的所有依附元件中連結。

基本上,這裡的情況可能與使用以工作為基礎的建構系統非常不同。事實上,最終結果是相同的二進位檔,且產生該程序的流程會分析大量步驟,以找出依附元件,然後依序執行這些步驟。但二者之間存在重大差異第一個目標會出現在步驟 3:由於 Bazel 知道每個目標只會產生 Java 程式庫,因此知道只需執行 Java 編譯器,而不是執行使用者定義的指令碼,這樣便能放心同時執行這些步驟。相較於在多核心機器上一次建構一個目標,這可能會帶來規模級的效能提升,而且只有在建構系統自主執行策略時,才有可能這麼做,因為這樣可更有效地保證平行運作。

不過,這麼做的好處不只在於平行處理。再者,如果開發人員第二次輸入 bazel build :MyBinary 且不做任何變更,我們就可以明顯看出:Bazel 會在不到一秒內結束,並顯示訊息指出目標已是最新版本。這是因為我們先前提過的功能程式設計範例,而且 Bazel 知道每個目標都是執行 Java 編譯器的結果,而且知道 Java 編譯器的輸出內容僅取決於輸入內容,因此只要輸入內容未變更,輸出內容即可重複使用。這項分析會在每個層級執行;當 MyBinary.java 變更時,Bazel 知道要重新建立 MyBinary 但可以重複使用 mylib。如果 //java/com/example/common 的來源檔案有所變更,Bazel 知道要重新建立該程式庫、mylibMyBinary,但重複使用 //java/com/example/myproduct/otherlib。由於 Bazel 知道每個步驟執行的工具屬性,因此能夠僅重新建構最低的構件組合,同時確保不會產生過時的建構作業。

以構件重新建構建構程序 (而非工作) 相當簡單但功能強大。降低程式設計師的彈性後,建構系統就能進一步瞭解在建構的每個步驟中完成的工作。您可以運用這些知識,平行處理建構程序並重複使用其輸出內容,讓建構作業更有效率。但這只是第一步,這些平行處理的構成要素,以及重複運用在分散式及高擴充性建構系統的基礎。

Bazel 其他實用的小技巧

構件型的建構系統基本上可透過平行運作來解決問題,並且重複使用工作型建構系統固有的重複使用問題。但稍早仍有一些問題尚未解決。Bazel 有巧妙的解決這些問題,我們應先討論再繼續。

做為依附元件的工具

我們先前遇到的一個問題是,建構取決於安裝於機器上的工具,而由於工具版本或位置不同,在各系統上重新產生建構作業並不容易。如果專案所使用的語言需要根據建構基礎或編譯的平台 (例如 Windows 與 Linux) 而需要不同的工具,問題會更加困難,而且每個平台都需要不同的工具組合才能執行相同的工作。

Bazel 會將工具視為每個目標的依附元件,藉此解決這個問題的第一個部分。工作區中的每個 java_library 會以隱含方式依附於 Java 編譯器,後者預設為已知的編譯器。每當 Bazel 建構 java_library 時,都會檢查並確認指定的編譯器可在已知位置使用。就像任何其他依附元件一樣,當 Java 編譯器變更時,所有依附於該編譯器的成果都會重新建構。

Bazel 藉由設定建構設定,解決了第二部分的問題 (平台獨立性)。並非直接根據工具指定目標,而是取決於設定類型:

  • 主機設定:在建構期間執行的建構工具
  • 目標設定:建構您最終要求的二進位檔

擴充建構系統

Bazel 內建數種熱門程式設計語言的目標,但工程師始終是想完成更多目標,而工作型系統的好處之一是可在支援任何類型的建構程序時發揮它作用,因此最好不要在以成果為基礎的建構系統中彌補這方面的不足。幸好,Bazel 允許透過新增自訂規則來擴充支援的目標類型。

如要在 Bazel 中定義規則,規則作者會宣告規則所需的輸入內容 (以 BUILD 檔案中傳遞的屬性形式),以及規則產生的一組固定輸出內容。作者也會定義該規則將產生的動作。每項動作都會宣告輸入和輸出、執行特定執行檔或將特定字串寫入檔案,並且可以透過輸入和輸出連結至其他動作。這表示動作是建構系統中最低層級的可組合單元,無論動作是否只使用宣告的輸入內容和輸出內容,Bazel 都會依照自身需求執行排程動作並快取結果。

我們的系統並非萬無一失,因為動作開發人員無法阻止行動開發人員從事某些行為,像是在行動中導入非確定性程序等。但實際發生的情況很少見,而且將濫用的可能性推送至動作層級,可大幅降低發生錯誤的機會。支援許多常見語言和工具的規則已全面於線上取得,大多數專案不需自行定義規則。即使如此,規則定義也只需要在存放區的集中位置定義,也就是說,大多數工程師都能使用這些規則,完全不必擔心實作。

隔離環境

動作聽起來可能會遇到與其他系統中的工作相同的問題,所以如果動作同時寫入同一個檔案,最終就會相互衝突嗎?事實上,Bazel 會使用沙箱機制來避免這些衝突。在支援的系統中,每個動作都會透過檔案系統沙箱與其他動作隔離。實際上,每個動作只能看到檔案系統的受限檢視畫面,其中包含其宣告的輸入內容,以及該系統產生的所有輸出內容。這會由 Linux 上的 LXC 等系統強制執行,與 Docker 背後的技術相同。這表示動作不會互相衝突,因為它們無法讀取任何未宣告的檔案,而且在動作完成時,系統會捨棄任何其寫入但未宣告的檔案。Bazel 也會使用沙箱來限制動作無法透過網路進行通訊。

確定外部依附元件

還有一個問題:建構系統通常需要從外部來源下載依附元件 (無論是工具或程式庫),而不是直接建構。您可在範例中透過 @com_google_common_guava_guava//jar 依附元件 (從 Maven 下載 JAR 檔案) 看到這一點。

視目前工作區之外的檔案而定,可能會有風險。這些檔案隨時可能會變更,因此可能需要建構系統持續檢查檔案是否更新。如果遠端檔案變更時,沒有在工作區原始碼中進行對應的變更,也可能導致無法重現的建構作業:建構可能會在一天內運作,但由於系統未註意到的依附元件變更,下個原因就失敗。最後,外部依附元件可能會在第三方擁有的情況下造成巨大的安全性風險:如果攻擊者能夠竊取第三方伺服器,他們可以將依附元件檔案替換成自己的設計,因而可能讓他們完全控制您的建構環境及其輸出內容。

其中一個根本問題是,我們希望建構系統瞭解這些檔案,而不用將其簽入原始碼控制系統。更新依附元件應謹慎行事,但這個選項應集中於單一中央位置進行,而不是由個別工程師進行管理或由系統自動管理。這是因為即使擁有「在頭上運作」模型,我們仍希望建構具確定性,這表示如果您從上週查看修訂項目,則應看到依附元件,而非現在情況。

Bazel 和一些其他建構系統需要一個工作區的資訊清單檔案,針對工作區中的每個外部依附元件列出加密編譯雜湊,藉此解決這個問題。雜湊是一種簡潔的方式來呈現檔案,而不需在原始碼控管系統中檢查整個檔案。每當工作區參照新的外部依附元件時,該依附元件的雜湊值會以手動或自動方式加入資訊清單。Bazel 執行建構作業時,會根據資訊清單中定義的預期雜湊檢查其快取依附元件的實際雜湊值,並且僅在雜湊不同時重新下載檔案。

如果下載成果的雜湊與資訊清單中宣告的雜湊不同,則除非更新資訊清單中的雜湊,否則建構作業會失敗。這項作業會自動完成,但變更內容必須先獲準並檢查到來源控制項中,版本才會接受新的依附元件。這代表依附元件的更新時間一律都會記錄,而外部依附元件在工作區來源發生相應變更時就無法變更。這也意味著,當您檢查舊版的原始碼時,建構作業保證會使用該版本簽入時所使用的相同依附元件 (否則,如果這些依附元件已無法使用,就會失效)。

當然,如果遠端伺服器無法使用或開始提供毀損的資料,這可能不是問題;如果您沒有其他可用的依附元件副本,這可能會導致所有建構作業開始失敗。如要避免這個問題,建議您針對所有較常見的專案,將其所有依附元件鏡像到您信任及控管的伺服器或服務上。否則,即使簽收的雜湊保證安全無虞,您還是必須一直設法解決建構系統的可用性。