本文件說明程式碼集和 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 在建構過程中會建立一組較為複雜的目錄。如需完整說明,請參閱「輸出目錄版面配置」。
「工作區」是在 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()
之前,您可以修改 FragmentOptions
。)
Bazel 透過下列方式學習選項類別:
- 其中一些是硬連線至 Bazel (
CommonCommandOptions
) - 從每個 Bazel 指令的 @Command 註解
- 從
ConfiguredRuleClassProvider
(這些是與個別程式設計語言相關的指令列選項) - Starlark 規則也可以定義自己的選項 (請參閱這裡)
每個選項 (Starlark 定義選項除外) 都是 FragmentOptions
子類別中具有 @Option
註解的成員變數,這個註解可指定指令列選項的名稱、指令列選項類型以及一些說明文字。
指令列選項的值 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/external
或 $EXECROOT/..
(前者當然會導致主存放區中無法有名為 external
的套件;這就是我們要從中遷移的原因)$EXECROOT
套件
每個存放區都由套件、相關檔案集合和依附元件規格組成。這些項目會由名為 BUILD
或 BUILD.bazel
的檔案指定。如果兩者皆存在,Bazel 偏好使用 BUILD.bazel
;而 Bazel 的祖系 Blaze 是系統仍接受 BUILD
檔案的原因,才會使用這個檔案名稱。然而,結果被認為是常用的路徑區段,在 Windows 上更是如此,因為檔案名稱不區分大小寫。
套件彼此獨立:套件的 BUILD
檔案變更不會導致其他套件變更。新增或移除 BUILD
檔案_可能_會變更其他套件,因為遞迴式 glob 會在套件邊界停止,因此 BUILD
檔案的存在會停止遞迴。
BUILD
檔案的評估作業稱為「套件載入」。這項功能已在 PackageFactory
類別中實作,其運作方式是呼叫 Starlark 解譯器,並需要瞭解可用的規則類別集。套件載入的結果是 Package
物件。這類別名大多是從字串 (目標名稱) 對應至目標本身。
包裝載入過程中,大部分的複雜性都來自 glob:Bazel 不需要明確列出每個來源檔案,而是可以執行 glob (例如 glob(["**/*.java"])
)。與殼層不同,它支援遞迴 glob,可沿著子目錄 (但不進入子包裝) 展開。這需要存取檔案系統,而且速度可能很慢,因此我們會實作各種技巧,盡可能平行且有效率地同時執行系統。
以下類別會實作繪圖操作:
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 中。
Starlark 規則類別必須使用 load()
陳述式在 BUILD
檔案的開頭匯入,而 Java 規則類別「自然」是由 Bazel 所知,因為已透過 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 中實作 (說明文件請見此處)
推導資訊供應者
傳遞式資訊提供者是一種方法 (也是唯一的方法),可讓已設定的目標告知其他依附於該目標的已設定目標。之所以名稱是「遞移」,是因為其名稱通常是設定目標的遞移性關閉作業。
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
複雜一點,原因有二:
- 在大多數情況下,檔案的執行檔案路徑與 execpath 相同。我們會使用這個方法來節省一些 RAM。
- 執行檔樹狀結構中含有各種舊版項目,這些項目也需要呈現。
系統會使用 RunfilesProvider
收集執行檔:這個類別的例項代表已設定目標 (例如程式庫) 的執行檔,以及其間接關閉需求,這些執行檔會像巢狀集合一樣收集 (實際上,這些執行檔是使用巢狀集合在封面下實作):每個目標都會將其依附元件的執行檔合併,並新增一些自己的執行檔,然後將產生的集合向上傳送至依附元件圖表。RunfilesProvider
執行個體包含兩個 Runfiles
例項,一個使用「data」屬性做為規則依據時,另一個則適用於其他各種傳入依附元件。這是因為目標在透過資料屬性依附時,有時會顯示不同的執行檔,而非其他方式。這是不必要的舊版行為,我們尚未移除。
二進位檔的執行檔案會表示為 RunfilesSupport
的執行個體。這與 Runfiles
不同,因為 RunfilesSupport
具有實際建構的功能 (而 Runfiles
只是對應)。這需要下列額外元件:
- 輸入執行檔案資訊清單。此為執行檔案樹狀結構的序列化說明。它用於執行檔案樹狀目錄的內容代理程式,而 Bazel 會假設只有在資訊清單內容變更時,執行檔案樹狀目錄才會變更。
- 輸出執行檔案資訊清單。這項屬性由處理執行檔樹狀結構的執行階段程式庫使用,特別是在 Windows 上,因為 Windows 有時不支援符號連結。
- 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()
規則包含比這項資訊更多的資訊,因此需要較長的時間才能載入。
執行平台可透過下列任一方式指定:
- 在 WORKSPACE 檔案中,使用
register_execution_platforms()
函式 - 在指令列上使用 --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
):preorder、 postorder、topological (節點一律位於其祖系之後) 和「Don't Care, but it 每次都應相同」。
相同的資料結構在 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
的叫用表示。為了減少 SkyFrame 邊緣的數量,動作圖表依附元件邊緣與 SkyFrame 依附元件邊緣的對應說明在 ActionExecutionFunction.getInputDeps()
和 Artifact.key()
中所述。此外,還有幾項最佳化調整:
- 衍生成果沒有自己的
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()
方法檢查這個快取是否有命中。
與名稱相反,這是從衍生成果的路徑到產生該成果的動作的對應項目。該動作的說明如下:
- 輸入和輸出檔案集合及其總和檢查碼
- 其「動作鍵」通常是執行的命令列,但一般來說,它代表輸入檔案的總和檢查碼未擷取的所有內容 (例如
FileWriteAction
,它是寫入資料的總和檢查碼)
此外,我們也正在開發一項高度實驗性的「自上而下的動作快取」,這項功能會使用傳遞式雜湊,避免多次存取快取。
輸入探索和輸入裁剪
有些動作不只是一組輸入內容,動作的輸入組合可進行兩種變更:
- 動作可能會在執行之前發現新的輸入內容,或判斷其部分輸入內容實際上不是必要項目。標準範例是 C++,在這種情況下,建議您根據 C++ 檔案的傳遞閉包,根據經驗判斷檔案使用的標頭檔案,這樣就不必將每個檔案都傳送至遠端執行者;因此,我們可以選擇不將每個標頭檔案都註冊為「輸入」檔案,而是掃描來源檔案,找出傳遞式納入的標頭,並只將這些標頭檔案標示為
#include
陳述式中提及的輸入檔案 (我們會高估,因此不必實作完整的 C 前置處理器)。這個選項目前在 Bazel 中硬連結為「false」,且只在 Google 中使用。 - 動作可能會發現在執行期間未使用某些檔案。在 C++ 中,這稱為「.d 檔案」:編譯器會告知在建構完成後使用了哪些標頭檔案,避免 Bazel 使用這種做法,避免產生比 Make 更糟糕的漸進式。這比依賴編譯器的納入掃描器提供更準確的估計值。
這些方法是使用 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 在呼叫 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
,以便叫用RepositoryLoaderFunction
RepositoryLoaderFunction
會將要求轉發至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//
),以及另一個二進位檔存放區 (例如 @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
和NativePosixFileSystem
ProcessUtils
WindowsFileOperations
和WindowsFileProcesses
com.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 的其他部分) 只能透過 Reporter.getOutErr()
將遠端程序呼叫 (RPC) 串流至用戶端,藉此直接存取這些串流。只有在指令需要轉儲大量可能的二進位資料 (例如 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
檔案,然後各種輔助方法可要求已設定的目標、變更設定,並斷言分析結果的各種內容。