本文件說明程式碼集和 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 可執行檔 (「用戶端」) 會取得控制權。它會按照下列步驟設定適當的伺服器程序:
- 檢查是否已自行解壓縮。如果沒有,則會執行這項操作。這就是伺服器實作項目的來源。
- 檢查是否有有效的伺服器執行個體:是否正在執行、是否有正確的啟動選項,以及是否使用正確的工作區目錄。它會查看目錄
$OUTPUT_BASE/server,其中有一個鎖定檔案,其中包含伺服器正在監聽的通訊埠。 - 視需要終止舊伺服器程序
- 視需要啟動新的伺服器程序
適當的伺服器程序就緒後,系統會透過 gRPC 介面與其通訊,傳送需要執行的指令,然後將 Bazel 的輸出內容傳回至終端機。一次只能執行一項指令。這項功能是透過精細的鎖定機制實作,其中部分為 C++,部分為 Java。由於無法並行執行 bazel version 和其他指令,因此我們提供了一些基礎架構,可用於並行執行多個指令。主要阻斷因素是 BlazeModule 的生命週期和 BlazeRuntime 中的部分狀態。
在指令結束時,Bazel 伺服器會傳送用戶端應傳回的結束代碼。bazel run 的實作方式很有趣:這個指令的工作是執行 Bazel 剛建構的項目,但它無法透過伺服器程序執行,因為它沒有終端機。因此,它會告訴用戶端應執行哪個二進位檔的 ujexec(),以及使用哪些引數。
當使用者按下 Ctrl-C 時,用戶端會將其轉譯為 gRPC 連線上的取消呼叫,並嘗試盡快終止指令。在第三次按下 Ctrl-C 後,用戶端會改為傳送 SIGKILL 至伺服器。
用戶端的原始碼位於 src/main/cpp 下方,用於與伺服器通訊的通訊協定則位於 src/main/protobuf/command_server.proto 中。
伺服器的主要進入點是 BlazeRuntime.main(),而來自用戶端的 gRPC 呼叫則由 GrpcServerImpl.run() 處理。
目錄版面配置
Bazel 會在建構期間建立一組相當複雜的目錄。如需完整說明,請參閱「輸出目錄版面配置」。
「workspace」是指 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 伺服器取得控制權,並得知需要執行的指令時,就會發生下列事件序列:
BlazeCommandDispatcher會收到新要求的通知。它會決定指令是否需要在工作區中執行 (幾乎所有指令都需要,但如果與原始程式碼無關的指令,例如版本或說明,則不需),以及是否正在執行其他指令。找到正確的指令。每個指令都必須實作
BlazeCommand介面,且必須有@Command註解 (這有點反模式,如果指令所需的所有中繼資料都由BlazeCommand上的各個方法描述,那就太好了)剖析指令列選項。每個指令都有不同的命令列選項,請參閱
@Command註解。建立事件匯流排。事件匯流是用於建構期間發生的事件串流。其中部分會在「Build Event Protocol」的保護下匯出至 Bazel 外,以便向全世界說明建構作業的進行情形。
指令取得控制權。最有趣的指令是執行建構作業的指令,例如建構、測試、執行、涵蓋率等:這項功能是由
BuildTool實作。系統會剖析指令列上的目標模式集合,並解析
//pkg:all和//pkg/...等萬用字元。這會在AnalysisPhaseRunner.evaluateTargetPatterns()中實作,並在 Skyframe 中以TargetPatternPhaseValue的形式實體化。系統會執行載入/分析階段,產生動作圖表 (指向式無環圖表,包含需要為建構作業執行的指令)。
執行階段會開始執行。也就是說,您必須執行所有必要動作,才能建構所要求的頂層目標。
指令列選項
OptionsParsingResult 物件會說明 Bazel 叫用的指令列選項,而這項物件又會包含從「選項類別」到選項值的對應項目。「選項類別」是 OptionsBase 的子類別,可將彼此相關的指令列選項分組。例如:
- 與程式設計語言 (
CppOptions或JavaOptions) 相關的選項。這些選項應為FragmentOptions的子類別,並最終會包裝為BuildOptions物件。 - 與 Bazel 執行動作的方式相關的選項 (
ExecutionOptions)
這些選項的設計目的是在分析階段使用,可透過 Java 中的 RuleContext.getFragment() 或 Starlark 中的 ctx.fragments 使用。其中部分 (例如是否執行 C++ 包含掃描作業) 會在執行階段讀取,但由於 BuildConfiguration 在該階段無法使用,因此必須明確設定管線。詳情請參閱「設定」一節。
警告:我們會假設 OptionsBase 例項是不可變動的,並以這種方式使用 (例如在 SkyKeys 中)。但實際上並非如此,修改這些例項會以難以偵錯的方式,破壞 Bazel 的運作。不幸的是,要讓這些項目真正不可變化是一項艱鉅的任務。(在建構完成後立即修改 FragmentOptions,其他人無法保留參照,且在呼叫 equals() 或 hashCode() 之前,這麼做是沒問題的)。
Bazel 會透過以下方式瞭解選項類別:
- 有些則是硬連線至 Bazel (
CommonCommandOptions) - 從每個 Bazel 指令的 @Command 註解
- 從
ConfiguredRuleClassProvider(這些是與個別程式設計語言相關的指令列選項) - Starlark 規則也可以定義自己的選項 (請參閱這裡)
每個選項 (不含 Starlark 定義的選項) 都是具有 @Option 註解的 FragmentOptions 子類別成員變數,可指定指令列選項的名稱和類型,以及一些說明文字。
指令列選項值的 Java 類型通常是簡單的值 (字串、整數、布林值、標籤等)。不過,我們也支援更複雜類型的選項;在這種情況下,從指令列字串轉換為資料類型的作業會落在 com.google.devtools.common.options.Converter 的實作上。
Bazel 看到的原始碼樹狀結構
Bazel 是建構軟體的業務,這項作業是透過讀取及解讀原始碼來完成。Bazel 運作的完整原始碼稱為「工作區」,並以存放區、套件和規則的形式進行結構化。
存放區
「存放區」是開發人員工作的原始碼樹狀結構,通常代表單一專案。Bazel 的祖系 Blaze 會在單一來源檔案庫上運作,也就是單一來源樹狀結構,其中包含用於執行建構作業的所有原始碼。相反地,Bazel 支援原始碼跨越多個存放區的專案。呼叫 Bazel 的存放區稱為「主要存放區」,其他則稱為「外部存放區」。
在根目錄中,會有一個名為 WORKSPACE (或 WORKSPACE.bazel) 的檔案標示存放區。這個檔案包含整個建構作業的「全域」資訊,例如可用的外部存放區集合。其運作方式與一般 Starlark 檔案相同,也就是說,一個檔案可以 load() 其他 Starlark 檔案。這通常用於擷取明確參照的存放區所需的存放區 (我們稱之為「deps.bzl 模式」)
外部存放區的程式碼會在 $OUTPUT_BASE/external 下建立符號連結或下載。
執行建構作業時,整個來源樹狀結構都必須拼湊在一起;這項作業會由 SymlinkForest 執行,該工具會將主存放區中的每個套件連結至 $EXECROOT,並將每個外部存放區連結至 $EXECROOT/external 或 $EXECROOT/.. (前者當然會導致主存放區中無法有名為 external 的套件;這就是我們要從該存放區遷移的原因)
套件
每個存放區都由套件、相關檔案集合和依附元件規格組成。這些項目會由名為 BUILD 或 BUILD.bazel 的檔案指定。如果兩者皆存在,Bazel 會偏好 BUILD.bazel;BUILD 檔案仍會接受的原因是 Bazel 的祖系 Blaze 使用了這個檔案名稱。不過,這項功能已成為常用的路徑片段,特別是在 Windows 上,因為在 Windows 上,檔案名稱不區分大小寫。
套件彼此獨立:套件的 BUILD 檔案變更不會導致其他套件變更。新增或移除 BUILD 檔案_可能_會變更其他套件,因為遞迴式 glob 會在套件邊界停止,因此 BUILD 檔案的存在會停止遞迴。
BUILD 檔案的評估作業稱為「套件載入」。這項功能已在 PackageFactory 類別中實作,其運作方式是呼叫 Starlark 解譯器,並需要瞭解可用的規則類別集。套件載入的結果是 Package 物件。這類別名大多是從字串 (目標名稱) 對應至目標本身。
包裝載入過程中,大部分的複雜性都來自 glob:Bazel 不需要明確列出每個來源檔案,而是可以執行 glob (例如 glob(["**/*.java"]))。與殼層不同,它支援遞迴 glob,可沿著子目錄 (但不進入子包裝) 展開。這項操作需要存取檔案系統,而這可能會造成速度變慢,因此我們會實作各種技巧,讓檔案系統以平行方式運作,並盡可能提高效率。
以下類別實作了 Globbing:
LegacyGlobber,快速且無需 Skyframe 的 globberSkyframeHybridGlobber:使用 Skyframe 的版本,並會回復舊版 globber,以避免「Skyframe 重新啟動」(詳見下文)
Package 類別本身包含一些成員,這些成員專門用於剖析 WORKSPACE 檔案,對實際套件而言並無意義。這是設計上的缺陷,因為描述一般套件的物件不應包含描述其他項目的欄位。包括:
- 存放區對應
- 已註冊的工具鍊
- 已註冊的執行平台
理想情況下,解析 WORKSPACE 檔案與解析一般套件之間應有更明確的區隔,以免 Package 需要同時滿足兩者的需要。很遺憾,這麼做很困難,因為這兩者是密切相關的。
標籤、目標和規則
套件由目標組成,目標有以下類型:
- 檔案:建構作業的輸入或輸出內容。在 Bazel 的用語中,我們稱之為構件 (另文有討論)。並非所有在建構期間建立的檔案都是目標;Bazel 的輸出內容通常不會有相關聯的標籤。
- 規則:這些是從輸入內容衍生輸出內容的步驟。這些元素通常與程式設計語言相關 (例如
cc_library、java_library或py_library),但也有一些與語言無關的元素 (例如genrule或filegroup)。 - 套件群組:請參閱「顯示設定」一節。
目標的名稱稱為「標籤」。標籤的語法為 @repo//pac/kage:name,其中 repo 是標籤所在的存放區名稱、pac/kage 是 BUILD 檔案所在的目錄,而 name 則是相對於套件目錄的檔案路徑 (如果標籤是指來源檔案)。在指令列中參照目標時,可以省略標籤的部分內容:
- 如果省略存放區,系統會將標籤視為位於主要存放區。
- 如果省略套件部分 (例如
name或:name),系統會將標籤視為位於目前工作目錄的套件中 (不允許包含上層參照 (..) 的相對路徑)
規則的類型 (例如「C++ 程式庫」) 稱為「規則類別」。規則類別可在 Starlark (rule() 函式) 或 Java (稱為「原生規則」,類型為 RuleClass) 中實作。長期來說,每個語言專屬規則都會在 Starlark 中實作,但部分舊版規則系列 (例如 Java 或 C++) 目前仍在 Java 中實作。
您必須使用 load() 陳述式,在 BUILD 檔案開頭匯入 Starlark 規則類別,但 Bazel 會「天生」知道 Java 規則類別,因為它已註冊至 ConfiguredRuleClassProvider。
規則類別包含以下資訊:
- 屬性 (例如
srcs、deps):類型、預設值、限制等。 - 附加至每個屬性的設定轉換和面向 (如有)
- 規則的實作
- 規則「通常」會建立的傳遞式資訊提供者
術語說明:在程式碼庫中,我們經常使用「規則」一詞來表示規則類別建立的目標。不過,在 Starlark 和面向使用者的文件中,"Rule" 應專門用於參照規則類別本身;目標只是一個「目標」。另請注意,雖然 RuleClass 名稱中含有「class」,但規則類別與該類型的目標之間並沒有 Java 繼承關係。
Skyframe
Bazel 底層的評估框架稱為 Skyframe。其模型是將建構期間需要建構的所有項目,整理成有向無環圖,其中邊緣會從任何資料指向其相依項目,也就是建構時需要知道的其他資料。
圖表中的節點稱為 SkyValue,其名稱則稱為 SkyKey。這兩者都是不可變動的,且只能從不可變動的物件存取。這個不變量幾乎總是有效,如果不有效 (例如個別選項類別 BuildOptions,這是 BuildConfigurationValue 和其 SkyKey 的成員),我們會盡可能不變更這些類別,或只以無法從外部觀察的方式變更這些類別。因此,在 Skyframe 中計算的所有內容 (例如已設定的目標) 也必須是不可變動的。
觀察 Skyframe 圖表最方便的方法,就是執行 bazel dump
--skyframe=detailed,這個指令會將圖表轉儲,每行一個 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 的快取和遞增運算作業是以 SkyFunction 和 SkyValue 的細微度運作。
每當 SkyFunction 要求無法使用的依附元件時,getValue() 就會傳回 null。接著,函式應透過傳回空值,將控制權交還給 Skyframe。稍後,Skyframe 會評估無法使用的依附元件,然後從頭重新啟動函式。只有這時,getValue() 呼叫才會成功,並傳回非空值。
這會導致在 SkyFunction 中執行的任何運算,在重新啟動前必須重複執行。但這不包括評估依附元件 SkyValues 所做的作業,因為該作業已快取。因此,我們通常會透過以下方式解決這個問題:
- 使用
getValuesAndExceptions()在批次中宣告依附元件,以限制重新啟動次數。 - 將
SkyValue拆解為由不同SkyFunction計算的獨立部分,以便獨立計算及快取。這項操作可能會增加記憶體用量,因此應謹慎執行。 - 在重新啟動之間儲存狀態,使用
SkyFunction.Environment.getState()或在「Skyframe 背後」保留臨時靜態快取。
我們需要這類因應措施,主要是因為我們通常有數十萬個處於飛行中的 Skyframe 節點,而 Java 不支援輕量級執行緒。
Starlark
Starlark 是一種網域專屬語言,可用來設定及擴充 Bazel。它被視為 Python 的受限子集,具有更少的類型、對控制流程的更多限制,以及最重要的強烈不變性保證,可啟用並行讀取。這並非圖靈完備,因此某些 (但非全部) 使用者不太願意使用該語言完成一般程式設計工作。
Starlark 是在 net.starlark.java 套件中實作。這項服務也提供獨立的 Go 實作項目,請參閱這裡。Bazel 目前使用的 Java 實作項目是解譯器。
Starlark 可用於多種情境,包括:
BUILD語言。這裡是定義新規則的地方。在這個情境中執行的 Starlark 程式碼,只能存取BUILD檔案本身的內容,以及該檔案載入的.bzl檔案。- 規則定義。這就是定義新規則 (例如支援新語言) 的方式。在這個情境中執行的 Starlark 程式碼可存取其直接依附元件提供的設定和資料 (稍後會進一步說明)。
- WORKSPACE 檔案。這是定義外部存放區 (不在主要來源樹狀結構中的程式碼) 的位置。
- 存放區規則定義。這裡是定義新外部存放區類型的所在位置。在這個情境中執行的 Starlark 程式碼可以在執行 Bazel 的機器上執行任意程式碼,並且可存取工作區以外的範圍。
BUILD 和 .bzl 檔案可用的方言略有不同,因為它們表示的內容不同。如要查看差異清單,請按這裡。
如要進一步瞭解 Starlark,請參閱這篇文章。
載入/分析階段
在載入/分析階段,Bazel 會判斷建構特定規則所需的動作。其基本單位是「已設定的目標」,也就是 (目標、設定) 組合。
之所以稱為「載入/分析階段」,是因為這項作業可分為兩個不同的部分,過去這兩個部分會以序列化方式執行,但現在則可同時執行:
- 載入套件,也就是將
BUILD檔案轉換為代表這些檔案的Package物件 - 分析已設定的目標,也就是執行規則的實作項目,以產生動作圖表
在指令列上要求的已設定目標的傳遞關聯閉包中,每個已設定目標都必須由下而上分析;也就是先分析葉節點,再分析指令列上的節點。分析單一已設定目標的輸入內容如下:
- 設定。("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)
- 直接依附元件。其傳遞式資訊提供者可供分析的規則使用。之所以稱為「匯總」,是因為這些類別會在已設定目標的傳遞閉包中提供資訊的「匯總」,例如 classpath 中的所有 .jar 檔案,或是需要連結至 C++ 二進位檔的所有 .o 檔案。
- 目標本身。這是載入目標所在套件的結果。對於規則,這包括屬性,而這通常是重要的部分。
- 已設定目標的實作項目。規則可以是 Starlark 或 Java 程式語言。所有非規則設定的目標都會在 Java 中實作。
分析已設定的目標後,輸出內容如下:
- 已設定依附於該資訊的目標,可存取的間接資訊提供者
- 可建立的構件,以及產生構件的動作。
提供給 Java 規則的 API 是 RuleContext,相當於 Starlark 規則的 ctx 引數。它的 API 功能更強大,但同時也更容易發生「壞事」™,例如編寫時間或空間複雜度為二次方 (或更糟) 的程式碼、讓 Bazel 伺服器因 Java 例外狀況而當機,或是違反不變量 (例如不小心修改 Options 例項,或是讓已設定的目標變得可變)。
決定已設定目標的直接依附元件的演算法位於 DependencyResolver.dependentNodeMap() 中。
設定
設定是指建構目標的「方式」:針對哪個平台、使用哪些指令列選項等等。
同一個版本可為多個設定建構相同的目標。這在下列情況下很實用:當我們在交叉編譯時,使用相同的程式碼為建構期間執行的工具和目標程式碼,或是建構大型 Android 應用程式 (包含多個 CPU 架構的原生程式碼)
從概念上來說,設定就是 BuildOptions 例項。不過,在實際情況中,BuildOptions 會由 BuildConfiguration 包裝,提供其他各式各樣的功能。從依附元件圖表頂端傳播至底部。如果變更,則需要重新分析建構。
這會導致異常狀況,例如如果要求的測試執行次數有所變更,就必須重新分析整個建構作業,即使這只會影響測試目標也是如此 (我們已規劃要「修剪」設定,以免發生這種情況,但這項功能尚未就緒)。
當規則實作需要部分設定時,就必須使用 RuleClass.Builder.requiresConfigurationFragments() 在其定義中宣告該部分。這麼做可以避免錯誤 (例如使用 Java 片段的 Python 規則),並便於裁減設定,以便在 Python 選項變更時,不必重新分析 C++ 目標。
規則的設定不一定與「父項」規則相同。在依附邊中變更設定的程序稱為「設定轉換」。這可能發生在兩個地方:
- 在依附元件邊緣上。這些轉換會在
Attribute.Builder.cfg()中指定,並從Rule(轉換發生的位置) 和BuildOptions(原始設定) 傳遞至一或多個BuildOptions(輸出設定)。 - 在任何已設定目標的傳入邊上。這些項目會在
RuleClass.Builder.cfg()中指定。
相關的類別為 TransitionFactory 和 ConfigurationTransition。
使用設定轉換,例如:
- 宣告在建構期間使用特定依附元件,因此應在執行架構中建構
- 如要宣告特定依附元件必須針對多個架構進行建構 (例如針對肥胖 Android APK 中的原生程式碼)
如果設定轉換會產生多個設定,就稱為分割轉換。
設定轉換也可以在 Starlark 中實作 (說明文件請見此處)
推導資訊供應者
傳遞式資訊提供者是一種方法 (也是唯一的方法),可讓已設定的目標告知其他依附於該目標的已設定目標。名稱中含有「transitive」的原因是,這通常是某種已設定目標的傳遞閉包匯總。
Java 傳遞式資訊提供者和 Starlark 傳遞式資訊提供者之間通常會 1:1 對應 (例外狀況是 DefaultInfo,這是 FileProvider、FilesToRunProvider 和 RunfilesProvider 的合併結果,因為該 API 被視為更偏向 Starlark,而非 Java 的直接轉寫)。其鍵為下列其中一種:
- Java 類別物件。這項功能僅適用於無法透過 Starlark 存取的供應商。這些提供者是
TransitiveInfoProvider的子類別。 - 字串。這是舊版做法,且極不建議使用,因為容易發生名稱衝突。這類傳遞資訊提供者是
build.lib.packages.Info的直接子類別。 - 供應商符號。您可以使用
provider()函式,透過 Starlark 建立這個項目,這也是建立新供應者的建議方式。符號由 Java 中的Provider.Key例項表示。
以 Java 實作的全新供應器應使用 BuiltinProvider 實作。NativeProvider 已淘汰 (我們尚未有時間移除),且無法從 Starlark 存取 TransitiveInfoProvider 子類別。
已設定的目標
已設定的目標會以 RuleConfiguredTargetFactory 的形式實作。每個在 Java 中實作的規則類別都有一個子類別。Starlark 設定的目標會透過 StarlarkRuleConfiguredTargetUtil.buildRule() 建立。
已設定的目標工廠應使用 RuleConfiguredTargetBuilder 建構其傳回值。其中包含下列內容:
filesToBuild,這個模糊的概念是「這個規則所代表的檔案集合」。這些是指令列或 genrule 的 srcs 中設定目標時,所建構的檔案。- 其執行檔、規則和資料。
- 輸出群組。這些是規則可建構的各種「其他檔案組合」。您可以使用 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 樹狀結構的序列化說明。它用於執行檔案樹狀目錄的內容代理程式,而 Bazel 會假設只有在資訊清單內容變更時,執行檔案樹狀目錄才會變更。
- 輸出 runfiles 資訊清單。這個屬性會由處理執行檔樹狀結構的執行階段程式庫使用,特別是在 Windows 上,因為 Windows 有時不支援符號連結。
- runfiles 中介。為了讓 runfiles 樹狀結構存在,您必須建立符號連結樹狀結構和符號連結所指向的構件。為了減少依附元件邊緣的數量,可以使用 runfiles 中介軟體來代表所有這些邊緣。
- 用於執行
RunfilesSupport物件所代表的執行檔的指令列引數。
切面
面向切片是一種「沿著依附元件圖表傳播運算」的方式。如要瞭解這些選項,請參閱這篇文章,瞭解 Bazel 使用者如何使用這些選項。舉例來說,協定方塊是個不錯的動機:proto_library 規則不應瞭解任何特定語言,但在任何程式設計語言中建構協定方塊訊息 (協定方塊的「基本單位」) 的實作項目時,應與 proto_library 規則耦合,這樣如果同一種語言中的兩個目標都依附於同一個協定方塊,就只會建構一次。
就像已設定的目標一樣,這些目標在 Skyframe 中會以 SkyValue 表示,且建構方式與已設定的目標相似:它們有一個名為 ConfiguredAspectFactory 的工廠類別,可存取 RuleContext,但與已設定的目標工廠不同的是,它也知道所附加的已設定目標及其供應者。
使用 Attribute.Builder.aspects() 函式,為每個屬性指定沿著依附元件圖表傳播的面向集。以下是幾個名稱容易混淆的類別,這些類別會參與這項程序:
AspectClass是實作面向。它可以位於 Java 中 (此時為子類別),也可以位於 Starlark 中 (此時為StarlarkAspectClass的例項)。它類似於RuleConfiguredTargetFactory。AspectDefinition是層面的定義,其中包含所需的供應器、提供的供應器,以及實作項目的參照,例如適當的AspectClass例項。這與RuleClass類似。AspectParameters是一種將向下傳播至依附元件關係圖的層面參數化的做法。目前是字串對字串對應。通訊協定緩衝區就是一個很好的例子,說明為何這項功能實用:如果某種語言有好幾個 API,則應為哪個 API 建構通訊協定緩衝區的資訊,應沿著依附元件圖表傳播。Aspect代表計算沿著相依關係圖傳播的層面所需的所有資料。其中包含面向的類別、定義和參數。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++ 編譯器 (工具鍊說明文件請見此處)。
為此,工具鍊會附註其支援的執行和目標平台限制組合。為此,工具鍊的定義會分為兩個部分:
toolchain()規則:描述工具鍊支援的執行和目標限制組合,並指出工具鍊的類型 (例如 C++ 或 Java) (後者由toolchain_type()規則表示)- 描述實際工具鍊 (例如
cc_toolchain()) 的語言特定規則
我們之所以採取這種做法,是因為我們需要知道每個工具鍊的限制條件,才能執行工具鍊解析作業,而特定語言的 *_toolchain() 規則包含比這項作業更多資訊,因此需要較長的時間才能載入。
執行平台可透過下列任一方式指定:
- 在使用
register_execution_platforms()函式的 WORKSPACE 檔案中 - 在指令列上使用 --extra_execution_platforms 指令列選項
可用的執行平台集合會在 RegisteredExecutionPlatformsFunction 中計算。
系統會根據 PlatformOptions.computeTargetPlatform() 決定已設定目標的目標平台。我們最終希望支援多個目標平台,但目前尚未實作,因此列出這些平台。
ToolchainResolutionFunction 會決定要用於已設定目標的工具鍊組合。它是以下函式:
- 已註冊的工具鍊組合 (位於 WORKSPACE 檔案和設定中)
- 所需的執行和目標平台 (在設定中)
- 已設定目標 (在
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() 規則的例項。
您可以透過多種方式為規則指定支援的環境:
- 透過
restricted_to=屬性。這是最直接的規格形式,可宣告規則支援此群組的確切環境組合。 - 透過
compatible_with=屬性。除了預設支援的「標準」環境外,這會宣告規則支援的環境。 - 透過套件層級屬性
default_restricted_to=和default_compatible_with=。 - 透過
environment_group()規則中的預設規格。每個環境都屬於一組主題相關的對等環境 (例如「CPU 架構」、「JDK 版本」或「行動作業系統」)。如果未透過restricted_to=/environment()屬性指定,環境群組定義會包含「預設」應支援的環境。沒有這些屬性的規則會繼承所有預設值。 - 透過規則類別預設值。這會覆寫指定規則類別的所有例項全域預設值。例如,您可以使用這項功能讓所有
*_test規則皆可測試,而不需要每個例項都明確宣告這項功能。
environment() 是以一般規則實作,而 environment_group() 是 Target 的子類別 (但不是 Rule (EnvironmentGroup)),也是 Starlark (StarlarkLibrary.environmentGroup()) 預設可用的函式,最終會建立同名目標。這是為了避免循環相依性,因為每個環境都需要宣告所屬的環境群組,而每個環境群組都需要宣告其預設環境。
您可以使用 --target_environment 指令列選項,將建構作業限制在特定環境中。
限制檢查的實作項目位於 RuleContextConstraintSemantics 和 TopLevelConstraintSemantics 中。
平台限制
目前的「官方」方式是使用用於描述工具鍊和平台的相同限制,說明目標與哪些平台相容。我們正在審查提取要求 #10945。
顯示設定
如果您要處理由許多開發人員 (例如 Google) 共同維護的大型程式碼集,請務必小心,避免其他人任意依賴您的程式碼。否則,根據Hyrum 定律,使用者會依賴您認為是實作細節的行為。
Bazel 透過稱為「可見度」的機制支援此功能:您可以使用「可見度」屬性宣告特定目標只能依附於特定目標。這個屬性有點特別,因為雖然它會保留標籤清單,但這些標籤可能會對套件名稱編碼,而非指向任何特定目標的指標。(是的,這是設計上的缺陷)。
這項功能會在以下位置實作:
RuleVisibility介面代表可見度宣告。可以是常數 (完全公開或完全私密),也可以是標籤清單。- 標籤可以參照套件群組 (預先定義的套件清單)、直接參照套件 (
//pkg:__pkg__) 或套件的子集區 (//pkg:__subpackages__)。這與使用//pkg:*或//pkg/...的指令列語法不同。 - 套件群組會以專屬目標 (
PackageGroup) 和設定目標 (PackageGroupConfiguredTarget) 的形式實作。如果需要,我們可以用簡單的規則取代這些項目。這些邏輯的實作方式如下:PackageSpecification對應至//pkg/...等單一模式;PackageGroupContents對應至單一package_group的packages屬性;PackageSpecificationProvider則會匯總package_group和其傳遞的includes。 - 從顯示標籤清單轉換為依附元件是在
DependencyResolver.visitTargetVisibility和其他一些雜項位置完成。 - 實際的檢查是在
CommonPrerequisiteValidator.validateDirectPrerequisiteVisibility()中完成
巢狀集合
通常,已設定的目標會匯總其依附元件的一組檔案、新增自己的檔案,並將匯總集合包裝成轉接資訊提供者,以便依附於該目標的已設定目標也能執行相同操作。範例:
- 用於建構作業的 C++ 標頭檔案
- 代表
cc_library傳遞閉包的物件檔案 - 需要放在 classpath 的 .jar 檔案集合,以便 Java 規則編譯或執行
- Python 規則的傳遞閉包中的 Python 檔案集
如果我們以簡單的方式執行這項操作,例如使用 List 或 Set,最終會造成二次方記憶體用量:如果有 N 個規則鏈結,且每個規則都會新增一個檔案,那麼我們就會有 1+2+...+N 個集合成員。
為瞭解決這個問題,我們提出了 NestedSet 的概念。這是由其他 NestedSet 例項和自身的部分成員組成的資料結構,因此可形成集合的有向非循環圖。它們是不可變動的,且成員可進行疊代。我們定義了多個疊代順序 (NestedSet.Order):前序、後序、拓樸 (節點一律會出現在其祖系之後) 和「不指定,但每次都應相同」。
同樣的資料結構在 Starlark 中稱為 depset。
構件和動作
實際的建構作業包含一組需要執行的指令,才能產生使用者想要的輸出內容。指令會以 Action 類別的例項表示,檔案則會以 Artifact 類別的例項表示。這些動作會以「動作圖」的形式排列在二元有向無環圖中。
構件分為兩種:來源構件 (在 Bazel 開始執行前可用的構件) 和衍生構件 (需要建構的構件)。衍生構件本身可以是多種類型:
- **一般成果物。**這些檔案會透過計算其總和檢查是否為最新版本,並以 mtime 做為捷徑;如果檔案的 ctime 未變更,我們就不會計算總和。
- 未解析的符號連結構件。系統會透過呼叫 readlink() 來檢查這些項目是否為最新版本。與一般構件不同,這些項目可能是懸空符號連結。通常用於將某些檔案打包成某種封存檔。
- 樹狀圖成果。這些不是單一檔案,而是目錄樹狀結構。系統會檢查其中的檔案組合及其內容,以確認這些檔案是否為最新版本。以
TreeArtifact表示。 - 常數中繼資料構件。這些構件發生變更時不會觸發重建作業。這項資訊僅用於建構戳記資訊:我們不希望因為目前時間變更就重新建構。
來源構件無法是樹狀結構構件或未解析的符號連結構件,並沒有任何根本原因,只是我們尚未實作 (不過,我們應該要實作 -- 在 BUILD 檔案中參照來源目錄是 Bazel 少數已知的長期不正確問題之一;我們有一種可透過 BAZEL_TRACK_SOURCE_DIRECTORIES=1 JVM 屬性啟用的實作方式,可說是有效的)
Artifact 的一種常見類型是中介者。這些值由 Artifact 例項表示,而這些例項是 MiddlemanAction 的輸出內容。這些值用於處理某些特殊情況:
- 匯總中介服務用於將構件分組。這樣一來,如果許多動作都使用相同的大量輸入內容,我們就不會擁有 N*M 個依附元件邊緣,只有 N+M 個 (這些會以巢狀集合取代)
- 安排依附元件中介服務,確保某個動作會在另一個動作之前執行。這些函式主要用於 linting,但也用於 C++ 編譯 (請參閱
CcCompilationContext.createMiddleman()瞭解詳情) - 執行檔案中介服務可確保執行檔案樹狀目錄的存在,因此不需要個別依賴輸出資訊清單和執行檔案樹狀目錄參照的每個構件。
動作最適合用來表示需要執行的指令、所需的環境,以及產生的輸出內容。以下是動作說明的主要元件:
- 需要執行的指令列
- 所需的輸入構件
- 需要設定的環境變數
- 註解:描述需要執行的環境 (例如平台)
還有一些其他特殊情況,例如寫入 Bazel 已知內容的檔案。這些是 AbstractAction 的子類別。大部分的動作都是 SpawnAction 或 StarlarkAction (相同,不應是個別的類別),但 Java 和 C++ 有各自的動作類型 (JavaCompileAction、CppCompileAction 和 CppLinkAction)。
我們最終希望將所有內容都移至 SpawnAction;JavaCompileAction 非常接近,但由於 .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 中重新執行動作,仍可在本機動作快取中命中。它代表本機檔案系統的狀態,並且會序列化至磁碟,也就是說,當您啟動新的 Bazel 伺服器時,即使 Skyframe 圖表為空白,您仍可取得本機動作快取命中。
系統會使用 ActionCacheChecker.getTokenIfNeedToExecute() 方法檢查這個快取是否有命中。
與名稱相反,這項作業是從衍生成果的路徑,對應至產生該成果的動作。該動作的說明如下:
- 輸入和輸出檔案集合及其總和檢查碼
- 其「動作鍵」通常是執行的命令列,但一般來說,它代表輸入檔案的總和檢查碼未擷取的所有內容 (例如
FileWriteAction,它是寫入資料的總和檢查碼)
此外,我們也正在開發一項實驗性質的「自上而下的動作快取」,這項功能會使用中介雜湊值,避免多次存取快取。
輸入探索和輸入裁剪
有些動作不只是一組輸入內容,動作輸入集合的變更有兩種形式:
- 動作可能會在執行前發現新的輸入內容,或決定部分輸入內容其實並非必要。標準範例是 C++,在這種情況下,建議您根據 C++ 檔案的傳遞閉包,根據經驗判斷檔案使用的標頭檔案,這樣就不必將每個檔案都傳送至遠端執行者;因此,我們可以選擇不將每個標頭檔案都註冊為「輸入」檔案,而是掃描來源檔案,找出傳遞式包含的標頭,並只將這些標頭檔案標示為
#include陳述式中提及的輸入檔案 (我們會高估,因此不需要實作完整的 C 前置處理器)。這個選項目前在 Bazel 中硬連結為「false」,且僅供 Google 使用。 - 動作可能會發現在執行期間未使用某些檔案。在 C++ 中,這稱為「.d 檔案」:編譯器會在事後指出使用了哪些標頭檔案,為了避免比 Make 更糟糕的增量,Bazel 會利用這項事實。這比依賴編譯器的納入掃描器提供更準確的估計值。
這些功能是使用 Action 上的函式實作:
- 系統會呼叫
Action.discoverInputs()。應傳回一組判定為必要的巢狀構件。這些必須是來源構件,這樣在動作圖中就不會出現沒有對應項目的依附元件邊緣。 - 透過呼叫
Action.execute()執行動作。 - 在
Action.execute()結尾,動作可以呼叫Action.updateInputs(),告訴 Bazel 並非所有輸入都需要。如果已使用的輸入內容遭到回報為未使用,這可能會導致不正確的增量建構作業。
當動作快取在新的動作例項 (例如在伺服器重新啟動後建立) 中傳回命中時,Bazel 會呼叫 updateInputs() 本身,讓輸入集反映先前執行的輸入內容探索和修剪結果。
Starlark 動作可以使用此功能,透過 ctx.actions.run() 的 unused_inputs_list= 引數,將部分輸入內容宣告為未使用。
執行動作的多種方式:策略/ActionContext
部分動作可透過不同方式執行。舉例來說,指令列可在本機執行、在本機的各種沙箱中執行,或在遠端執行。這項概念稱為 ActionContext (或 Strategy,因為我們只成功將名稱改半...)
動作內容的生命週期如下:
- 執行階段開始時,系統會詢問
BlazeModule例項的動作背景資訊。這會發生在ExecutionTool的建構函式中。動作內容類型是由 JavaClass例項識別,該例項會參照ActionContext的子介面,以及動作內容必須實作的介面。 - 系統會從可用的動作內容中選取適當的動作內容,並轉送至
ActionExecutionContext和BlazeExecutor。 - 動作要求使用
ActionExecutionContext.getContext()和BlazeExecutor.getStrategy()的內容 (其實應該只有一種方法可以執行這項操作…)
策略可以自由呼叫其他策略來執行其工作;例如,在動態策略中,同時在本機和遠端啟動動作,然後使用先完成的動作。
其中一個值得注意的策略是實作持續性工作站程序 (WorkerSpawnStrategy)。這個概念是,某些工具的啟動時間很長,因此應在動作之間重複使用,而非為每個動作重新啟動 (這確實代表潛在的正確性問題,因為 Bazel 依賴 worker 程序的承諾,即不會在個別要求之間攜帶可觀察的狀態)
如果工具有所變更,就必須重新啟動 worker 程序。系統會使用 WorkerFilesHash 為所用工具計算檢查和,判斷 worker 是否可重複使用。這項作業需要知道哪些動作輸入內容代表工具的一部分,哪些代表輸入內容;這項作業由動作建立者決定:Spawn.getToolFiles() 和 Spawn 的執行檔會計為工具的一部分。
進一步瞭解策略 (或動作情境):
本機資源管理員
Bazel 可以同時執行多個動作。應平行執行的本機動作數量因動作而異:動作所需資源越多,同時執行的執行個體就應越少,以免本機電腦超載。
這項功能已在 ResourceManager 類別中實作:每個動作都必須以 ResourceSet 例項 (CPU 和 RAM) 的形式,標註預估所需的本機資源。接著,當動作內容需要本機資源時,就會呼叫 ResourceManager.acquireResources(),並在所需資源可用之前保持封鎖狀態。
如要進一步瞭解本機資源管理,請參閱這篇文章。
輸出目錄的結構
每個動作都需要在輸出目錄中提供個別位置,用於放置輸出內容。衍生成果的位置通常如下:
$EXECROOT/bazel-out/<configuration>/bin/<package>/<artifact name>
如何判斷與特定設定相關聯的目錄名稱?有兩個互相衝突的理想房源屬性:
- 如果同一個建構作業中可以發生兩個設定,則兩者應具有不同的目錄,以便兩者都能擁有相同動作的專屬版本;否則,如果兩個設定不一致,例如產生相同輸出檔案的動作指令列,Bazel 就無法判斷要選擇哪個動作 (「動作衝突」)。
- 如果兩個設定代表「大致」相同的內容,則應使用相同的名稱,這樣在命令列相符的情況下,在其中一個設定中執行的動作就能重複使用於另一個設定:舉例來說,對 Java 編譯器的命令列選項進行變更,不應導致 C++ 編譯動作重新執行。
到目前為止,我們尚未找到解決這個問題的原則性方法,這與設定裁減問題相似。如要進一步瞭解這些選項,請參閱這篇文章。主要的問題領域是 Starlark 規則 (作者通常不熟悉 Bazel) 和面向,這些會為產生「相同」輸出檔案的空間增添另一個維度。
目前的方法是將設定的路徑區段設為 <CPU>-<compilation mode>,並加上各種後置詞,以便在 Java 中實作的設定轉換不會導致動作衝突。此外,系統會新增一組 Starlark 設定轉換的總和檢查碼,以免使用者造成動作衝突。這項功能仍有許多不完美之處。這項功能已在 OutputDirectories.buildMnemonic() 中實作,且需要每個設定片段在輸出目錄名稱中加入自己的部分。
測試命名空間
Bazel 提供豐富的測試執行支援功能。支援以下功能:
- 從遠端執行測試 (如果有可用的遠端執行後端)
- 並行執行多次測試 (用於解除壓縮或收集時間資料)
- 分割測試 (將同一個測試中的測試案例分割至多個程序,以提高速度)
- 重新執行不穩定測試
- 將測試分組為測試套件
測試是具有 TestProvider 的一般設定目標,可說明應如何執行測試:
- 建構結果會導致測試執行的構件。這是一個「快取狀態」檔案,其中包含序列化的
TestResultData訊息 - 測試應執行的次數
- 測試應分割成多少個分割區
- 與測試執行方式相關的部分參數 (例如測試逾時時間)
決定要執行哪些測試
決定要執行哪些測試是一項複雜的程序。
首先,在目標模式剖析期間,測試套件會遞迴展開。擴充功能已在 TestsForTargetPatternFunction 中實作。有點令人意外的是,如果測試套件未宣告任何測試,則會參照套件中的「每項」測試。這項功能是在 Package.beforeBuild() 中實作,方法是將名為 $implicit_tests 的隱含屬性新增至測試套件規則。
接著,系統會根據指令列選項,篩選大小、標記、逾時和語言的測試。這會在 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 叫用執行,要求在「main」版本後執行測試。這可透過 SkyframeExecutor.runExclusiveTest() 實作。
與一般動作不同,一般動作會在動作結束時傾印終端機輸出內容,但使用者可以要求串流傳輸測試輸出內容,以便瞭解長時間執行的測試進度。這項功能由 --test_output=streamed 指令列選項指定,並暗示執行專屬測試,以免不同測試的輸出內容混雜。
這項功能已在名稱恰當的 StreamedTestOutput 類別中實作,其運作方式是輪詢所需測試的 test.log 檔案變更,並將新的位元組轉儲至 Bazel 規則的終端機。
執行測試的結果會透過觀察各種事件 (例如 TestAttempt、TestResult 或 TestingCompleteEvent) 顯示在事件匯流中。這些結果會轉儲至建構事件通訊協定,並由 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 的子類別。
某些查詢輸出格式 (絕對是 proto) 的微妙要求是,Bazel 需要傳送套件載入提供的「所有」資訊,以便比較輸出內容,並判斷特定目標是否已變更。因此,屬性值必須可序列化,這也是為何只有少數屬性類型沒有任何屬性含有複雜的 Starlark 值。一般來說,解決方法是使用標籤,並將複雜資訊附加至該標籤的規則。這並不是令人滿意的解決方法,如果能取消這項規定就太好了。
模組系統
您可以新增模組來擴充 Bazel。每個模組都必須是 BlazeModule 的子類別 (這個名稱是 Bazel 歷史的遺跡,當時 Bazel 的名稱是 Blaze),並在執行指令時取得各種事件的相關資訊。
這些函式主要用於實作各種「非核心」功能,只有部分版本的 Bazel (例如我們在 Google 使用的版本) 需要這些函式:
- 遠端執行系統的介面
- 新的指令
BlazeModule 提供的擴充功能點集合有點雜亂。請勿將其視為良好設計原則的範例。
事件匯流
BlazeModules 與其他 Bazel 元件的主要通訊方式是透過事件匯流 (EventBus):每個版本都會建立新的例項,Bazel 的各個部分都可以將事件發布至此,而模組可以為感興趣的事件註冊事件監聽器。舉例來說,下列項目會以事件表示:
- 已決定要建構的建構目標清單 (
TargetParsingCompleteEvent) - 已決定頂層設定 (
BuildConfigurationEvent) - 是否已成功建構目標 (
TargetCompleteEvent) - 已執行測試 (
TestAttempt、TestSummary)
其中部分事件會在 Bazel 外部以「Build Event Protocol」呈現 (這些是 BuildEvent)。這不僅可讓 BlazeModule 觀察建構作業,也能讓 Bazel 程序以外的項目觀察建構作業。您可以透過包含通訊協定訊息的檔案存取這些事件,也可以讓 Bazel 連線至伺服器 (稱為 Build Event Service),以便串流傳輸事件。
這會在 build.lib.buildeventservice 和 build.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> 下建立目錄。
擷取存放區的步驟如下:
PackageLookupFunction會瞭解自己需要存放區,並建立RepositoryName做為SkyKey,以便叫用RepositoryLoaderFunctionRepositoryLoaderFunction會將要求轉送至RepositoryDelegatorFunction,但原因不明 (程式碼指出,這是為了避免在 Skyframe 重新啟動時重新下載內容,但這並非很充分的理由)RepositoryDelegatorFunction會依序檢查 WORKSPACE 檔案的區塊,直到找到要求的存放區為止,藉此找出系統要求擷取的存放區規則- 系統會找到實作存放區擷取作業的適當
RepositoryFunction,可能是存放區的 Starlark 實作項目,或是以 Java 實作的存放區硬式編碼對應項目。
由於擷取存放區的成本可能非常高,因此我們提供多層快取:
- 下載的檔案會使用快取,並以核對和 (
RepositoryCache) 做為索引。這項功能需要在 WORKSPACE 檔案中提供核對和,但這對密封性來說是有利的做法。無論執行的是以哪個工作區或輸出基礎,這個值都會由同一個工作站上的每個 Bazel 伺服器例項共用。 - 系統會為
$OUTPUT_BASE/external下方的每個存放區寫入「標記檔案」,其中包含用於擷取該存放區的規則的總和檢查碼。如果 Bazel 伺服器重新啟動,但總和檢查碼未變更,則不會重新擷取。這會在RepositoryDelegatorFunction.DigestWriter中實作。 --distdir指令列選項會指定另一個快取,用於查詢要下載的構件。這在企業設定中非常實用,因為 Bazel 不應從網路隨機擷取內容。這項功能是由DownloadManager實作。
下載存放區後,其中的構件會視為來源構件。這會造成問題,因為 Bazel 通常會透過呼叫 stat() 來檢查來源構件是否為最新版本,而當這些構件所在的存放區定義發生變更時,這些構件也會失效。因此,外部存放區中構件的 FileStateValue 必須依附外部存放區。這個作業是由 ExternalFilesHelper 負責。
受管理的目錄
有時,外部存放區需要修改工作區根目錄下的檔案 (例如在來源樹狀結構的子目錄中安置下載的套件)。這與 Bazel 的假設相牴觸,因為 Bazel 假設只有使用者可以修改原始檔案,而 Bazel 本身不會修改,且允許套件參照工作區根目錄下的每個目錄。為了讓這類外部存放區正常運作,Bazel 會執行以下兩項操作:
- 允許使用者指定 Bazel 無法存取的工作區子目錄。這些依附元件會列在名為
.bazelignore的檔案中,而功能則在BlacklistedPackagePrefixesFunction中實作。 - 我們會將從工作區的子目錄到由其處理的外部存放區的對應項目編碼至
ManagedDirectoriesKnowledge,並以與一般外部存放區相同的方式處理參照這些項目的FileStateValue。
存放區對應
有時,多個存放區會想依附相同的存放區,但使用不同的版本 (這是「菱形依附元件問題」的例子)。舉例來說,如果建構中不同存放區中的兩個二進位檔都想依附 Guava,那麼兩者都會以標籤參照 Guava,且標籤會以 @guava// 開頭,並預期代表不同的版本。
因此,Bazel 允許重新對應外部存放區標籤,讓字串 @guava// 可以參照某個二進位檔存放區中的一個 Guava 存放區 (例如 @guava1//),以及另一個二進位檔存放區中的另一個 Guava 存放區 (例如 @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 類別如下:
NativePosixFiles和NativePosixFileSystemProcessUtilsWindowsFileOperations和WindowsFileProcessescom.google.devtools.build.lib.platform
控制台輸出內容
發出主控台輸出內容看似簡單,但由於同時執行多個程序 (有時是遠端執行)、精細快取、希望提供精美且色彩繽紛的終端機輸出內容,以及擁有長時間執行的伺服器,因此這項工作並非易事。
在用戶端傳入 RPC 呼叫後,系統會建立兩個 RpcOutputStream 例項 (用於 stdout 和 stderr),將印入其中的資料轉送至用戶端。然後將這些內容包裝在 OutErr (stdout、stderr 組合) 中。任何需要在主控台上顯示的內容都會經過這些串流。然後將這些串流交給 BlazeCommandDispatcher.execExclusively()。
根據預設,輸出內容會以 ANSI 轉義序列列印。如果不希望使用這些值 (--color=no),則會由 AnsiStrippingOutputStream 移除。此外,System.out 和 System.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 的測試,以及只執行分析階段的測試。我們將前者稱為「整合測試」,後者稱為「單元測試」,雖然兩者更像是整合程度較低的整合測試。我們也有一些實際的單元測試,可在必要時使用。
我們有兩種整合測試:
- 在
src/test/shell下使用非常精細的 bash 測試架構實作的測試 - 以 Java 實作的函式。這些類別會實作為
BuildIntegrationTestCase的子類別
BuildIntegrationTestCase 是首選的整合測試架構,因為它可支援大多數的測試情境。由於這是 Java 框架,因此可提供偵錯功能,並與許多常見的開發工具完美整合。Bazel 存放區中有很多 BuildIntegrationTestCase 類別範例。
分析測試會以 BuildViewTestCase 的子類別實作。您可以使用暫存檔案系統來編寫 BUILD 檔案,然後各種輔助方法可要求已設定的目標、變更設定,並斷言分析結果的各種內容。