Bazel 程式碼集

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

本文說明程式碼集,以及 Bazel 的結構。適用於願意為 Bazel 做出貢獻的人,不適用於一般使用者。

簡介

Bazel 的程式碼庫很大 (約 35 萬行生產程式碼和約 26 萬行測試程式碼),沒有人熟悉整個環境:大家都非常瞭解自己的特定領域,但很少人知道各個方向的山丘後方有什麼。

為避免使用者在旅程中迷失於黑暗森林,找不到簡單的道路,本文將概述程式碼集,方便使用者開始使用。

Bazel 原始碼的公開版本位於 GitHub,網址為 github.com/bazelbuild/bazel。這並非「事實來源」,而是衍生自 Google 內部來源樹狀結構,其中包含 Google 外部無用的額外功能。長期目標是將 GitHub 設為資料來源。

我們接受透過一般 GitHub 提取要求機制提交的貢獻內容,並由 Google 員工手動匯入內部來源樹狀結構,然後重新匯出至 GitHub。

用戶端/伺服器架構

Bazel 的大部分內容都位於伺服器程序中,且會在建構作業之間保留在 RAM 中。這樣 Bazel 就能在建構作業之間維持狀態。

因此 Bazel 指令列有兩種選項:啟動和指令。在類似這樣的指令列中:

    bazel --host_jvm_args=-Xmx8G build -c opt //foo:bar

部分選項 (--host_jvm_args=) 位於要執行的指令名稱之前,部分則位於之後 (-c opt);前者稱為「啟動選項」,會影響整個伺服器程序,後者則稱為「指令選項」,只會影響單一指令。

每個伺服器執行個體都有一個相關聯的工作區 (稱為「存放區」的來源樹狀結構集合),且每個工作區通常都有一個作用中的伺服器執行個體。如要規避這項限制,可以指定自訂輸出基準 (詳情請參閱「目錄版面配置」一節)。

Bazel 會以單一 ELF 可執行檔的形式發布,同時也是有效的 .zip 檔案。 輸入 bazel 時,以 C++ 實作的上述 ELF 可執行檔 (「用戶端」) 會取得控制權。並透過下列步驟設定適當的伺服器程序:

  1. 檢查是否已自行解壓縮。如果沒有,系統會執行這項操作。這是伺服器的實作來源。
  2. 檢查是否有運作中的有效伺服器執行個體:正在執行、具有正確的啟動選項,並使用正確的工作區目錄。系統會查看目錄 $OUTPUT_BASE/server,找出正在執行的伺服器, 該目錄中會有鎖定檔案,內含伺服器接聽的通訊埠。
  3. 視需要終止舊的伺服器程序
  4. 視需要啟動新的伺服器程序

準備好適當的伺服器程序後,系統會透過 gRPC 介面將需要執行的指令傳達給該程序,然後將 Bazel 的輸出內容透過管道傳回終端機。一次只能執行一項指令。這是透過精密的鎖定機制實作,部分以 C++ 撰寫,部分以 Java 撰寫。我們提供一些基礎架構,可並行執行多個指令,因為無法與其他指令並行執行 bazel version 有點令人難為情。主要阻礙是 BlazeModule 的生命週期,以及 BlazeRuntime 中的某些狀態。

指令結束時,Bazel 伺服器會傳輸用戶端應傳回的結束代碼。有趣的是,bazel run 的實作方式是:這項指令的工作是執行 Bazel 剛建構的項目,但由於沒有終端機,因此無法從伺服器程序執行這項工作。因此,它會改為告知用戶端應執行哪個二進位檔,以及應使用哪些引數。exec()

按下 Ctrl-C 時,用戶端會將其轉換為 gRPC 連線上的 Cancel 呼叫,並盡快終止指令。第三次按下 Ctrl-C 後,用戶端會改為傳送 SIGKILL 給伺服器。

用戶端的原始碼位於 src/main/cpp,與伺服器通訊時使用的通訊協定位於 src/main/protobuf/command_server.proto

伺服器的主要進入點是 BlazeRuntime.main(),而用戶端的 gRPC 呼叫則由 GrpcServerImpl.run() 處理。

目錄版面配置

Bazel 會在建構期間建立一組相當複雜的目錄。如需完整說明,請參閱「輸出目錄版面配置」。

「主要存放區」是 Bazel 執行的來源樹狀結構。這通常對應至您從原始碼控管系統簽出的項目。這個目錄的根目錄稱為「工作區根目錄」。

Bazel 會將所有資料放在「輸出使用者根目錄」下。這通常是 $HOME/.cache/bazel/_bazel_${USER},但可以使用 --output_user_root 啟動選項覆寫。

「安裝基礎」是 Bazel 解壓縮的位置。這項作業會自動完成,且每個 Bazel 版本都會在安裝基底下方,根據其總和檢查碼取得子目錄。預設為 $OUTPUT_USER_ROOT/install,可使用 --install_base 指令列選項變更。

「輸出基本路徑」是 Bazel 執行個體附加至特定工作區時的寫入位置。每個輸出基礎在任何時間最多只能執行一個 Bazel 伺服器執行個體。通常位於 $OUTPUT_USER_ROOT/<checksum of the path to the workspace>。您可以使用 --output_base 啟動選項變更這個值,這項功能有助於解決限制,也就是在任何工作區中,一次只能執行一個 Bazel 執行個體。

輸出目錄包含下列項目:

  • $OUTPUT_BASE/external 擷取的外部存放區。
  • 執行根目錄,其中包含目前建構作業所有原始碼的符號連結。位於 $OUTPUT_BASE/execroot。建構期間的工作目錄為 $EXECROOT/<name of main repository>。我們計畫將這項功能改為 $EXECROOT,但這項變更會造成不相容問題,因此是長期計畫。
  • 在建構期間建立的檔案。

執行指令的程序

Bazel 伺服器取得控制權並得知需要執行的指令後,會依序發生下列事件:

  1. BlazeCommandDispatcher 會收到新要求的通知。判斷指令是否需要工作區才能執行 (幾乎所有指令都需要,與原始碼無關的指令除外,例如版本或說明),以及是否有其他指令正在執行。

  2. 找到正確的指令。每個指令都必須實作 BlazeCommand 介面,且必須有 @Command 註解 (這有點像是反模式,如果指令所需的所有中繼資料都由 BlazeCommand 的方法說明,那就太好了)

  3. 系統會剖析指令列選項。每個指令都有不同的指令列選項,詳情請參閱 @Command 註解。

  4. 系統會建立事件匯流排。事件匯流排是建構期間發生的事件串流。其中部分會匯出至 Bazel 外部,並受建構事件通訊協定保護,向外界說明建構作業的進度。

  5. 指令會取得控制權。最有趣的指令是執行建構的指令,例如建構、測試、執行、涵蓋範圍等:這項功能是由 BuildTool 實作。

  6. 系統會剖析指令列上的目標模式集,並解析 //pkg:all//pkg/... 等萬用字元。這項功能是在 AnalysisPhaseRunner.evaluateTargetPatterns() 中實作,並在 Skyframe 中具體化為 TargetPatternPhaseValue

  7. 系統會執行載入/分析階段,產生動作圖表 (建構作業需要執行的指令的有向非循環圖)。

  8. 執行階段會執行。也就是說,系統會執行建構所要求頂層目標所需的所有動作。

指令列選項

Bazel 叫用的指令列選項會以 OptionsParsingResult 物件說明,該物件則包含從「選項類別」到選項值的對應。「選項類別」是 OptionsBase 的子類別,可將相關的指令列選項歸為一組。例如:

  1. 與程式設計語言 (CppOptionsJavaOptions) 相關的選項。這些選項應為 FragmentOptions 的子類別,最終會包裝成 BuildOptions 物件。
  2. 與 Bazel 執行動作的方式相關的選項 (ExecutionOptions)

這些選項的設計用途是在分析階段使用 (透過 Java 中的 RuleContext.getFragment() 或 Starlark 中的 ctx.fragments)。其中有些 (例如是否要進行 C++ 掃描) 會在執行階段讀取,但這一律需要明確的管道,因為當時無法使用 BuildConfiguration。詳情請參閱「設定」一節。

警告:我們喜歡假設 OptionsBase 執行個體是不可變動的,並以這種方式使用這些執行個體 (例如做為 SkyKeys 的一部分)。但事實並非如此,修改這些執行個體會以難以偵錯的細微方式破壞 Bazel。但很遺憾,要讓這些物件真正不可變動,需要付出大量心力。(在其他人有機會保留對 FragmentOptions 的參照之前,以及在 equals()hashCode() 呼叫之前,在建構後立即修改 FragmentOptions 是可以的)。

Bazel 會透過下列方式瞭解選項類別:

  1. 有些是硬體連線至 Bazel (CommonCommandOptions)
  2. 每個 Bazel 指令的 @Command 註解
  3. ConfiguredRuleClassProvider (這些是與個別程式設計語言相關的指令列選項)
  4. Starlark 規則也可以定義自己的選項 (請參閱這裡)

每個選項 (Starlark 定義的選項除外) 都是具有 @Option 註解的 FragmentOptions 子類別成員變數,可指定指令列選項的名稱和類型,以及一些說明文字。

指令列選項值的 Java 型別通常是簡單的項目 (字串、整數、布林值、標籤等)。不過,我們也支援更複雜的類型選項;在這種情況下,從指令列字串轉換為資料類型的工作,會交由 com.google.devtools.common.options.Converter 的實作項目處理。

Bazel 看到的來源樹狀結構

Bazel 的業務是建構軟體,這需要讀取及解讀原始碼。Bazel 運作的原始碼總體稱為「工作區」,並分為存放區、套件和規則。

存放區

「存放區」是開發人員使用的來源樹狀結構,通常代表單一專案。Bazel 的前身 Blaze 是在單一存放區上運作,也就是包含用於執行建構作業的所有原始碼的單一來源樹狀結構。相較之下,Bazel 支援原始碼跨越多個存放區的專案。叫用 Bazel 的存放區稱為「主要存放區」,其他存放區則稱為「外部存放區」。

存放區的根目錄中會以存放區界線檔案 (MODULE.bazelREPO.bazel,或舊版環境中的 WORKSPACEWORKSPACE.bazel) 標示。主要存放區是您從中叫用 Bazel 的來源樹狀結構。外部存放區的定義方式有很多種,詳情請參閱外部依附元件總覽

外部存放區的程式碼會以符號連結或下載至 $OUTPUT_BASE/external

執行建構作業時,需要將整個來源樹狀結構拼湊在一起;這項作業是由 SymlinkForest 完成,它會將主要存放區中的每個套件符號連結至 $EXECROOT,並將每個外部存放區符號連結至 $EXECROOT/external$EXECROOT/..

套件

每個存放區都由套件組成,也就是相關檔案的集合和依附元件的規格。這些項目是由名為 BUILDBUILD.bazel 的檔案指定。如果兩者都存在,Bazel 會優先使用 BUILD.bazel;之所以仍接受 BUILD 檔案,是因為 Bazel 的祖先 Blaze 使用這個檔案名稱。不過,這其實是常用的路徑區隔,尤其是在不區分檔案名稱大小寫的 Windows 上。

套件彼此獨立:變更套件的 BUILD 檔案不會導致其他套件變更。新增或移除 BUILD 檔案可能會變更其他套件,因為遞迴 glob 會在套件界線停止,因此 BUILD 檔案的存在會停止遞迴。

BUILD 檔案的評估稱為「套件載入」。這項功能是在 PackageFactory 類別中實作,透過呼叫 Starlark 解譯器運作,且需要瞭解可用的規則類別集。封裝載入作業的結果是 Package 物件。這主要是從字串 (目標名稱) 到目標本身的對應。

封裝載入期間的大部分複雜性都是 globbing:Bazel 不需要明確列出每個來源檔案,而是可以執行 glob (例如 glob(["**/*.java"]))。與殼層不同的是,Bazel 支援遞迴 glob,可遞迴至子目錄 (但不會遞迴至子封裝)。這需要存取檔案系統,但由於存取速度可能較慢,我們實作了各種技巧,盡可能以平行方式有效率地執行這項作業。

下列類別會實作 Globbing:

  • LegacyGlobber,這個快速的 globber 完全不知道 Skyframe 的存在
  • SkyframeHybridGlobber,這個版本使用 Skyframe,並還原為舊版 globber,以避免「Skyframe 重新啟動」(如下所述)

Package 類別本身包含一些專門用於剖析「外部」套件 (與外部依附元件相關) 的成員,這些成員對實際套件沒有意義。這是設計上的瑕疵,因為描述一般套件的物件不應包含描述其他內容的欄位。包括:

  • 存放區對應
  • 已註冊的工具鍊
  • 已註冊的執行平台

理想情況下,剖析「外部」套件與剖析一般套件之間應有更多區隔,這樣 Package 就不必同時滿足兩者的需求。很抱歉,這項作業難度較高,因為兩者之間有很深的關聯。

標籤、目標和規則

套件由目標組成,目標類型如下:

  1. 檔案:建構的輸入或輸出內容。在 Bazel 術語中,我們稱這些項目為「構件」 (詳見其他章節)。並非所有在建構期間建立的檔案都是目標;Bazel 的輸出內容通常不會有相關聯的標籤。
  2. 規則:說明如何從輸入內容衍生輸出內容。這些通常與程式設計語言 (例如 cc_libraryjava_librarypy_library) 相關聯,但也有一些與語言無關 (例如 genrulefilegroup)。
  3. 套件群組:請參閱「顯示設定」一節。

目標的名稱稱為「標籤」。標籤的語法為 @repo//pac/kage:name,其中 repo 是標籤所在存放區的名稱,pac/kageBUILD 檔案所在的目錄,而 name 是檔案的路徑 (如果標籤參照來源檔案),相對於套件的目錄。在指令列上參照目標時,可以省略標籤的部分內容:

  1. 如果省略存放區,標籤會視為位於主要存放區。
  2. 如果省略套件部分 (例如 name:name),系統會將標籤視為位於目前工作目錄的套件中 (不允許包含上層參照的相對路徑 (..))

規則種類 (例如「C++ 程式庫」) 稱為「規則類別」。規則類別可以透過 Starlark (rule() 函式) 或 Java (所謂的「原生規則」,類型為 RuleClass) 實作。從長遠來看,所有語言專屬規則都會以 Starlark 實作,但部分舊版規則系列 (例如 Java 或 C++) 目前仍以 Java 實作。

Starlark 規則類別必須使用 load() 陳述式,在 BUILD 檔案開頭匯入,而 Java 規則類別則會透過註冊 ConfiguredRuleClassProvider,由 Bazel「天生」得知。

規則類別包含以下資訊:

  1. 屬性 (例如 srcsdeps):類型、預設值、限制等。
  2. 附加至各屬性的設定轉換和層面 (如有)
  3. 規則的實作方式
  4. 規則「通常」會建立的遞移資訊提供者

術語注意事項:在程式碼集,我們經常使用「規則」表示規則類別建立的目標。但在 Starlark 和使用者適用的文件中,「規則」應專指規則類別本身;目標只是「目標」。另請注意,儘管 RuleClass 的名稱含有「class」,規則類別與該類型的目標之間並沒有 Java 繼承關係。

Skyframe

Bazel 的基礎評估架構稱為 Skyframe。其模型是將建構期間需要建構的所有項目,整理成有向非循環圖,邊緣會從任何資料片段指向其依附元件,也就是建構該資料片段時需要知道的其他資料片段。

圖表中的節點稱為「節點」,節點名稱則稱為「節點名稱」。SkyValueSkyKey兩者都具有深度不可變動性,只有不可變動的物件才能從中存取。這項不變量幾乎一律成立,如果不是 (例如個別選項類別 BuildOptions,這是 BuildConfigurationValue 和其 SkyKey 的成員),我們會盡量不變更這些選項,或只以從外部無法觀察到的方式變更。因此,在 Skyframe 中計算的所有內容 (例如設定的目標) 也必須是不可變動的。

如要觀察 Skyframe 圖表,最方便的方法是執行 bazel dump --skyframe=deps,這會傾印圖表,每行一個 SkyValue。建議您只針對小型建構作業執行這項操作,因為這可能會佔用大量空間。

Skyframe 位於 com.google.devtools.build.skyframe 套件中。名稱相似的套件 com.google.devtools.build.lib.skyframe 包含 Bazel 在 Skyframe 上的實作項目。如要進一步瞭解 Skyframe,請參閱這篇文章

如要將指定 SkyKey 評估為 SkyValue,Skyframe 會叫用與金鑰類型對應的 SkyFunction。在函式評估期間,函式可能會呼叫 SkyFunction.Environment.getValue() 的各種多載,向 Skyframe 要求其他依附元件。這會將這些依附元件註冊到 Skyframe 的內部圖表中,因此當任何依附元件變更時,Skyframe 就會知道要重新評估函式。換句話說,Skyframe 的快取和增量運算作業會以 SkyFunctionSkyValue 的精細度運作。

每當 SkyFunction 要求無法使用的依附元件時,getValue() 就會傳回空值。接著,函式應自行傳回空值,將控制權交還給 Skyframe。稍後,Skyframe 會評估無法使用的依附元件,然後從頭重新啟動函式,但這次 getValue() 呼叫會成功,並傳回非空值結果。

因此,重新啟動前在 SkyFunction 內執行的任何運算都必須重複。但這不包括評估依附元件 SkyValues 所做的作業,這些作業會快取。因此,我們通常會採取下列方法解決這個問題:

  1. 分批宣告依附元件 (使用 getValuesAndExceptions()),以限制重新啟動次數。
  2. SkyValue 分成由不同 SkyFunction 計算的獨立片段,以便獨立計算及快取。這項作業應有策略地進行,因為可能會增加記憶體用量。
  3. 在重新啟動之間儲存狀態,方法是使用 SkyFunction.Environment.getState(),或「在 Skyframe 後方」保留臨時靜態快取。如果 SkyFunction 較為複雜,重新啟動時的狀態管理可能會變得棘手,因此我們導入了 StateMachines,以結構化方式處理邏輯並行作業,包括暫停和恢復 SkyFunction 內階層式運算的掛鉤。範例: DependencyResolver#computeDependencies 使用 StateMachinegetState() 計算已設定目標的潛在龐大直接依附元件集,否則可能會導致成本高昂的重新啟動。

從根本上來說,Bazel 需要這些類型的解決方法,因為數十萬個進行中的 Skyframe 節點很常見,而截至 2023 年,Java 對輕量執行緒的支援「並未優於」StateMachine 實作。

Starlark

Starlark 是網域專屬語言,可用來設定及擴充 Bazel。它被視為 Python 的受限子集,類型少得多,對控制流程的限制也更多,最重要的是,它能保證強大的不可變動性,以啟用並行讀取。這並非圖靈完備,因此部分 (但不是全部) 使用者不會嘗試在該語言中完成一般程式設計工作。

Starlark 是在 net.starlark.java 套件中實作。此外,這裡也提供獨立的 Go 實作項目:Bazel 目前使用的 Java 實作項目是解譯器。

Starlark 可用於多種情境,包括:

  1. BUILD 檔案。您可以在這裡定義新的建構目標。在此環境中執行的 Starlark 程式碼只能存取 BUILD 檔案本身的內容,以及該檔案載入的 .bzl 檔案。
  2. MODULE.bazel檔案。您可以在這裡定義外部依附元件。在此環境中執行的 Starlark 程式碼只能存取少數預先定義的指令。
  3. .bzl 檔案。在這裡定義新的建構規則、存放區規則和模組擴充功能。這裡的 Starlark 程式碼可以定義新函式,並從其他 .bzl 檔案載入。

BUILD.bzl 檔案可用的方言略有不同,因為兩者表達的內容不同。如要查看兩者的差異,請按這裡

如要進一步瞭解 Starlark,請參閱這裡

載入/分析階段

在載入/分析階段,Bazel 會判斷建構特定規則所需的動作。基本單位是「已設定的目標」,也就是 (目標、設定) 配對。

這個階段稱為「載入/分析階段」,因為可以分成兩個不同的部分,這兩個部分過去會序列化,但現在可以重疊:

  1. 載入套件,也就是將 BUILD 檔案轉換為代表這些檔案的 Package 物件
  2. 分析已設定的目標,也就是執行規則的實作項目,以產生動作圖

指令列上所要求已設定目標的遞移閉包中,每個已設定的目標都必須由下而上分析,也就是先分析葉節點,再分析指令列上的目標。單一已設定目標的分析輸入內容如下:

  1. 設定。(如何建構該規則;例如目標平台,以及使用者想傳遞至 C++ 編譯器的指令列選項等項目)
  2. 直接依附元件。這些資訊提供者可供分析的規則使用。之所以稱為「匯總」,是因為這類目標會提供所設定目標遞移閉包中的資訊「匯總」,例如類別路徑上的所有 .jar 檔案,或是需要連結至 C++ 二進位檔的所有 .o 檔案。
  3. 目標本身。這是載入目標所在套件的結果。如果是規則,則包括其屬性,這通常是重點所在。
  4. 已設定目標的實作方式。規則可以採用 Starlark 或 Java。所有非規則設定的目標都會以 Java 實作。

分析已設定目標的輸出內容如下:

  1. 設定依附目標的遞移資訊提供者可以存取
  2. 可建立的構件,以及產生這些構件的動作。

提供給 Java 規則的 API 是 RuleContext,相當於 Starlark 規則的 ctx 引數。這個 API 的功能更強大,但同時也更容易做出「壞事」™,例如編寫時間或空間複雜度為二次方 (或更糟) 的程式碼、導致 Bazel 伺服器因 Java 例外狀況而當機,或是違反不變量 (例如不慎修改 Options 執行個體,或使已設定的目標可變動)

決定已設定目標直接依附元件的演算法位於 DependencyResolver.dependentNodeMap() 中。

設定

設定是建構目標的「方式」:適用於哪個平台、使用哪些指令列選項等。

在同一個建構作業中,可以為多個設定建構相同目標。舉例來說,如果工具和目標程式碼使用相同程式碼,且我們正在進行交叉編譯,或是建構胖 Android 應用程式 (內含多個 CPU 架構的原生程式碼),這就很有用。

從概念上來說,設定是 BuildOptions 執行個體。不過,在實務上,BuildOptions 會由 BuildConfiguration 包裝,提供額外的各種功能。它會從依附元件圖的頂端傳播至底部。如果變更,則需要重新分析建構作業。

舉例來說,如果要求的測試執行次數有所變更 (即使只影響測試目標),就必須重新分析整個建構作業,這會導致異常情況 (我們計畫「修剪」設定,避免發生這種情況,但目前尚未準備就緒)。

如果規則實作需要部分設定,就必須使用 RuleClass.Builder.requiresConfigurationFragments() 在定義中宣告。這麼做是為了避免錯誤 (例如 Python 規則使用 Java 片段),以及方便修剪設定,這樣一來,如果 Python 選項變更,就不需要重新分析 C++ 目標。

規則的設定不一定與「父項」規則相同。在依附元件邊緣變更設定的程序稱為「設定轉換」。這可能發生在兩個地方:

  1. 在依附元件邊緣。這些轉換是在 Attribute.Builder.cfg() 中指定,且是從 Rule (轉換發生的位置) 和 BuildOptions (原始設定) 到一或多個 BuildOptions (輸出設定) 的函式。
  2. 在任何連入已設定目標的邊緣。這些項目會在 RuleClass.Builder.cfg() 中指定。

相關類別為 TransitionFactoryConfigurationTransition

設定轉換的用途包括:

  1. 宣告特定依附元件是在建構期間使用,因此應建構於執行架構中
  2. 如要聲明特定依附元件必須為多種架構建構 (例如胖 Android APK 中的原生程式碼)

如果設定轉換產生多個設定,則稱為「分割轉換」。

您也可以在 Starlark 中實作設定轉換 (說明文件請參閱這裡)

轉移資訊供應商

設定的目標可透過遞移資訊提供者,瞭解所依附的其他設定目標,也能將自身資訊提供給依附的設定目標。名稱中包含「transitive」的原因是,這通常是所設定目標的遞移閉包匯總。

一般來說,Java 遞移資訊提供者和 Starlark 資訊提供者之間會有一對一的對應關係 (例外狀況是 DefaultInfo,因為該 API 比直接轉譯 Java API 更像 Starlark,因此是 FileProviderFilesToRunProviderRunfilesProvider 的合併)。其鍵值為下列其中一項:

  1. Java 類別物件。這項功能僅適用於無法透過 Starlark 存取的供應器。這些供應商是 TransitiveInfoProvider 的子類別。
  2. 字串。這是舊版做法,且容易發生名稱衝突,因此強烈建議不要使用。這類遞移資訊供應商是 build.lib.packages.Info 的直接子類別。
  3. 供應商符號。這可使用 provider() 函式從 Starlark 建立,也是建立新供應商的建議方式。在 Java 中,符號由 Provider.Key 執行個體表示。

以 Java 實作的新供應商應使用 BuiltinProvider 實作。NativeProvider 已淘汰 (我們還沒時間移除),且無法從 Starlark 存取 TransitiveInfoProvider 子類別。

已設定的目標

設定的目標會以 RuleConfiguredTargetFactory 實作。針對以 Java 實作的每個規則類別,都有一個子類別。系統會透過 StarlarkRuleConfiguredTargetUtil.buildRule() 建立 Starlark 設定的目標。

設定的目標工廠應使用 RuleConfiguredTargetBuilder 建構傳回值。當中包含下列項目:

  1. 他們的 filesToBuild,也就是「這項規則代表的一組檔案」這個模糊概念。這些檔案會在指令列或 genrule 的 srcs 中設定目標時建構。
  2. 執行檔、一般檔案和資料。
  3. 輸出群組。這些是規則可建構的各種「其他檔案集」。您可以在 BUILD 中使用 filegroup 規則的 output_group 屬性存取這些檔案,也可以在 Java 中使用 OutputGroupInfo 供應器存取。

Runfiles

部分二進位檔需要資料檔案才能執行。最明顯的例子是需要輸入檔案的測試。在 Bazel 中,這項概念以「執行檔」表示。「執行檔樹狀結構」是特定二進位檔的資料檔案目錄樹狀結構。這個樹狀結構會在檔案系統中建立,並以個別符號連結指向來源或輸出樹狀結構中的檔案。

一組執行檔會以 Runfiles 執行個體表示。從概念上來說,這是從執行檔案樹狀目錄中檔案的路徑到代表該檔案的 Artifact 例項的對應。這比單一 Map 複雜一點,原因有二:

  • 在多數情況下,檔案的執行檔路徑與其 execpath 相同。 我們使用這項功能來節省部分 RAM。
  • 執行檔樹狀結構中有多種舊版項目,這些項目也需要表示。

系統會使用 RunfilesProvider 收集 Runfile:這個類別的執行個體代表已設定目標 (例如程式庫) 及其遞移封閉需要使用的 Runfile,並會以巢狀集合的形式收集 (事實上,這些 Runfile 是使用巢狀集合實作)。每個目標會合併其依附元件的 Runfile,加入部分自己的 Runfile,然後在依附元件圖表中向上傳送產生的集合。RunfilesProvider 例項包含兩個 Runfiles 例項,一個用於透過「data」屬性依附規則時,另一個則用於所有其他類型的傳入依附元件。這是因為透過資料屬性依附目標時,目標有時會呈現不同的執行檔。這是我們尚未移除的舊版行為,並非預期。

二進位檔的 Runfile 會以 RunfilesSupport 的執行個體表示。這與 Runfiles 不同,因為 RunfilesSupport 具有實際建構的能力 (不同於 Runfiles,後者只是對應)。因此需要下列額外元件:

  • 輸入執行檔資訊清單。這是執行檔樹狀結構的序列化說明。這項檔案會做為執行檔案樹狀目錄內容的 Proxy,且 Bazel 會假設執行檔案樹狀目錄只會在資訊清單內容變更時變更。
  • 輸出執行檔資訊清單。這項功能由處理 Runfile 樹狀結構的執行階段程式庫使用,特別是在有時不支援符號連結的 Windows 上。
  • 指令列引數,用於執行 RunfilesSupport 物件代表的執行檔。

切面

「將運算結果向下傳播至依附元件圖表」的方法是使用 Aspect。Bazel 使用者可參閱這篇文章瞭解相關資訊。以通訊協定緩衝區為例,proto_library 規則不應瞭解任何特定語言,但以任何程式設計語言建構通訊協定緩衝區訊息 (通訊協定緩衝區的「基本單元」) 的實作項目,應與 proto_library 規則耦合,這樣一來,如果同一語言中的兩個目標依附於相同通訊協定緩衝區,系統只會建構一次。

與設定的目標一樣,這些目標在 Skyframe 中會以 SkyValue 表示,且建構方式與設定的目標非常相似:這些目標具有名為 ConfiguredAspectFactory 的工廠類別,可存取 RuleContext,但與設定的目標工廠不同,這些目標也瞭解所附加的設定目標及其供應商。

使用 Attribute.Builder.aspects() 函式,為每個屬性指定要向下傳播至依附元件圖的層面集。有幾個名稱容易混淆的類別會參與這個程序:

  1. AspectClass 是該層面的實作方式。可以是 Java (這種情況下是子類別),也可以是 Starlark (這種情況下是 StarlarkAspectClass 的例項)。這與 RuleConfiguredTargetFactory 類似。
  2. AspectDefinition 是指層面的定義,包括所需的供應器、提供的供應器,以及對實作的參照 (例如適當的 AspectClass 例項)。這與 RuleClass 類似。
  3. AspectParameters 可用來將參數傳遞至依附元件關係圖。目前是字串對字串對應。通訊協定緩衝區就是一個很好的例子:如果某種語言有多個 API,則應將通訊協定緩衝區應建構的 API 相關資訊,向下傳播至依附元件圖表。
  4. Aspect 代表計算某個層面所需的所有資料,該層面會沿著依附關係圖向下傳播。當中包含層面類別、定義和參數。
  5. RuleAspect 函式會決定特定規則應傳播哪些層面。這是 Rule -> Aspect 函式。

有點出乎意料的是,構面可以附加至其他構面;舉例來說,收集 Java IDE 類別路徑的構面可能想瞭解類別路徑上的所有 .jar 檔案,但其中有些是通訊協定緩衝區。在這種情況下,IDE 層面會想要附加至 (proto_library 規則 + Java proto 層面) 配對。

類別 AspectCollection 會擷取層面上的層面複雜度。

平台和工具鍊

Bazel 支援多平台建構,也就是建構動作可能在多個架構中執行,且程式碼建構適用於多個架構。在 Bazel 術語中,這些架構稱為「平台」 (完整說明文件請參閱這裡)

平台是由鍵/值對應所描述,從限制設定 (例如「CPU 架構」的概念) 對應至限制值 (例如特定 CPU,如 x86_64)。我們在 @platforms 存放區中提供最常用的限制設定和值的「字典」。

「工具鍊」的概念來自於:視建構作業執行的平台和目標平台而定,您可能需要使用不同的編譯器;舉例來說,特定 C++ 工具鍊可能在特定 OS 上執行,並以其他 OS 為目標。Bazel 必須根據設定的執行和目標平台,判斷要使用的 C++ 編譯器 (工具鍊文件請參閱這裡)。

為此,工具鍊會註解其支援的執行和目標平台限制集。為此,工具鍊的定義分為兩部分:

  1. toolchain() 規則,說明工具鍊支援的一組執行和目標限制,並指出工具鍊的類型 (例如 C++ 或 Java),後者以 toolchain_type() 規則表示
  2. 說明實際工具鍊的語言專屬規則 (例如 cc_toolchain())

我們需要瞭解每個工具鍊的限制,才能執行工具鍊解析作業,而語言專屬*_toolchain()規則包含的資訊遠多於此,因此載入時間較長。

執行平台可透過下列任一方式指定:

  1. 在 MODULE.bazel 檔案中使用 register_execution_platforms() 函式
  2. 在指令列中使用 --extra_execution_platforms 指令列選項

系統會在 RegisteredExecutionPlatformsFunction 中計算可用的執行平台集。

已設定目標的目標平台取決於 PlatformOptions.computeTargetPlatform()。這是平台清單,因為我們最終想支援多個目標平台,但目前尚未實作。

系統會根據 ToolchainResolutionFunction,判斷要用於已設定目標的工具鍊組合。這項指標取決於:

  • 已註冊的工具鍊集 (位於 MODULE.bazel 檔案和設定中)
  • 所需的執行和目標平台 (在設定中)
  • 設定目標所需的工具鍊類型集 (在 UnloadedToolchainContextKey)
  • 已設定目標 (exec_compatible_with 屬性) 和設定 (--experimental_add_exec_constraints_to_targets) 的執行平台限制集,位於 UnloadedToolchainContextKey

結果是 UnloadedToolchainContext,本質上是從工具鍊類型 (以 ToolchainTypeInfo 執行個體表示) 到所選工具鍊標籤的對應。由於未包含工具鍊本身,只有工具鍊的標籤,因此稱為「未載入」。

然後,工具鍊會實際使用 ResolvedToolchainContext.load() 載入,並由要求工具鍊的已設定目標實作項目使用。

我們也有舊版系統,依賴單一「主機」設定,並以各種設定標記 (例如 --cpu ) 代表目標設定。我們正在逐步改用上述系統。為處理使用者依賴舊版設定值的情況,我們已實作平台對應,可在舊版標記和新版平台限制之間進行轉換。他們的程式碼位於 PlatformMappingFunction,並使用非 Starlark 的「小語言」。

限制

有時,您會希望將目標指定為僅與少數平台相容。很遺憾,Bazel 有多種機制可達成此目的:

  • 規則專屬限制
  • environment_group()/environment()
  • 平台限制

規則專屬限制大多用於 Google 的 Java 規則;這些限制即將淘汰,且不適用於 Bazel,但原始碼可能包含相關參照。控管這項行為的屬性稱為 constraints=

environment_group() 和 environment()

這些規則是舊版機制,目前不常使用。

所有建構規則都可以宣告可建構的「環境」,而「環境」是 environment() 規則的例項。

您可以透過多種方式為規則指定支援的環境:

  1. 透過 restricted_to= 屬性。這是最直接的規格形式,可宣告規則支援的確切環境集。
  2. 透過 compatible_with= 屬性。除了預設支援的「標準」環境外,這項功能還會宣告規則支援的環境。
  3. 透過套件層級屬性 default_restricted_to=default_compatible_with=
  4. 透過 environment_group() 規則中的預設規格。每個環境都屬於一組主題相關的同類環境 (例如「CPU 架構」、「JDK 版本」或「行動作業系統」)。環境群組的定義包括:如果 restricted_to= / environment() 屬性未另行指定,則「預設」應支援哪些環境。如果規則沒有這類屬性,就會沿用所有預設值。
  5. 透過規則類別預設值。這會覆寫指定規則類別所有例項的全域預設值。舉例來說,這項功能可用於讓所有 *_test 規則都可測試,不必讓每個執行個體明確宣告這項功能。

environment() 會實作為一般規則,而 environment_group() 既是 Target 的子類別,但不是 Rule (EnvironmentGroup),也是 Starlark (StarlarkLibrary.environmentGroup()) 預設提供的函式,最終會建立同名目標。這是為了避免循環依附元件,因為每個環境都需要宣告所屬的環境群組,而每個環境群組都需要宣告預設環境。

您可以使用 --target_environment 指令列選項,將建構作業限制在特定環境中。

限制檢查的實作項目位於 RuleContextConstraintSemanticsTopLevelConstraintSemantics

平台限制

目前「官方」描述目標相容平台的方式,是使用與描述工具鍊和平台相同的限制。這項功能已在提取要求 #10945 中實作。

顯示設定

如果您與許多開發人員共用大型程式碼集 (例如在 Google),請務必避免其他人任意依附您的程式碼。否則,根據 Hyrum 定律,使用者開始依賴您視為實作詳細資料的行為。

Bazel 支援這項功能,機制稱為「可見性」:您可以使用 visibility 屬性,限制哪些目標可以依附特定目標。這個屬性有點特別,因為雖然它會保留標籤清單,但這些標籤可能會編碼套件名稱的模式,而不是指向任何特定目標。(沒錯,這是設計上的瑕疵)。

這項功能在下列位置實作:

  • RuleVisibility 介面代表可見度宣告。可以是常數 (完全公開或完全私人),也可以是標籤清單。
  • 標籤可以指套件群組 (預先定義的套件清單)、直接指套件 (//pkg:__pkg__) 或套件子樹狀結構 (//pkg:__subpackages__)。這與使用 //pkg:*//pkg/... 的指令列語法不同。
  • 套件群組會以自己的目標 (PackageGroup) 實作,並設定目標 (PackageGroupConfiguredTarget)。如果需要,我們或許可以將這些目標換成簡單的規則。這些邏輯是透過下列項目實作:PackageSpecification,對應至單一模式,例如 //pkg/...PackageGroupContents,對應至單一 package_grouppackages 屬性;以及 PackageSpecificationProvider,會彙整 package_group 及其遞移 includes
  • 從可見度標籤清單轉換為依附元件的作業,是在 DependencyResolver.visitTargetVisibility 和其他幾個雜項位置完成。
  • 實際檢查是在 CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility()

巢狀集合

通常,已設定的目標會匯總其依附元件中的一組檔案、新增自己的檔案,並將匯總的檔案組包裝成轉換資訊提供者,以便依附於該目標的已設定目標也能執行相同作業。範例:

  • 建構作業使用的 C++ 標頭檔
  • 代表 cc_library 遞移閉包的物件檔案
  • Java 規則編譯或執行時,需要位於類別路徑上的一組 .jar 檔案
  • Python 規則遞移閉包中的 Python 檔案集

如果我們以簡單的方式執行這項操作 (例如使用 ListSet),最終會導致二次方記憶體用量:如果有一連串 N 條規則,且每條規則都會新增檔案,我們就會有 1+2+...+N 個集合成員。

為解決這個問題,我們提出了「NestedSet」的概念。這是一種資料結構,由其他 NestedSet 例項和部分自有成員組成,因此會形成有向非循環圖 (DAG) 的集合。這類集合無法變更,且可逐一查看成員。我們定義了多個疊代順序 (NestedSet.Order):前序、後序、拓撲 (節點一律位於祖先節點之後),以及「不在意,但每次都應相同」。

在 Starlark 中,相同的資料結構稱為 depset

構件和動作

實際建構作業包含一組需要執行的指令,用來產生使用者想要的輸出內容。指令會以 Action 類別的例項表示,檔案則以 Artifact 類別的例項表示。這些動作會排列在稱為「動作圖」的二分有向無環圖中。

構件分為兩種:來源構件 (在 Bazel 開始執行前可用) 和衍生構件 (需要建構)。衍生構件本身可以是多種形式:

  1. 一般構件。系統會計算這些檔案的總和檢查碼,並以 mtime 做為捷徑,檢查檔案是否為最新版本;如果檔案的 ctime 沒有變更,系統就不會計算總和檢查碼。
  2. 未解析的符號連結構件。系統會呼叫 readlink(),檢查這些項目是否為最新版本。與一般構件不同,這些項目可能是懸空符號連結。通常用於將某些檔案封裝到某種封存檔的情況。
  3. 樹狀構件。這些不是單一檔案,而是目錄樹。系統會檢查檔案集及其內容,確認檔案是否為最新版本。這些值會以 TreeArtifact 表示。
  4. 常數中繼資料構件。這些構件的變更不會觸發重建。這項資訊專門用於建構時間戳記資訊,我們不希望因為目前時間變更而重建。

來源構件無法成為樹狀結構構件或未解析的符號連結構件,並無基本原因,只是我們尚未實作 (不過我們應該實作,因為在 BUILD 檔案中參照來源目錄是 Bazel 少數已知的長期不正確問題之一;我們有一個可行的實作項目,可透過 BAZEL_TRACK_SOURCE_DIRECTORIES=1 JVM 屬性啟用)

動作最好理解為需要執行的指令、所需的環境,以及產生的輸出內容集。以下是動作說明的主要元件:

  • 需要執行的指令列
  • 所需的輸入構件
  • 需要設定的環境變數
  • 描述執行環境的註解 (例如平台)

還有一些其他特殊情況,例如寫入 Bazel 已知的內容檔案。這些是 AbstractAction 的子類別。大多數動作都是 SpawnActionStarlarkAction (兩者相同,不應是獨立類別),不過 Java 和 C++ 有自己的動作型別 (JavaCompileActionCppCompileActionCppLinkAction)。

我們最終希望將所有內容移至 SpawnActionJavaCompileAction 相當接近,但由於 .d 檔案剖析和掃描包含項目,C++ 屬於特殊情況。

動作圖大多「嵌入」Skyframe 圖中:從概念上來說,動作的執行作業會以 ActionExecutionFunction 的叫用表示。ActionExecutionFunction.getInputDeps()Artifact.key() 說明瞭從動作圖依附元件邊緣到 Skyframe 依附元件邊緣的對應,並進行了一些最佳化,以減少 Skyframe 邊緣的數量:

  • 衍生構件沒有自己的 SkyValue。而是用來找出產生該動作的金鑰Artifact.getGeneratingActionKey()
  • 巢狀集合有自己的 Skyframe 鍵。

共用動作

部分動作是由多個已設定的目標產生;Starlark 規則的限制較多,因為只能將衍生動作放入由設定和套件決定的目錄 (但即使如此,同一套件中的規則仍可能發生衝突),但以 Java 實作的規則可將衍生構件放在任何位置。

這項功能可視為錯誤,但由於在處理來源檔案時,可大幅節省執行時間,因此很難移除。舉例來說,如果來源檔案需要以某種方式處理,且該檔案由多個規則參照 (手勢),但這會耗用一些 RAM:每個共用動作的例項都必須個別儲存在記憶體中。

如果兩個動作產生相同的輸出檔案,則必須完全相同:具有相同的輸入內容、相同的輸出內容,並執行相同的指令列。這項等價關係是在 Actions.canBeShared() 中實作,並在分析和執行階段之間進行驗證,方法是查看每個動作。這項功能是在 SkyframeActionExecutor.findAndStoreArtifactConflicts() 中實作,也是 Bazel 中少數需要「全域」建構檢視畫面的位置。

執行階段

此時 Bazel 實際上會開始執行建構動作,例如產生輸出的指令。

分析階段結束後,Bazel 會先判斷需要建構哪些構件。這項邏輯會編碼在 TopLevelArtifactHelper 中;大致來說,這是指令列上設定目標的 filesToBuild,以及特殊輸出群組的內容,明確目的是表示「如果這個目標位於指令列上,請建構這些構件」。

下一步是建立執行根目錄。由於 Bazel 可選擇從檔案系統中的不同位置讀取來源套件 (--package_path),因此需要為在本機執行的動作提供完整的來源樹狀結構。這項作業由 SymlinkForest 類別處理,做法是記下分析階段使用的每個目標,並建立單一目錄樹,將每個含有使用目標的套件,從實際位置符號連結。替代做法是將正確路徑傳遞至指令 (請將 --package_path 納入考量)。這項做法不理想的原因如下:

  • 如果套件從一個套件路徑項目移至另一個項目 (過去很常見),這個工具就會變更動作指令列
  • 如果動作是遠端執行,產生的指令列會與在本機執行時不同
  • 需要使用特定工具的指令列轉換 (請考量 Java 類別路徑和 C++ 包含路徑等差異)
  • 變更動作的指令列會使動作快取項目失效
  • --package_path 正逐步淘汰

接著,Bazel 會開始遍歷動作圖 (由動作及其輸入和輸出構件組成的二分有向圖),並執行動作。每個動作的執行作業都以 SkyValue 類別 ActionExecutionValue 的執行個體表示。

由於執行動作的成本很高,我們在 Skyframe 後方設有幾層快取,可供存取:

  • ActionExecutionFunction.stateMap 包含的資料可讓 Skyframe 重新啟動 ActionExecutionFunction,且費用低廉
  • 本機動作快取包含檔案系統狀態的資料
  • 遠端執行系統通常也包含自己的快取

本地動作快取

這個快取是 Skyframe 後方的另一層;即使動作在 Skyframe 中重新執行,仍可能命中本機動作快取。這代表本機檔案系統的狀態,並序列化至磁碟,也就是說,即使 Skyframe 圖表為空白,啟動新的 Bazel 伺服器時,仍可取得本機動作快取命中。

系統會使用 ActionCacheChecker.getTokenIfNeedToExecute() 方法檢查這個快取是否有相符項目。

與名稱相反,這是從衍生構件路徑到發出構件的動作的對應。這個動作的說明如下:

  1. 輸入和輸出檔案集及其校驗碼
  2. 「動作金鑰」通常是執行的指令列,但一般來說,代表輸入檔案的總和檢查碼未擷取的任何內容 (例如 FileWriteAction 的總和檢查碼是寫入資料的總和檢查碼)

此外,我們還在開發「由上而下動作快取」這項高度實驗性的功能,這項功能會使用遞移雜湊,盡量避免存取快取。

輸入探索和輸入修剪

有些動作比輸入一組資料更複雜。動作輸入內容的變更分為兩種形式:

  • 動作可能會在執行前發現新的輸入內容,或判斷某些輸入內容實際上並非必要。標準範例是 C++,最好從 C++ 檔案的遞移閉包中,對 C++ 檔案使用的標頭檔案做出有根據的猜測,這樣我們就不必將每個檔案傳送至遠端執行器;因此,我們選擇不將每個標頭檔案註冊為「輸入」,而是掃描來源檔案中遞移包含的標頭,並只將 #include 陳述式中提及的標頭檔案標示為輸入 (我們會高估,這樣就不必實作完整的 C 前置處理器)。這個選項目前在 Bazel 中硬連線至「false」,且僅供 Google 使用。
  • 動作可能會發現執行期間未使用某些檔案。在 C++ 中,這稱為「.d 檔案」:編譯器會告知事後使用的標頭檔,為了避免增量性比 Make 更差的尷尬情況,Bazel 會利用這項事實。這項工具會使用編譯器,因此估算結果比 include 掃描器更準確。

這些是使用 Action 的方法實作:

  1. 系統會呼叫 Action.discoverInputs()。這個方法應傳回一組巢狀結構的構件,這些構件會判定為必要構件。這些必須是來源構件,這樣動作圖表中就不會有任何依附元件邊緣,在設定的目標圖表中沒有對應項目。
  2. 呼叫 Action.execute() 即可執行動作。
  3. Action.execute() 結尾,動作可以呼叫 Action.updateInputs(),告知 Bazel 並非所有輸入內容都是必要項目。如果使用的輸入內容回報為未使用,可能會導致增量建構不正確。

當動作快取在新的動作執行個體 (例如在伺服器重新啟動後建立) 上傳回命中時,Bazel 會自行呼叫 updateInputs(),以便輸入內容組合反映先前完成的輸入內容探索和修剪結果。

Starlark 動作可使用這項功能,透過 ctx.actions.run()unused_inputs_list= 引數,將部分輸入內容宣告為未使用。

執行動作的各種方式:策略/ActionContexts

部分動作可以不同方式執行。舉例來說,指令列可以在本機執行,也可以在本機的各種沙箱中執行,或是在遠端執行。體現這個概念的項目稱為「ActionContext」(或 Strategy,因為我們只成功完成一半的重新命名作業...)

動作背景資訊的生命週期如下:

  1. 執行階段開始時,系統會詢問 BlazeModule 執行個體有哪些動作背景資訊。這項作業會在 ExecutionTool 的建構函式中執行。動作情境類型是由 Java Class 執行個體所識別,該執行個體是指 ActionContext 的子介面,以及動作情境必須實作的介面。
  2. 系統會從可用動作內容中選取適當的動作內容,並轉送至 ActionExecutionContextBlazeExecutor
  3. 動作會使用 ActionExecutionContext.getContext()BlazeExecutor.getStrategy() 要求背景資訊 (其實應該只有一種做法...)

策略可免費呼叫其他策略來執行工作;舉例來說,動態策略會同時在本機和遠端啟動動作,然後使用先完成的動作。

其中一項值得注意的策略是實作永續性工作站程序 (WorkerSpawnStrategy)。這個概念是說,有些工具的啟動時間很長,因此應該在動作之間重複使用,而不是為每個動作重新啟動 (這代表可能會有正確性問題,因為 Bazel 依賴工作站程序的承諾,也就是不會在個別要求之間攜帶可觀察的狀態)。

如果工具有所變更,工作程序就必須重新啟動。系統會使用 WorkerFilesHash 計算所用工具的總和檢查碼,判斷工作人員是否可重複使用。這項功能會根據動作的哪些輸入內容代表工具的一部分,以及哪些代表輸入內容來判斷。這由動作建立者決定:Spawn.getToolFiles()Spawn 的執行檔會計為工具的一部分。

進一步瞭解策略 (或動作環境!):

  • 如要瞭解執行動作的各種策略,請參閱這篇文章
  • 如要瞭解動態策略 (即在本機和遠端執行動作,以先完成者為準),請參閱這篇文章
  • 如要瞭解在本地執行動作的複雜細節,請參閱這篇文章

本機資源管理工具

Bazel 可以平行執行許多動作。應平行執行的本機動作數量因動作而異:動作需要的資源越多,同時執行的執行個體就越少,以免本機過載。

這是在 ResourceManager 類別中實作:每個動作都必須以 ResourceSet 執行個體 (CPU 和 RAM) 形式,註解其所需的本機資源估算值。然後,當動作內容執行需要本機資源的操作時,就會呼叫 ResourceManager.acquireResources(),並遭到封鎖,直到所需資源可用為止。

如要進一步瞭解本機資源管理,請參閱這篇文章

輸出目錄的結構

每個動作都需要輸出目錄中的獨立位置,以便放置輸出內容。衍生構件的位置通常如下所示:

$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>

與特定設定相關聯的目錄名稱是如何決定的?有兩項相互衝突的理想屬性:

  1. 如果兩個設定可能出現在同一個建構中,就應該有不同的目錄,這樣兩者才能各自擁有相同動作的版本;否則,如果兩個設定對動作的指令列 (例如產生相同輸出檔案) 有不同意見,Bazel 就不知道該選擇哪個動作 (「動作衝突」)
  2. 如果兩個設定「大致上」代表相同的事物,則應具有相同名稱,這樣一項設定中執行的動作就能重複用於另一項設定 (如果指令列相符的話)。舉例來說,變更 Java 編譯器的指令列選項不應導致 C++ 編譯動作重新執行。

目前我們尚未找到解決這個問題的原則性方法,這與設定修剪問題類似。如需更詳細的選項說明,請參閱這個頁面。主要問題區域是 Starlark 規則 (作者通常不熟悉 Bazel) 和層面,這些層面會為可產生「相同」輸出檔案的事物空間新增另一個維度。

目前的方法是,設定的路徑區隔為 <CPU>-<compilation mode>,並加上各種後置字元,這樣以 Java 實作的設定轉換就不會導致動作衝突。此外,系統還會新增一組 Starlark 設定轉換的總和檢查碼,避免使用者造成動作衝突。但仍有許多不足之處。這項功能是在 OutputDirectories.buildMnemonic() 中實作,並依賴每個設定片段將自己的部分新增至輸出目錄的名稱。

測試命名空間

Bazel 支援多種測試執行方式。支援:

  • 從遠端執行測試 (如有遠端執行後端)
  • 並行執行多次測試 (用於修正不穩定問題或收集時間資料)
  • 分割測試 (將同一項測試中的測試案例分割至多個程序,以加快速度)
  • 重新執行不穩定測試
  • 將測試分組到測試套件中

測試是已設定的目標,具有 TestProvider,可說明測試的執行方式:

  • 建構結果會導致測試執行的構件。這是「快取狀態」檔案,內含序列化的 TestResultData 訊息
  • 測試應執行的次數
  • 測試應分割成的分片數量
  • 測試執行方式的相關參數 (例如測試逾時)

判斷要執行的測試

判斷要執行的測試是複雜的程序。

首先,在剖析目標模式期間,測試套件會以遞迴方式展開。擴充功能是在 TestsForTargetPatternFunction 中實作。有點令人意外的是,如果測試套件未宣告任何測試,則是指套件中的所有測試。這是透過在測試套件規則中新增名為 $implicit_tests 的隱含屬性來實作。Package.beforeBuild()

然後,系統會根據指令列選項,依大小、標記、逾時和語言篩選測試。這是在 TestFilter 中實作,並在目標剖析期間從 TargetPatternPhaseFunction.determineTests() 呼叫,結果會放入 TargetPatternPhaseValue.getTestsToRunLabels()。可供篩選的規則屬性無法設定,是因為這發生在分析階段之前,因此無法設定。

接著在 BuildView.createResult() 中進一步處理:篩除分析失敗的目標,並將測試分為專屬和非專屬測試。然後放入 AnalysisResultExecutionTool 就能知道要執行哪些測試。

為了讓這個複雜的程序更加透明,我們提供tests() 查詢運算子 (在 TestsFunction 中實作),可指出在指令列上指定特定目標時執行的測試。很遺憾,這是重新實作的項目,因此可能在多個細微方面與上述內容有所出入。

執行測試

測試的執行方式是要求快取狀態構件。這會導致 TestRunnerAction 的執行,最終呼叫 --test_strategy 指令列選項所選擇的 TestActionContext,以要求的方式執行測試。

測試會根據詳細的通訊協定執行,並使用環境變數告知測試預期結果。如要詳細瞭解 Bazel 對測試的期望,以及測試對 Bazel 的期望,請參閱這篇文章。最簡單的說法是,結束代碼為 0 代表成功,其他任何值則代表失敗。

除了快取狀態檔案,每個測試程序還會發出許多其他檔案。這些檔案會放在「測試記錄目錄」中,也就是目標設定輸出目錄的 testlogs 子目錄:

  • test.xml:JUnit 樣式的 XML 檔案,詳細說明測試分片中的個別測試案例
  • test.log:測試的控制台輸出內容。stdout 和 stderr 不會分開。
  • test.outputs,即「未宣告的輸出目錄」;測試會使用這個目錄,除了在終端機中列印的內容外,還會輸出檔案。

測試執行期間可能會發生兩種情況,但建構一般目標時不會發生:專屬測試執行和輸出串流。

部分測試必須以獨占模式執行,例如不得與其他測試平行執行。方法是在測試規則中加入 tags=["exclusive"],或使用 --test_strategy=exclusive 執行測試。每個專屬測試都會由個別的 Skyframe 叫用執行,要求在「主要」建構作業完成後執行測試。這項功能是在 SkyframeExecutor.runExclusiveTest() 中實作。

與一般動作不同,一般動作會在完成時傾印終端機輸出內容,但使用者可以要求串流測試輸出內容,以便瞭解長時間執行的測試進度。這是由 --test_output=streamed 指令列選項指定,並表示專屬測試執行,因此不同測試的輸出內容不會交錯。

這項功能是在名為 StreamedTestOutput 的類別中實作,做法是輪詢相關測試的 test.log 檔案變更,並將新位元組傾印至 Bazel 規則所在的終端機。

如要查看執行測試的結果,請觀察事件匯流排上的各種事件 (例如 TestAttemptTestResultTestingCompleteEvent)。這些結果會傾印至建構事件通訊協定,並由 AggregatingTestListener 發送至控制台。

涵蓋範圍集合

涵蓋範圍會以 LCOV 格式,透過檔案中的測試回報。 bazel-testlogs/$PACKAGE/$TARGET/coverage.dat

如要收集涵蓋範圍,每個測試執行作業都會包裝在名為 collect_coverage.sh 的指令碼中。

這個指令碼會設定測試環境,以啟用涵蓋範圍收集功能,並判斷涵蓋範圍執行階段要將涵蓋範圍檔案寫入何處。然後執行測試。測試本身可能會執行多個子程序,並包含以多種不同程式設計語言編寫的部分 (具有個別的涵蓋範圍收集執行階段)。包裝函式指令碼負責視需要將產生的檔案轉換為 LCOV 格式,並合併為單一檔案。

測試策略會執行 collect_coverage.sh 的插補作業,且需要 collect_coverage.sh 位於測試的輸入內容中。這是透過隱含屬性 :coverage_support 達成,該屬性會解析為設定標記 --coverage_support 的值 (請參閱 TestConfiguration.TestOptions.coverageSupport)。

部分語言會進行離線插樁,也就是在編譯時加入涵蓋範圍插樁 (例如 C++),其他語言則會進行線上插樁,也就是在執行時加入涵蓋範圍插樁。

另一個核心概念是基準涵蓋範圍。如果程式庫、二進位檔或測試中的程式碼未執行,就會出現這種涵蓋範圍。這個問題的解決方法是,如果您想計算二進位檔的測試涵蓋範圍,光是合併所有測試的涵蓋範圍是不夠的,因為二進位檔中可能會有未連結至任何測試的程式碼。因此,我們會為每個二進位檔發出涵蓋範圍檔案,其中只包含我們收集涵蓋範圍的檔案,且不含涵蓋範圍的行。目標的預設基準涵蓋範圍檔案位於 bazel-testlogs/$PACKAGE/$TARGET/baseline_coverage.dat,但建議規則產生自己的基準涵蓋範圍檔案,其中包含的內容比來源檔案名稱更有意義。

我們會追蹤兩組檔案,為每項規則收集涵蓋範圍資料:一組是經過插碼的檔案,另一組是插碼中繼資料檔案。

這組插碼檔案就是一組要插碼的檔案。對於線上涵蓋範圍執行階段,這項功能可在執行階段用來決定要檢測哪些檔案。這項資料也會用於實作基準涵蓋範圍。

這組插碼中繼資料檔案是測試產生 LCOV 檔案時,Bazel 需要的額外檔案。實際上,這包含執行階段專屬檔案;舉例來說,gcc 會在編譯期間發出 .gcno 檔案。如果啟用涵蓋範圍模式,這些項目會新增至測試動作的輸入集。

系統會將是否要收集涵蓋範圍的資訊儲存在 BuildConfiguration 中。這很方便,因為您可以根據這個位元輕鬆變更測試動作和動作圖,但也表示如果這個位元翻轉,所有目標都必須重新分析 (部分語言 (例如 C++) 需要不同的編譯器選項,才能發出可收集涵蓋範圍的程式碼,因此無論如何都需要重新分析,這在某種程度上可減輕這個問題)。

涵蓋範圍支援檔案會透過隱含依附元件中的標籤依附元件,因此可由叫用政策覆寫,進而允許不同版本的 Bazel 存在差異。理想情況下,這些差異會移除,並以其中一個為標準。

我們也會產生「涵蓋範圍報告」,合併 Bazel 呼叫中每個測試收集的涵蓋範圍。這項作業由 CoverageReportActionFactory 處理,並從 BuildView.createResult() 呼叫。系統會查看執行的第一個測試的 :coverage_report_generator 屬性,取得所需工具的存取權。

查詢引擎

Bazel 有一種小語言,可用來詢問各種圖表相關問題。系統提供下列查詢類型:

  • bazel query 用於調查目標圖表
  • bazel cquery 用於調查設定的目標圖表
  • bazel aquery 用於調查動作圖表

這些項目都是透過子類別化 AbstractBlazeQueryEnvironment 實作。如要執行其他查詢函式,可以建立 QueryFunction 的子類別。為了允許串流查詢結果,系統會將 query2.engine.Callback 傳遞至 QueryFunction,而不是將結果收集到某些資料結構中,而 QueryFunction 會針對要傳回的結果呼叫 query2.engine.Callback

查詢結果可以多種方式發出:標籤、標籤和規則類別、XML、protobuf 等。這些項目會實作為 OutputFormatter 的子類別。

某些查詢輸出格式 (尤其是 proto) 的細微需求是,Bazel 必須發出套件載入提供的「所有」資訊,這樣才能比較輸出內容,並判斷特定目標是否已變更。因此,屬性值必須可序列化,這也是為什麼只有少數屬性類型沒有任何具有複雜 Starlark 值的屬性。一般來說,解決方法是使用標籤,並將複雜資訊附加至含有該標籤的規則。這並非令人滿意的解決方法,因此我們很希望可以解除這項規定。

模組系統

您可以新增模組來擴充 Bazel。每個模組都必須將 BlazeModule 子類別化 (這個名稱是 Bazel 歷史的遺物,當時稱為 Blaze),並在執行指令期間取得各種事件的相關資訊。

這些函式主要用於實作各種「非核心」功能,只有部分版本的 Bazel (例如 Google 使用的版本) 需要這些功能:

  • 遠端執行系統的介面
  • 新指令

擴充點 BlazeModule 的組合有點隨機。請勿將其做為良好設計原則的範例。

事件匯流排

BlazeModules 與 Bazel 其餘部分通訊的主要方式是透過事件匯流排 (EventBus):系統會為每個建構作業建立新例項,Bazel 的各個部分可以將事件發布至該例項,而模組可以為感興趣的事件註冊監聽器。舉例來說,下列項目會以事件表示:

  • 已決定要建構的建構目標清單 (TargetParsingCompleteEvent)
  • 已判斷頂層設定 (BuildConfigurationEvent)
  • 已建構目標,無論是否成功 (TargetCompleteEvent)
  • 已執行測試 (TestAttemptTestSummary)

其中部分事件會在 Bazel 外部以「建構事件通訊協定」 (即 BuildEvent) 表示。這不僅允許 BlazeModule,也允許 Bazel 程序以外的事物觀察建構作業。您可以透過包含通訊協定訊息的檔案存取這些事件,也可以讓 Bazel 連線至伺服器 (稱為 Build Event Service) 來串流事件。

這是在 build.lib.buildeventservicebuild.lib.buildeventstream Java 套件中實作。

外部存放區

Bazel 最初的設計是搭配單一存放區 (包含建構所需一切內容的單一來源樹狀結構) 使用,但 Bazel 所在的環境不一定符合這個條件。「外部存放區」是抽象概念,用於連結這兩個世界:代表建構所需的程式碼,但不在主要來源樹狀結構中。

WORKSPACE 檔案

系統會剖析 WORKSPACE 檔案,判斷外部存放區集。舉例來說,類似下方的宣告:

    local_repository(name="foo", path="/foo/bar")

存放區中會提供名為 @foo 的結果。複雜的是,您可以在 Starlark 檔案中定義新的存放區規則,然後載入新的 Starlark 程式碼,並用來定義新的存放區規則,依此類推。

為處理這種情況,WORKSPACE 檔案的剖析作業 (位於 WorkspaceFileFunction 中) 會依 load() 陳述式劃分成多個區塊。區塊索引以 WorkspaceFileKey.getIndex() 表示,而計算 WorkspaceFileFunction 直到索引 X,表示評估直到第 X 個 load() 陳述式為止。

擷取存放區

存放區的程式碼必須先擷取,Bazel 才能使用。這樣一來,Bazel 就會在 $OUTPUT_BASE/external/<repository name> 下建立目錄。

擷取存放區的步驟如下:

  1. PackageLookupFunction 發現需要存放區,並建立 RepositoryName 做為 SkyKey,這會叫用 RepositoryLoaderFunction
  2. RepositoryLoaderFunction 會將要求轉送至 RepositoryDelegatorFunction,原因不明 (程式碼表示這是為了避免在 Skyframe 重新啟動時重新下載項目,但這並非十分合理的理由)
  3. RepositoryDelegatorFunction 會疊代 WORKSPACE 檔案的區塊,直到找到所要求的存放區為止,藉此找出要擷取的存放區規則
  4. 系統會找到適當的 RepositoryFunction,實作存放區擷取作業;這可能是存放區的 Starlark 實作項目,或是以 Java 實作的存放區硬式編碼對應。

由於擷取存放區的成本可能非常高,因此快取層級相當多元:

  1. 下載的檔案會依據其總和檢查碼 (RepositoryCache) 建立快取。這需要總和檢查碼位於 WORKSPACE 檔案中,但無論如何,這對密封性都有好處。無論在哪個工作區或輸出基礎中執行,同一工作站上的每個 Bazel 伺服器執行個體都會共用這個目錄。
  2. 系統會為 $OUTPUT_BASE/external 下的每個存放區寫入「標記檔案」,其中包含用於擷取規則的總和檢查碼。如果 Bazel 伺服器重新啟動,但檢查碼未變更,系統就不會重新擷取。這是在 RepositoryDelegatorFunction.DigestWriter 中實作。
  3. --distdir 指令列選項會指定另一個快取,用於查閱要下載的構件。這在企業設定中非常實用,因為 Bazel 不應從網際網路擷取隨機項目。這是由 DownloadManager 實作。

下載存放區後,其中的構件會視為來源構件。這會造成問題,因為 Bazel 通常會對來源構件呼叫 stat(),藉此檢查構件是否為最新版本,而當構件所在存放區的定義變更時,這些構件也會失效。因此,外部存放區中的構件FileStateValue必須依附於外部存放區。這項作業是由 ExternalFilesHelper 負責。

存放區對應

多個存放區可能想依附於同一個存放區,但版本不同 (這是「菱形依附元件問題」的例子)。舉例來說,如果建構作業中不同存放區的兩個二進位檔都想依附於 Guava,這兩個二進位檔可能會以開頭為 @guava// 的標籤參照 Guava,並預期這代表不同版本的 Guava。

因此,Bazel 允許重新對應外部存放區標籤,以便字串 @guava// 可在一個二進位的存放區中參照一個 Guava 存放區 (例如 @guava1//),在另一個二進位的存放區中參照另一個 Guava 存放區 (例如 @guava2//)。

或者,您也可以使用這項功能加入鑽石。如果存放區依附於 @guava1//,另一個存放區依附於 @guava2//,存放區對應可讓您重新對應這兩個存放區,以使用標準 @guava// 存放區。

對應會在 WORKSPACE 檔案中指定為個別存放區定義的 repo_mapping 屬性。接著,該節點會以 WorkspaceFileValue 的成員身分顯示在 Skyframe 中,並連接至下列項目:

  • Package.Builder.repositoryMapping,用於透過 RuleClass.populateRuleAttributeValues() 轉換套件中規則的標籤值屬性
  • Package.repositoryMapping,用於分析階段 (解決載入階段未剖析的 $(location) 等項目)
  • BzlLoadFunction,用於解析 load() 陳述式中的標籤

JNI 位元

Bazel 伺服器大多是以 Java 編寫,例外狀況是 Java 無法自行完成的部分,或是在我們實作時無法自行完成的部分。這類作業大多僅限於與檔案系統互動、控制程序,以及各種其他低階事項。

C++ 程式碼位於 src/main/native 下方,而具有原生方法的 Java 類別如下:

  • NativePosixFilesNativePosixFileSystem
  • ProcessUtils
  • WindowsFileOperationsWindowsFileProcesses
  • com.google.devtools.build.lib.platform

控制台輸出內容

發出控制台輸出內容看似簡單,但由於執行多個程序 (有時是遠端執行)、細微的快取、希望有美觀且色彩豐富的終端機輸出內容,以及長時間執行的伺服器,因此並非易事。

用戶端傳送 RPC 呼叫後,系統會立即建立兩個 RpcOutputStream 執行個體 (分別用於 stdout 和 stderr),將列印到這些執行個體的資料轉送至用戶端。然後包裝在 OutErr 中 (即 (stdout, stderr) 配對)。凡是需要在控制台上列印的內容,都會透過這些串流傳送。然後將這些串流交給 BlazeCommandDispatcher.execExclusively()

輸出內容預設會以 ANSI 逸出序列列印。如果不需要這些項目 (--color=no),系統會透過 AnsiStrippingOutputStream 移除。此外,System.outSystem.err 會重新導向至這些輸出串流。這樣一來,您就能使用 System.err.println() 列印偵錯資訊,並在用戶端的終端機輸出內容中顯示 (與伺服器不同)。如果程序產生二進位輸出內容 (例如 bazel query --output=proto),系統會確保不會對 stdout 進行任何處理。

簡短訊息 (錯誤、警告等) 會透過 EventHandler 介面顯示。請注意,這些內容與使用者在 EventBus 上發布的內容不同 (這點容易造成混淆)。每個 Event 都有 EventKind (錯誤、警告、資訊和其他幾項),且可能會有 Location (導致事件發生的原始碼位置)。

部分 EventHandler 實作項目會儲存收到的事件。這項功能用於將各種快取處理作業 (例如快取設定目標發出的警告) 造成的資訊重新傳送至 UI。

部分 EventHandler 也允許發布最終會傳送到事件匯流排的事件 (一般 Event 不會顯示在該處)。這些是 ExtendedEventHandler 的實作項目,主要用途是重播快取的 EventBus 事件。這些 EventBus 事件都會實作 Postable,但並非所有發布至 EventBus 的項目都必須實作這個介面;只有 ExtendedEventHandler 快取的項目 (最好是這樣,而且大部分項目都會這麼做;但這並非強制規定)

終端機輸出內容大多是透過 UiEventHandler 發出,負責所有花俏的輸出格式和進度回報 Bazel 所做的事。這項函式有兩項輸入內容:

  • 事件匯流排
  • 透過 Reporter 管道傳送至其中的事件串流

指令執行機制 (例如 Bazel 的其餘部分) 與用戶端 RPC 串流的唯一直接連線是透過 Reporter.getOutErr(),這可直接存取這些串流。只有在指令需要傾印大量可能的二進位資料時 (例如 bazel query),才會使用這項功能。

剖析 Bazel

Bazel 速度很快。Bazel 也很慢,因為建構作業往往會成長,直到達到可容忍的極限為止。因此,Bazel 內含可剖析建構作業和 Bazel 本身的剖析器。這項功能是在名為 Profiler 的類別中實作。這項功能預設為開啟,但只會記錄簡略資料,因此負擔尚可接受;--record_full_profiler_data 指令列會記錄所有可記錄的資料。

這會以 Chrome 分析器格式發出設定檔,建議在 Chrome 中查看。 其資料模型為工作堆疊:使用者可以開始及結束工作,且這些工作應整齊地彼此巢狀內嵌。每個 Java 執行緒都有自己的工作堆疊。TODO: How does this work with actions and continuation-passing style?

分析器分別在 BlazeRuntime.initProfiler()BlazeRuntime.afterCommand() 中啟動及停止,並盡可能長時間保持運作,以便分析所有內容。如要在設定檔中新增項目,請呼叫 Profiler.instance().profile()。這個函式會傳回 Closeable,其結尾代表工作結束。建議搭配 try-with-resources 陳述式使用。

我們也會在 MemoryProfiler 中進行基本的記憶體剖析。這項記錄檔一律會開啟,主要記錄堆積大小上限和垃圾收集行為。

測試 Bazel

Bazel 主要有兩種類型的測試:一種是將 Bazel 視為「黑箱」進行觀察,另一種則是只執行分析階段。我們將前者稱為「整合測試」,後者稱為「單元測試」,但後者更像是整合測試,只是整合程度較低。我們也有一些實際的單元測試,這些測試是必要的。

整合測試分為兩種類型:

  1. 這些測試是使用 src/test/shell 下非常精細的 Bash 測試架構實作。
  2. 以 Java 實作的項目。這些實作項目是 BuildIntegrationTestCase 的子類別

BuildIntegrationTestCase 是首選的整合測試架構,因為它適用於大多數測試情境。由於是 Java 架構,因此可提供偵錯功能,並與許多常見的開發工具完美整合。Bazel 存放區中有很多 BuildIntegrationTestCase 類別的範例。

分析測試會實作為 BuildViewTestCase 的子類別。您可以使用暫存檔案系統寫入 BUILD 檔案,然後各種輔助方法可以要求設定的目標、變更設定,並判斷分析結果的各種事項。