以構件為基礎的建構系統

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

本頁將介紹以構件為基礎的建構系統,以及建立這些系統的理念。Bazel 是一種以成果為基礎的建構系統。雖然以工作為基礎的建構系統比建構指令碼更進步,但讓個別工程師定義自己的工作,會讓他們擁有過多權力。

以構件為基礎的建構系統具有少數由系統定義的工作,工程師可以以有限的方式進行設定。工程師仍會告知系統要建構的內容,但建構系統會決定建構方式。與任務導向建構系統一樣,以構件為基礎的建構系統 (例如 Bazel) 仍有建構檔案,但這些建構檔案的內容大不相同。與 Turing 完整指令碼語言中的指令集不同,Bazel 中的 buildfile 是宣告式資訊清單,可說明要建構的一系列構件、這些構件的依附元件,以及影響建構方式的有限選項集。工程師在指令列上執行 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 有聰明的方法解決這些問題,我們應該在繼續之前討論這些問題。

工具做為依附元件

我們先前遇到的問題之一是,建構作業取決於機器上安裝的工具,而且由於工具版本或位置不同,因此在不同系統上重現建構作業可能會很困難。如果專案使用的語言需要根據建構或編譯的平台 (例如 Windows 與 Linux) 使用不同的工具,問題就會變得更加棘手,因為每個平台執行相同工作時所需的工具略有不同。

Bazel 會將工具視為各個目標的依附元件,解決這項問題的第一部分。工作區中的每個 java_library 都會隱含地依附 Java 編譯器,預設為知名編譯器。每當 Bazel 建構 java_library 時,都會檢查指定的編譯器是否可在已知位置使用。和其他依附元件一樣,如果 Java 編譯器有所變更,系統就會重建所有依附於該編譯器的構件。

Bazel 會設定建構設定,解決問題的第二部分,也就是平台獨立性。目標並非直接依賴工具,而是依賴設定類型:

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

擴充建構系統

Bazel 內建多種熱門程式設計語言的目標,但工程師總是希望能做得更多。以工作為基礎的系統的好處之一,就是可靈活支援任何建構程序,因此最好不要在以構件為基礎的建構系統中放棄這項功能。幸運的是,Bazel 允許透過新增自訂規則來擴充支援的目標類型。

如要在 Bazel 中定義規則,規則作者必須宣告規則所需的輸入內容 (以 BUILD 檔案中傳遞的屬性形式),以及規則產生的固定輸出內容組合。作者也會定義該規則產生的動作。每個動作都會宣告其輸入和輸出內容、執行特定可執行檔或將特定字串寫入檔案,並可透過輸入和輸出內容連結至其他動作。這表示動作是建構系統中最低層級的可組合元件,只要動作只使用宣告的輸入和輸出,即可執行任何動作,而 Bazel 會負責安排動作並適當快取結果。

由於無法阻止動作開發人員在動作中引入非確定性程序等行為,因此系統並非萬無一失。但實際上這種情況並不常見,而且將濫用行為的可能性一直推到動作層級,可以大幅降低發生錯誤的機會。支援許多常見語言和工具的規則在網路上隨處可見,因此大多數專案都不需要自行定義規則。即使是這類規則,也只需要在存放區的一個中央位置定義規則定義,也就是說,大多數工程師都能使用這些規則,不必擔心實作問題。

隔離環境

動作似乎可能會遇到其他系統中的工作遇到的問題,也就是說,寫入相同檔案的動作會不會彼此衝突?事實上,Bazel 會使用沙箱機制,避免發生這些衝突。在支援的系統上,每個動作都會透過檔案系統沙箱與其他動作隔離。因此,每個動作只能看到受限的檔案系統檢視畫面,其中包含已宣告的輸入內容和產生的任何輸出內容。這項功能是由 Linux 上的 LXC 等系統強制執行,而 LXC 是 Docker 背後的相同技術。這表示動作無法與其他動作發生衝突,因為它們無法讀取未宣告的任何檔案,且未宣告的任何寫入檔案會在動作完成時遭到捨棄。Bazel 也會使用沙箱來限制透過網路通訊的動作。

讓外部依附元件具備決定性

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

依賴目前工作區外部的檔案存在風險。這些檔案隨時都可能變更,因此建構系統可能需要不斷檢查這些檔案是否為最新版本。如果遠端檔案變更,但工作區原始碼中沒有相對應的變更,也會導致無法重現的建構作業,也就是說,建構作業可能在某天運作,但在下一個工作天因未察覺的依附元件變更而無故失敗。最後,如果外部依附元件屬於第三方,可能會帶來巨大的安全風險:如果攻擊者能夠滲透該第三方伺服器,就能將依附元件檔案取代為他們自行設計的內容,進而完全掌控您的建構環境及其輸出內容。

基本問題是,我們希望建構系統能夠瞭解這些檔案,而不需要將這些檔案檢查至原始碼控管。更新依附元件應是經過深思熟慮的選擇,但這項選擇應在中央位置進行一次,而非由個別工程師或系統自動管理。這是因為即使使用「Live at Head」模型,我們仍希望建構作業具有決定性,也就是說,如果您檢查上週的版本,應會看到當時的依附元件,而非目前的依附元件。

Bazel 和其他一些建構系統會要求使用工作區範圍的資訊清單檔案,列出工作區中每個外部依附元件的密碼編譯雜湊。雜湊是一種簡潔的方式,可用於唯一代表檔案,而無須將整個檔案檢查至原始碼控管。每當從工作區參照新的外部依附元件時,該依附元件的雜湊就會手動或自動新增至資訊清單。當 Bazel 執行建構作業時,會檢查快取的依附元件實際雜湊與資訊清單中定義的預期雜湊是否相符,並只在雜湊不同時重新下載檔案。

如果我們下載的構件雜湊與資訊清單中宣告的雜湊不同,除非更新資訊清單中的雜湊,否則建構作業會失敗。這項作業可以自動執行,但必須先核准變更並將其納入來源控管,建構作業才會接受新的依附元件。也就是說,系統一律會記錄依附元件更新的時間,而且外部依附元件必須與工作區來源的變更相應,才能變更。這也表示在檢出較舊版本的原始碼時,系統可保證建構作業會使用與檢入該版本時相同的依附元件 (如果這些依附元件不再可用,則會失敗)。

當然,如果遠端伺服器無法使用或開始提供損毀的資料,這仍可能會造成問題,如果您沒有該依附元件的其他副本,這可能會導致所有版本開始失敗。為避免發生這種問題,建議您針對任何非簡單的專案,將所有依附元件鏡像到您信任且可控管的伺服器或服務。否則,即使已提交的雜湊可確保建構系統的安全性,您仍必須仰賴第三方提供建構系統。