以構件為基礎的建構系統

回報問題 查看原始碼

本頁說明依據構件建構建構系統及其建立理念。Bazel 是以成果為基礎的建構系統,雖然以工作為基礎的建構系統是建構指令碼的一大步驟,但可以讓個別工程師定義自己的工作,藉此為個別工程師提供過多功能。

以成果為基礎的建構系統有少量的系統定義,且工程師能夠以有限的方式設定這些工作。工程師仍告知系統要建構的「內容」,但建構系統決定建構的方式。如同工作型建構系統,以成果為基礎的建構系統 (如 Bazel) 仍有建構檔案,但這些建構檔案的內容截然不同。Bazel 的建構檔案是一種宣告式資訊清單,用來說明要建構的構件、它們的依附元件,以及影響建構方式的有限選項組合,而不是在 Turing 完整指令碼語言中寫到命令性的指令集,而是用來說明如何產生輸出內容。當工程師在指令列執行 bazel 時,會指定要建構的一組目標 (內容),而 Bazel 負責設定、執行及安排編譯步驟 (方法)。由於建構系統現在可完全控制執行工具的時機,因此可以提供更完善的保證,進一步提升效率,同時確保正確性。

功能性觀點

採用以構件為基礎的建構系統與功能性程式之間的相似方法很容易。傳統強制性的程式設計語言 (例如 Java、C 和 Python) 會指定要依序執行的陳述式清單,如同以工作為基礎的建構系統,可讓程式設計師定義要執行的一系列步驟。相較之下,功能性程式設計語言 (例如 Haskell 和 ML) 的結構更像是一系列數學方程式。在功能語言中,程式會描述要執行的計算,但會將運算執行的時間及確切方式保留給編譯器。

這會對應至在以構件為基礎的建構系統中宣告資訊清單的概念,並讓系統決定如何執行建構作業。使用功能性程式無法輕易表達許多問題,但也確實能夠受惠於這些問題:語言經常能夠平行平行處理這類程式,並保證提供絕對正確,但使用命令式語言可能無法達成的正確性。使用功能編碼表達表達的最簡單問題就是,利用一系列規則或函式將一段資料轉換成另一個資料。而這正是建構系統,實際上是整個系統的數學函式,會將來源檔案 (以及編譯器等工具) 做為輸入內容,然後產生二進位檔做為輸出內容。因此,最好能根據建構程式的原則來建構建構系統。

瞭解構件建構系統

Google 的建構系統 Blaze 是第一個以構件為基礎的建構系統。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 會在執行建構作業時,根據快取中定義的預期雜湊來檢查其快取依附元件的實際雜湊,並在雜湊不同時重新下載檔案。

如果下載的成果與資訊清單中宣告的構件不同,除非更新資訊清單中的雜湊,否則建構將會失敗。此設定可自動完成,但是變更必須通過核准,並檢查來源控管,該版本才會接受新的依附元件。這表示依附元件一律都會更新,且如果工作區來源中沒有相應的變更,外部依附元件就無法變更。這也意味著,在查看舊版原始碼時,該版本保證會使用與檢查該版本當下相同的依附元件 (否則,如果這些依附元件已無法使用,建構也會失敗)。

當然,如果遠端伺服器無法使用或開始提供損毀的資料,問題可能仍會造成為了避免這個問題,針對任何非凡的專案,您應將所有依附元件鏡射到您信任的伺服器或服務上。否則,您每次在系統的建構系統可用性中,都一定會受第三方任何人的攻擊,即使已簽入雜湊也能保證其安全性。