Bazel 程式碼集

7.3 · 7.2 · 7.1 · 7.0 · 6.5

本文件將說明程式碼集以及 Bazel 的結構。這項工具適用於願意為 Bazel 做出貢獻的使用者,而非終端使用者。

簡介

Bazel 的程式碼庫相當龐大 (約 350KLOC 的正式版程式碼和約 260KLOC 的測試程式碼),而且沒有人熟悉整個環境:每個人都很熟悉自己的特定領域,但很少人知道各個方向的山丘上有什麼。

為了讓在旅途中迷失方向的人能輕鬆找到正確路線,本文件會提供程式碼庫的概略說明,讓您更容易開始使用。

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 連線上的取消呼叫,並嘗試盡快終止指令。在第三次按下 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. 系統會建立事件匯流排。事件匯流是用於建構期間發生的事件串流。其中部分會在 Build Event Protocol 的保護下匯出至 Bazel 外,以便向全世界說明建構作業的進行情形。

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

  6. 系統會剖析指令列上的目標模式集合,並解析 //pkg:all//pkg/... 等萬用字元。這會在 AnalysisPhaseRunner.evaluateTargetPatterns() 中實作,並在 Skyframe 中以 TargetPatternPhaseValue 的形式實現。

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

  8. 執行階段已執行。也就是說,您必須執行所有必要動作,才能建立要求的頂層目標。

指令列選項

OptionsParsingResult 物件中會說明 Bazel 叫用的指令列選項,而該物件又包含從「選項類別」對應到選項值的對應項目。「選項類別」是 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.bazelBUILD 檔案仍會接受的原因是 Bazel 的祖系 Blaze 使用了這個檔案名稱。不過,這項功能已成為常用的路徑片段,特別是在 Windows 上,因為在 Windows 上,檔案名稱不區分大小寫。

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

BUILD 檔案的評估作業稱為「套件載入」。這項功能已在 PackageFactory 類別中實作,其運作方式是呼叫 Starlark 解譯器,並需要瞭解可用的規則類別集。套件載入的結果是 Package 物件。大部分是從字串 (目標名稱) 對應至目標本身。

載入套件期間有很大的複雜問題:Bazel 不需要明確列出每個來源檔案,而是可以執行 glob (例如 glob(["**/*.java"]))。與殼層不同,它支援向下傳遞至子目錄的遞迴 glob (但不支援子套件)。這需要存取檔案系統,而且速度可能很慢,因此我們會實作各種技巧,盡可能平行且有效率地同時執行系統。

以下類別會實作繪圖操作:

  • LegacyGlobber,快速且無需 Skyframe 的 globber
  • SkyframeHybridGlobber,這個版本是使用 SkyFrame,並還原為舊版 globber,以免發生「SkyFrame 重新啟動」(如下所述)

Package 類別本身包含一些成員,這些成員專門用於剖析「外部」套件 (與外部依附元件相關),但對實際套件而言並無意義。這是設計上的缺陷,因為描述一般套件的物件不應包含描述其他項目的欄位。包括:

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

理想情況下,剖析「外部」套件與剖析一般套件之間應有更明確的區隔,以免 Package 需要同時滿足兩者的需要。這很難做,因為兩者的交互作用非常深刻。

標籤、指定目標和規則

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

  1. 「Files」:建構的輸入或輸出內容。在 Bazel 的術語中,我們稱之為構件 (另文有討論)。並非所有在建構期間建立的檔案均為目標;Bazel 的輸出內容通常不會有相關標籤。
  2. 規則:這些是從輸入內容衍生輸出內容的步驟。這些元素通常與程式設計語言相關 (例如 cc_libraryjava_librarypy_library),但也有一些與語言無關的元素 (例如 genrulefilegroup)。
  3. 套件群組:請參閱「顯示設定」一節。

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

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

有一種規則 (例如「C++ 程式庫」) 稱為「規則類別」。規則類別可在 Starlark (rule() 函式) 或 Java (所謂的「原生規則」,類型為 RuleClass) 中實作。長期來說,每個語言專屬規則都會在 Starlark 中實作,但某些舊規則系列 (例如 Java 或 C++) 目前仍在 Java 中實作。

Starlark 規則類別必須使用 load() 陳述式在 BUILD 檔案的開頭匯入,而 Java 規則類別「自然」是由 Bazel 所知,因為已透過 ConfiguredRuleClassProvider 註冊。

規則類別包含以下資訊:

  1. 屬性 (例如 srcsdeps):類型、預設值、限制等。
  2. 附加至每個屬性的設定轉換和面向 (如果有的話)
  3. 規則的實作
  4. 規則「通常」會建立的間接資訊提供者

術語說明:在程式碼庫中,我們經常使用「規則」一詞來表示規則類別建立的目標。但在 Starlark 和麵向使用者的說明文件中,「規則」只能用來參照規則類別本身,而目標僅是「目標」。另請注意,雖然 RuleClass 名稱中含有「class」,但規則類別與該類型的目標之間並沒有 Java 繼承關係。

Skyframe

Bazel 底層的評估架構稱為 Skyframe。其模型是將建構期間建構的每個項目都整理成有向非循環圖,而且邊緣從任何資料指向其依附元件的邊緣,也就是需要知道建構它的其他資料。

圖表中的節點稱為 SkyValue,其名稱則稱為 SkyKey。兩者都基本上不可變更,只有不可變動的物件可透過這些物件存取。這個不變量幾乎總是有效,如果不有效 (例如個別選項類別 BuildOptions,這是 BuildConfigurationValue 和其 SkyKey 的成員),我們會盡可能不變更這些類別,或只以無法從外部觀察的方式變更這些類別。因此,在 Skyframe 中計算的所有內容 (例如已設定的目標) 也必須是不可變動的。

觀察 Skyframe 圖表最方便的方式,就是執行 bazel dump --skyframe=deps,這個指令會將圖表轉儲,每行一個 SkyValue。最好只對小型版本執行此操作,因為這可能會變得相當大。

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

如要將特定 SkyKey 評估為 SkyValue,Skyframe 會叫用與鍵類型相對應的 SkyFunction。在函式評估期間,函式可能會呼叫 SkyFunction.Environment.getValue() 的各種超載,從 Skyframe 要求其他依附元件。這會產生副作用,將這些依附元件註冊至 Skyframe 的內部圖表,以便 Skyframe 在任何依附元件變更時重新評估函式。換句話說,Skyframe 的快取和遞增運算作業是以 SkyFunctionSkyValue 的細微度運作。

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

這會導致在 SkyFunction 中執行的任何運算,在重新啟動前必須重複執行。但這不包括評估依附元件 SkyValues 所做的作業,因為該作業已快取。因此,我們通常透過以下方式解決這個問題:

  1. 使用 getValuesAndExceptions() 在批次中宣告依附元件,以限制重新啟動次數。
  2. SkyValue 拆分為由不同 SkyFunction 計算的獨立部分,以便單獨運算及快取。這項操作可能會增加記憶體用量,因此應謹慎執行。
  3. 在重新啟動之間儲存狀態,使用 SkyFunction.Environment.getState() 或在「Skyframe 背後」保留臨時靜態快取。對於複雜的 SkyFunction,重新啟動之間的狀態管理可能會變得棘手,因此我們引入了 StateMachine,以便以結構化方式處理邏輯並行作業,包括在 SkyFunction 中暫停及恢復階層式運算的鉤子。範例:DependencyResolver#computeDependencies 會使用 StateMachine 搭配 getState() 來計算已設定目標的可能龐大直接依附元件集,否則可能會導致重新啟動作業耗費過多。

從根本上來說,Bazel 需要這類因應措施,因為在執行中的 Skyframe 節點通常有數十萬個,而 Java 對輕量級執行緒的支援不如 StateMachine 實作,截至 2023 年為止。

史塔拉克

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. 設定。("how" to build that rule; for example, the target platform, but also things like command line options the user wants to be passed to the C++ compiler)
  2. 直接依附元件。這些遞移資訊提供者可適用於分析的規則之所以稱為「匯總」,是因為這些類別會在已設定目標的傳遞閉包中提供資訊的「匯總」,例如 classpath 中的所有 .jar 檔案,或是需要連結至 C++ 二進位檔的所有 .o 檔案。
  3. 目標本身。這是載入目標所在的套件所產生的結果。對規則來說,這包括屬性,通常都是很重要的。
  4. 已設定目標的實作項目。規則可以是 Starlark 或 Java 語言。所有非規則設定的目標皆已在 Java 中實作。

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

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

提供給 Java 規則的 API 為 RuleContext,相當於 Starlark 規則的 ctx 引數。其 API 的功能更強大,但同時也可輕鬆執行 Bad ThingsTM,例如:編寫程式碼的時間或空間複雜度為正反兩 (或較差)、導致 Bazel 伺服器因 Java 例外狀況而當機或違反非變數 (例如不慎修改 Options 執行個體,或將已設定的目標設為可變動的目標)

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

設定

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

同一個版本中的多項設定可以建構相同的目標。這在下列情況下很實用:當我們在交叉編譯時,使用相同的程式碼為建構期間執行的工具和目標程式碼,或是建構大型 Android 應用程式 (包含多個 CPU 架構的原生程式碼)

從概念上來說,設定就是 BuildOptions 例項。不過,在實際情況中,BuildOptions 會由 BuildConfiguration 包裝,提供其他各式各樣的功能。從依附元件圖表頂端傳播至底部。如果變更,則需要重新分析建構。

這會導致異常狀況,例如如果要求的測試執行次數有所變更,即使這只會影響測試目標,也必須重新分析整個建構作業 (我們已規劃要「修剪」設定,以免發生這種情況,但這項功能尚未就緒)。

當規則實作需要部分設定時,就必須使用 RuleClass.Builder.requiresConfigurationFragments() 在其定義中宣告設定。這麼做可以避免錯誤 (例如使用 Java 片段的 Python 規則),並便於裁減設定,以便在 Python 選項變更時,不必重新分析 C++ 目標。

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

  1. 在依附元件邊緣上。這些轉場會在 Attribute.Builder.cfg() 中指定,並從 Rule (轉場發生的位置) 和 BuildOptions (原始設定) 傳遞至一或多個 BuildOptions (輸出設定)。
  2. 在任何已設定目標的傳入邊上。這些項目會在 RuleClass.Builder.cfg() 中指定。

相關的類別為 TransitionFactoryConfigurationTransition

使用設定轉換,例如:

  1. 宣告在建構期間使用特定依附元件,因此應在執行架構中建構
  2. 如要宣告特定依附元件必須針對多個架構進行建構 (例如針對肥胖 Android APK 中的原生程式碼)

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

設定轉換也可以在 Starlark 中實作 (說明文件請見此處)

遞移資訊提供者

遞移資訊提供者是一個方法 (且「只限」_way),可讓已設定的目標瞭解其依附的其他設定目標,以及告知本身與依附其他已設定目標的唯一方式。名稱中含有「transitive」的原因是,這通常是設定目標的傳遞閉包的某種匯總。

Java 傳遞式資訊提供者和 Starlark 傳遞式資訊提供者之間通常會 1:1 對應 (例外狀況是 DefaultInfo,這是 FileProviderFilesToRunProviderRunfilesProvider 的合併結果,因為該 API 被視為更偏向 Starlark,而非 Java 的直接轉寫)。其鍵為下列其中一種:

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

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

已設定的目標

設定的目標會實作為 RuleConfiguredTargetFactory。每個在 Java 中實作的規則類別都有一個子類別。Starlark 設定的目標是透過 StarlarkRuleConfiguredTargetUtil.buildRule() 建立。

已設定的目標工廠應使用 RuleConfiguredTargetBuilder 建構其傳回值。其中包含下列內容:

  1. filesToBuild,這個模糊的概念是「這個規則所代表的檔案集合」。這些是在所設定目標位於指令列或 Genrule 的 src 中時建構的檔案。
  2. 其執行檔、規則和資料。
  3. 輸出內容群組。這些是規則可建構的各種「其他檔案組合」。您可以使用 BUILD 中檔案群組規則的 output_group 屬性,以及 Java 中的 OutputGroupInfo 供應器存取這些檔案。

執行檔

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

一組執行檔會以 Runfiles 例項表示。從概念上來說,這項作業是將執行檔案樹狀目錄中的檔案路徑對應至代表該檔案的 Artifact 例項。這與單一 Map 稍有不同,原因有二:

  • 在多數情況下,檔案的 runfiles 路徑會與 execpath 相同。我們會使用這個方法來節省一些 RAM。
  • 執行檔樹狀結構中含有各種舊版項目,這些項目也需要呈現。

執行檔案會透過 RunfilesProvider 收集:這個類別的例項代表執行檔案的目標 (例如程式庫) 及其轉換閉包需求,且會像透過巢狀集合一樣收集檔案 (實際上是使用巢狀集合實作):每個目標會聯集其依附元件的執行檔案、新增一些自己的依附元件,然後傳送在依附元件圖表中設定產生的設定。RunfilesProvider 例項包含兩個 Runfiles 例項,一個用於規則透過「data」屬性依附,另一個用於每種其他類型的傳入依附元件。這是因為目標在透過資料屬性依附時,有時會顯示不同的執行檔,而非其他方式。這是不必要的舊版行為,我們尚未移除。

二進位檔的執行檔案會以 RunfilesSupport 的例項表示。這與 Runfiles 不同,因為 RunfilesSupport 具有實際建構的功能 (而 Runfiles 只是對應)。這需要下列額外元件:

  • 輸入的 runfiles 資訊清單。這是 runfiles 樹狀結構的序列化說明。它可做為執行檔案樹狀結構內容的 Proxy,而 Bazel 會假設,只有在資訊清單內容有所變更時,執行檔案樹狀結構才會變更。
  • 輸出 runfiles 資訊清單。這項屬性由處理執行檔樹狀結構的執行階段程式庫使用,特別是在 Windows 上,因為 Windows 有時不支援符號連結。
  • 執行檔案中間人為了讓 runfiles 樹狀結構存在,您必須建立符號連結樹狀結構和符號連結所指向的構件。為了減少依附元件邊緣的數量,可以使用 runfiles 中介軟體來代表所有這些邊緣。
  • 用於執行 RunfilesSupport 物件所代表的執行檔的指令列引數

切面

切面是用來「在依附關係圖中傳播計算」。如要瞭解這些選項,請參閱這篇文章,瞭解 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 的 classpath 的切面可能會想瞭解 classpath 中的所有 .jar 檔案,但其中有些是通訊協定緩衝區。在這種情況下,IDE 面向會想要附加至 (proto_library 規則 + Java proto 面向) 組合。

類別 AspectCollection 會擷取各個面向的複雜性。

平台和工具鍊

Bazel 支援多平台建構作業,也就是在建構動作執行時可能會有多個架構,以及用於建構程式碼的多個架構。在 Bazel 的術語中,這些架構稱為「平台」 (完整說明文件請見這裡)

平台會透過鍵/值對應來描述,從限制設定 (例如「CPU 架構」的概念) 到限制值 (例如 x86_64 等特定 CPU)。我們在 @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) 中) 所需的工具鏈類型組合
  • UnloadedToolchainContextKey 中已設定目標 (exec_compatible_with 屬性) 和設定 (--experimental_add_exec_constraints_to_targets) 的執行平台限制組合

這會產生 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 透過稱為「可見度」的機制支援此功能:您可以使用可見度屬性限制哪些目標可以依附特定目標。這個屬性有點特別,因為雖然它會保留標籤清單,但這些標籤可能會對套件名稱編碼,而非指向任何特定目標的指標。(是的,這是設計瑕疵)。

這項功能會在以下位置實作:

  • 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 傳遞閉包的物件檔案
  • 需要放在 classpath 的 .jar 檔案集合,以便 Java 規則編譯或執行
  • Python 規則的傳遞閉包中的 Python 檔案集

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

為瞭解決這個問題,我們提出了 NestedSet 的概念。這是一種資料結構,由其他 NestedSet 執行個體和自己的某些成員組成,從而形成一個有向非循環圖。它們是不可變動的,且成員可進行疊代。我們定義了多個疊代順序 (NestedSet.Order):前序、後序、拓樸 (節點一律會出現在其祖系之後) 和「不指定,但每次都應相同」。

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

構件和動作

實際的建構作業包含一組需要執行的指令,才能產生使用者想要的輸出內容。指令會以 Action 類別的執行個體表示,且檔案會表示為 Artifact 類別的執行個體。這些元素會以有向無環的二元圖表排列,稱為「動作圖表」。

構件分為兩種:來源構件 (在 Bazel 開始執行前可以使用) 和衍生成果 (需要建構的構件)。衍生成果本身可以有多種:

  1. **一般成果。**系統會透過計算檔案的總和檢查檔案是否為最新版本,並以 mtime 做為捷徑;如果檔案的 ctime 未變更,系統就不會計算檔案的總和。
  2. 未解析的符號連結構件。系統會透過呼叫 readlink() 來檢查這些項目是否為最新版本。與一般構件不同,這些項目可能是懸空符號連結。通常用於將某些檔案打包成某種封存檔。
  3. 樹狀圖成果。這些不是單一檔案,而是目錄樹狀結構。系統會檢查其中的檔案組合及其內容,以確認這些檔案是否為最新版本。以 TreeArtifact 表示。
  4. 常數中繼資料構件。這些構件發生變更時不會觸發重建作業。這項資訊僅用於建構戳記資訊:我們不希望因為目前時間變更就進行重建作業。

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

Artifact 的一種重要類型是中介服務。這些值由 Artifact 例項表示,而這些例項是 MiddlemanAction 的輸出內容。這些結構會用於一個特殊情況:

  • 執行檔中介服務可確保執行檔樹狀目錄的存在,因此不需要個別依賴輸出資訊清單和執行檔樹狀目錄參照的每個構件。

動作最適合用來表示需要執行的指令、所需的環境,以及產生的輸出內容。以下是動作說明的主要元件:

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

也有一些特殊情況,例如編寫內容屬於 Bazel 的檔案。這些是 AbstractAction 的子類別。大部分的動作都是 SpawnActionStarlarkAction (相同,不應是個別類別),但 Java 和 C++ 有各自的動作類型 (JavaCompileActionCppCompileActionCppLinkAction)。

我們最終希望將所有內容都移至 SpawnActionJavaCompileAction 非常接近,但 C++ 有點特殊,因為它會剖析 .d 檔案並掃描包含檔案。

動作圖大多「嵌入」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 中重新執行動作,仍可在本機動作快取中命中。它代表本機檔案系統的狀態,並且會序列化至磁碟,這表示在啟動新的 Bazel 伺服器時,即使 Skyframe 圖表為空白,也能取得本機動作快取命中。

系統會使用 ActionCacheChecker.getTokenIfNeedToExecute() 方法,檢查這個快取是否含有命中。

有別於名稱,這是從衍生構件的路徑到發出動作的對應路徑。該動作的說明如下:

  1. 其輸入和輸出檔案集,以及其總和檢查碼
  2. 其「動作鍵」通常是執行的命令列,但一般來說,它代表輸入檔案的總和檢查碼未擷取的所有內容 (例如 FileWriteAction,它是寫入資料的總和檢查碼)

此外,我們也正在開發一項高度實驗性的「自上而下的動作快取」,這項功能會使用中介雜湊來避免多次存取快取。

輸入探索和輸入內容縮減

有些動作不只是一組輸入內容,動作的輸入組合可進行兩種變更:

  • 動作可能會在執行前發現新的輸入內容,或決定部分輸入內容其實並非必要。標準範例是 C++,在這種情況下,建議您根據 C++ 檔案的傳遞閉包,根據經驗判斷檔案使用的標頭檔案,這樣就不必將每個檔案都傳送至遠端執行者;因此,我們可以選擇不將每個標頭檔案都註冊為「輸入」檔案,而是掃描來源檔案,找出傳遞式納入的標頭,並只將這些標頭檔案標示為 #include 陳述式中提及的輸入檔案 (我們會高估,因此不必實作完整的 C 前置處理器)。這個選項目前在 Bazel 中硬連結為「false」,且只在 Google 中使用。
  • 動作可能會發現執行過程中並未有人用到某些檔案。在 C++ 中,這稱為「.d 檔案」:編譯器會在事後指出使用了哪些標頭檔案,為了避免比 Make 更糟糕的增量,Bazel 會利用這項事實。這比依賴編譯器的納入掃描器提供更準確的估計值。

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

  1. 系統會呼叫 Action.discoverInputs()。應傳回一組判定為必要的巢狀構件。這些必須是來源構件,以確保動作圖表中沒有與所設目標圖表沒有同等的依附元件邊緣。
  2. 透過呼叫 Action.execute() 執行動作。
  3. Action.execute() 結尾,動作可以呼叫 Action.updateInputs(),告訴 Bazel 並非所有輸入都需要。如果已使用的輸入內容被回報為未使用,這可能會導致不正確的增量建構作業。

當動作快取在新的動作例項 (例如在伺服器重新啟動後建立) 上傳回命中時,Bazel 會呼叫 updateInputs() 本身,讓輸入集反映先前執行的輸入內容探索和修剪結果。

Starlark 動作可利用設施,使用 ctx.actions.run()unused_inputs_list= 引數將某些輸入內容宣告為未使用。

執行動作的多種方式:策略/ActionContext

部分動作可透過不同方式執行。舉例來說,指令列可在本機執行、在本機的各種沙箱中執行,或在遠端執行。這項概念稱為 ActionContext (或 Strategy,因為我們只成功將名稱改半邊...)

動作內容的生命週期如下:

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

策略可以自由呼叫其他策略來執行其工作;例如,在動態策略中,同時在本機和遠端啟動動作,然後使用先完成的動作。

值得注意的策略是實作持續性工作站程序 (WorkerSpawnStrategy) 的做法。因為有些工具的啟動時間很長,因此建議在操作之間重複使用,而不是在每個動作之間啟動一個新的 (這可能代表潛在的正確問題,因為 Bazel 仰賴工作站處理程序的承諾,因為 Bazel 在個別要求之間並未提供可觀測的狀態)

如果工具有所變更,就必須重新啟動 worker 程序。系統會使用 WorkerFilesHash 計算所用工具的總和檢查碼,藉此判斷 worker 是否可重複使用。這項作業需要知道哪些動作輸入內容代表工具的一部分,哪些代表輸入內容;這項作業由動作建立者決定: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() 中進一步處理:系統會篩除分析失敗的目標,並將測試拆分為專屬和非專屬測試。接著,系統會將其放入 AnalysisResult,讓 ExecutionTool 知道要執行哪些測試。

為了讓這個複雜的程序更透明,您可以使用 tests() 查詢運算子 (在 TestsFunction 中實作),在指令列上指定特定目標時,告知系統執行哪些測試。很遺憾,這是重新實作,因此可能會在多種細微方面與上述內容有所出入。

執行測試

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

系統會根據詳盡的通訊協定執行測試,該通訊協定會使用環境變數來說明測試的預期結果。如要詳細瞭解 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。此外,如果將 --nobuild_tests_only 標記傳送至 Bazel,系統除了會進行測試,還會為二進位檔和程式庫產生這個檔案。

目前基準涵蓋率有問題。

我們會追蹤兩組檔案,用於收集每個規則的涵蓋率:一組是檢測檔案,另一組是檢測中繼資料檔案。

那組檢測檔案就是,用於檢測的一組檔案。對於線上涵蓋率執行階段,這可在執行階段用於決定要檢測哪些檔案。這個巨集也會用於導入基準涵蓋範圍。

一系列檢測中繼資料檔案是指測試需要產生 Bazel 所需 LCOV 檔案時,所需的額外檔案集。實際上,這類檔案包含特定執行階段的檔案;例如,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,讓後者針對要傳回的結果呼叫該函式。

查詢結果可透過多種方式產生:標籤、標籤和規則類別、XML、protobuf 等。這些類別會實作為 OutputFormatter 的子類別。

在某些查詢輸出格式 (Pro,proto 來說) 還有一項細微的需求:Bazel 必須發出「所有」_套件載入所提供的資訊,以便差異比較輸出,並判斷特定目標是否有所變更。 因此,屬性值必須可序列化,這也是為什麼只有少數屬性類型沒有任何屬性具有複雜的 Starlark 值。一般解決方法是使用標籤,並將複雜資訊附加至該標籤的規則。這並不是令人滿意的解決方法,如果能取消這項規定就太好了。

模組系統

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

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

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

BlazeModule 提供的延長點數組合可能有些危險。請勿將其視為良好設計原則的範例。

事件匯流程

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

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

Build Event Protocol (建構事件通訊協定) 中,部分事件會在 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// 開頭,並預期代表不同的版本。

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

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

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

  • 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 實作會儲存收到的事件。這項屬性可用於重播因各種快取處理作業而產生的資訊,例如由快取設定目標發出的警告。

部分 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 執行緒都會取得自己的工作堆疊。待辦事項:如何與操作和接續傳遞樣式搭配運作?

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

我們也會在 MemoryProfiler 中進行基本的記憶體剖析。這項功能一律處於開啟狀態,且主要用於記錄堆積區大小上限和 GC 行為。

測試 Bazel

Bazel 有兩種主要的測試:以「黑盒」方式觀察 Bazel 的測試,以及只執行分析階段的測試。我們將先前的「整合測試」和後者稱為「單元測試」,儘管兩者比較像是整合測試,但整合性也較低。我們也有一些實際的單元測試,可在必要時使用。

整合測試有兩種類型:

  1. src/test/shell 下使用非常精細的 bash 測試架構實作的測試
  2. 以 Java 實作的函式。這些類別會實作為 BuildIntegrationTestCase 的子類別

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

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