以構件為基礎的建構系統

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

本頁面說明以構件為基礎的建構系統,以及這類系統的建立理念。Bazel 是以構件為基礎的建構系統。雖然以工作為基礎的建構系統比建構指令碼更進步,但這類系統允許個別工程師定義自己的工作,因此賦予工程師過多權力。

以構件為基礎的建構系統具有系統定義的少量工作,工程師只能以有限的方式設定。工程師仍會告訴系統要建構「什麼」,但建構系統會決定「如何」建構。與以工作為基礎的建構系統一樣,以構件為基礎的建構系統 (例如 Bazel) 仍有建構檔案,但這些建構檔案的內容大不相同。Bazel 中的建構檔案並非圖靈完備指令碼語言中的一組必要指令,而是描述要建構的一組構件、這些構件的依附元件,以及影響建構方式的一組有限選項。工程師在指令列上執行 bazel 時,會指定要建構的一組目標 (即「內容」),而 Bazel 則負責設定、執行及排定編譯步驟 (即「方式」)。由於建構系統現在可全面控管要執行的工具,因此能提供更強大的保證,進而大幅提升效率,同時確保正確性。

功能觀點

很容易就能在以構件為基礎的建構系統和函式程式設計之間建立類比。傳統命令式程式設計語言 (例如 Java、C 和 Python) 會指定要依序執行的陳述式清單,就像以工作為基礎的建構系統可讓程式設計師定義一系列要執行的步驟。相較之下,函數式程式設計語言 (例如 Haskell 和 ML) 的結構更像是一連串的數學方程式。在函式語言中,程式設計師會說明要執行的運算,但會將運算執行的時間和確切方式等詳細資料留給編譯器。

這對應於在以構件為基礎的建構系統中宣告資訊清單,並讓系統瞭解如何執行建構作業的概念。許多問題無法輕易使用函式程式設計表示,但如果可以,就能從中獲益良多:語言通常能夠輕鬆地將這類程式平行化,並對其正確性做出強烈保證,這在命令式語言中是不可能的。最容易使用函式程式設計表示的問題,是單純涉及使用一系列規則或函式,將一筆資料轉換成另一筆資料的問題。而這正是建構系統的用途:整個系統實際上就是一個數學函式,會將來源檔案 (和編譯器等工具) 做為輸入內容,並產生二進位檔做為輸出內容。因此,以函式程式設計的原則為基礎建構建構系統,效果良好並不令人意外。

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

Google 的建構系統 Blaze 是第一個以構件為基礎的建構系統。Bazel 是 Blaze 的開放原始碼版本。

以下是 Bazel 中的建構檔 (通常名為 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 訣竅

以構件為基礎的建構系統從根本上解決了以工作為基礎的建構系統固有的平行處理和重複使用問題。但我們尚未解決稍早出現的幾個問題。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 檔案。

依附於目前工作區以外的檔案有風險。這些檔案隨時可能變更,因此建構系統可能需要不斷檢查檔案是否為最新版本。如果遠端檔案變更,但工作區原始碼未相應變更,也可能導致無法重現的建構作業,也就是建構作業可能今天正常運作,但隔天卻因未察覺的依附元件變更而失敗,且沒有明顯原因。最後,如果外部依附元件是由第三方擁有,可能會造成巨大的安全風險:如果攻擊者能夠入侵該第三方伺服器,就能以自己設計的內容取代依附元件檔案,進而完全掌控您的建構環境及其輸出內容。

基本問題是,我們希望建構系統能瞭解這些檔案,而不必將檔案存入原始碼控管。更新依附元件應是經過深思熟慮的選擇,但這項選擇應在中央位置做出一次,而不是由個別工程師管理或由系統自動管理。這是因為即使使用「Live at Head」模型,我們仍希望建構作業具有確定性,這表示如果您簽出上週的提交內容,應該會看到當時的依附元件,而不是現在的依附元件。

Bazel 和其他一些建構系統會要求提供工作區範圍的資訊清單檔案,列出工作區中每個外部依附元件的密碼編譯雜湊,藉此解決這個問題。雜湊是代表檔案的簡潔方式,無須將整個檔案存入原始碼控管。每當工作區參照新的外部依附元件時,系統會手動或自動將該依附元件的雜湊值新增至資訊清單。Bazel 執行建構作業時,會檢查快取依附元件的實際雜湊是否與資訊清單中定義的預期雜湊相符,只有在雜湊不同時才會重新下載檔案。

如果下載的構件雜湊與資訊清單中聲明的雜湊不同,除非更新資訊清單中的雜湊,否則建構作業會失敗。這項作業可以自動完成,但變更內容必須通過核准並簽入來源控管,建構作業才會接受新的依附元件。也就是說,系統一律會記錄依附元件的更新時間,且外部依附元件必須先變更工作區來源,才能變更自身。這也表示,在簽出舊版原始碼時,建構作業保證會使用該版本簽入時所用的依附元件 (否則如果這些依附元件已無法使用,建構作業就會失敗)。

當然,如果遠端伺服器無法使用或開始提供損毀的資料,仍可能造成問題,因為如果沒有其他副本,所有建構作業都會開始失敗。為避免這個問題,建議您將所有重要專案的依附元件,鏡像到您信任及控管的伺服器或服務。否則,即使登錄的雜湊值可確保安全性,您仍會受到第三方建構系統可用性的影響。