以構件為基礎的建構系統

回報問題 查看來源

本頁面說明以構件為基礎的建構系統,以及其建立背後的理念。Bazel 是一個以構件為基礎的建構系統。以工作為基礎的建構系統是高於建構指令碼的理想步驟,但藉由允許個別工程師定義自己的工作,對個別工程師提供太多電源。

以構件為基礎的建構系統需要由系統定義的少量工作,工程師可透過限制方式進行設定。工程師仍然會向系統告知「要建構的內容」,但建構系統會決定建構的「方式」。與以任務為基礎的建構系統一樣,構件型建構系統 (例如 Bazel) 仍會有建構檔案,但這些建構檔案的內容非常不同。Bazel 中的建構檔案不僅是描述如何產生輸出內容的強制性指令,而是描述一組要建構的成果、其依附元件,以及影響建構方式的有限選項組合,工程師在指令列上執行 bazel 時,會指定一組要建構的目標 (即「內容」),而 Bazel 會負責設定、執行及安排編譯步驟 (方法)。由於建構系統現在可完全控管要在何時執行哪些工具,因此可帶來更強大的保證,進一步提高效率,同時確保正確性。

功能性的觀點

在以構件為基礎的建構系統和功能程式設計之間,很容易地進行類比。傳統的命令式程式設計語言 (例如 Java、C 和 Python) 指定要連續執行的陳述式清單,就像工作型建構系統可讓程式設計人員定義一系列的執行步驟一樣。對比度,函式程式設計語言 (例如 Haskell 和機器學習) 的結構就更像一系列數學方程式。在功能性語言中,程式設計師會說明要執行的運算作業,但會將該運算執行時機和確切方式的詳細資料儲存到編譯器。

這相當於在以構件為基礎的建構系統中宣告資訊清單,讓系統知道如何執行建構。許多問題無法輕易透過功能程式表達,但這確實能受益於這項功能:語言通常能夠輕易平行處理這類程式,並強力保證其正確性,以命令式語言無法實現。使用功能程式設計表示最簡單的問題,就是只要使用一系列規則或函式,將一個資料轉換成另一段資料即可。這其實就是建構系統:整個系統實際上是一種數學函式,可將來源檔案和編譯器等工具當做輸入內容,並產生二進位檔做為輸出內容。因此,根據功能程式設計的原則建立建構系統並不令人意外。

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

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 具備巧妙的解決方式,因此我們應先討論這些方法,再繼續進行後續步驟。

做為依附元件的工具

我們先前遇到的問題是,建構作業必須依賴安裝在機器上的工具,而由於工具版本或位置不同,在各個系統上重現建構作業可能相當困難。如果專案使用的語言需要根據建立或編譯的平台 (例如 Windows 與 Linux) 而需要不同工具,這個問題會更加困難,而且每個平台都需要使用不同的工具組合來完成相同的工作。

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

Bazel 會透過設定建構設定來解決平台獨立性的第二部分。他們會依賴設定類型,而不是直接根據工具指定:

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

擴充建構系統

雖然 Bazel 準備了適用於多種熱門程式設計語言的目標,但工程師也希望有更多的選擇。工作型系統的優點之一,就是能夠靈活支援任何類型的建構程序,最好不會在以構件為基礎的建構系統中實現這一點。幸好,Bazel 允許新增自訂規則來擴充其支援的目標類型。

如要在 Bazel 中定義規則,規則作者會宣告規則需要的輸入 (以 BUILD 檔案中傳遞的屬性格式),以及規則產生的一組固定輸出內容。作者也定義了由該規則產生的動作。每項動作都會宣告其輸入和輸出內容、執行特定執行檔或將特定字串寫入檔案,並且透過其輸入和輸出內容連接至其他動作。這表示動作是建構系統中最低層級的可組合單元。只要動作只使用宣告的輸入和輸出內容,就可以執行任何所需操作,而 Bazel 會負責為動作安排執行時程,並視情況快取結果。

由於無法阻止動作開發人員在行為中導入非確定性程序,因此無法確保系統十分萬無一失。但是這種情況並不會經常發生,並將濫用行為的可能情況推進到動作層級,大幅減少出錯的機會。支援許多常見語言和工具的規則已廣泛地在線上取得,大多數專案都不需要自行定義規則。即使這些規則確實如此,規則定義也只需要在存放區中的一個中央位置定義,這意味著大部分的工程師可以直接使用這些規則,而不需要擔心其實作的問題。

隔離環境

動作聽起來可能會與其他系統中的工作發生相同的問題,因此無法將同時寫入相同檔案的動作又彼此衝突嗎?實際上,Bazel 會使用沙箱機制避免這類衝突。在支援的系統上,每項動作都會透過檔案系統沙箱隔離,與其他動作彼此隔離。實際上,每個動作只能看到檔案系統受限的檢視畫面,其中包含已宣告的輸入內容,以及已產生的任何輸出內容。這會由系統強制執行,例如 Linux 上的 LXC (Linux 採用的技術) 相同。這表示動作無法彼此衝突,因為他們無法讀取非自己宣告的任何檔案,而且動作完成後,會捨棄未宣告的任何檔案。Bazel 也會使用沙箱限制動作無法透過網路進行通訊。

使外部依附元件具有確定性

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

視目前工作區外的檔案而定,可能會有風險。這些檔案隨時都可能會變更,而這可能需要建構系統持續檢查檔案是否為最新內容。如果遠端檔案變更時,工作區原始碼沒有相應的變更,則也可能導致無法重現的建構作業。建構作業可能會發生一天,但由於未察覺依附元件變更,因此建構失敗,導致下一個版本失敗。最後,當外部依附元件為第三方擁有時,可能會帶來巨大的安全風險:如果攻擊者能夠滲透第三方伺服器,他們可以將依附元件檔案替換成他們自己的設計,這樣或許就能完整控管您的建構環境和輸出內容。

根本問題在於,我們希望建構系統能知道這些檔案,而不需要簽入原始碼控制項。更新依附元件時應謹慎選擇,但您應在同一處進行選擇,而不是由個別工程師管理,或由系統自動設定。這是因為即使採用「在頭頂」模型,我們仍希望建構作業具備確定性,這表示若您查看上週的修訂版本,您應看到依原樣顯示的依附元件,而非目前作業。

Bazel 和部分其他建構系統會要求一個適用於整個工作區的資訊清單檔案,針對工作區中的每個外部依附元件列出密碼編譯雜湊,藉此解決這項問題。雜湊會以簡潔的方式呈現檔案,而不必將整個檔案檢查至原始碼控制項。每當從工作區參照新的外部依附元件時,該依附元件的雜湊會以手動或自動方式新增至資訊清單。當 Bazel 執行建構作業時,它會將快取依附元件的實際雜湊與資訊清單中定義的預期雜湊值進行比對,並只在雜湊不同的情況下重新下載檔案。

如果我們下載的構件含有與資訊清單中宣告的雜湊不同的雜湊,除非資訊清單中的雜湊值更新,否則建構作業會失敗。系統可以自動執行這項操作,但該項變更必須獲得核准並在原始碼控管中經過檢查,才會接受新的依附元件。也就是說,系統一律會記錄依附元件更新時間,而且如果工作區來源沒有相對應的變更,外部依附元件就無法變更。這也意味著,查看舊版的原始碼時,建構作業保證會使用簽入該版本時所用的相同依附元件 (否則如果這些依附元件已無法使用,版本就會失敗)。

當然,如果遠端伺服器無法使用或開始提供毀損的資料,仍可能有問題。如果您沒有該依附元件的其他副本,這可能會導致所有建構作業開始失敗。為了避免這個問題,我們建議在所有不重要的專案中,將所有依附元件複製到您信任和控管的伺服器或服務上。否則,即使在登錄雜湊可確保安全性,您隨時都能仰賴第三方來建構系統。