本文件說明程式碼集和 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()
之前,您可以修改 FragmentOptions
。)
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/external
或 $EXECROOT/..
(前者當然會導致主存放區中無法有名為 external
的套件;這就是我們要從中遷移的原因)$EXECROOT
套件
每個存放區都由套件、相關檔案集合和依附元件規格組成。這些項目會由名為 BUILD
或 BUILD.bazel
的檔案指定。如果兩者皆存在,Bazel 會偏好 BUILD.bazel
;BUILD
檔案仍會接受的原因是 Bazel 的祖系 Blaze 使用了這個檔案名稱。不過,這項功能已成為常用的路徑片段,特別是在 Windows 上,因為在 Windows 上,檔案名稱不區分大小寫。
套件彼此獨立:套件的 BUILD
檔案變更不會導致其他套件變更。新增或移除 BUILD
檔案_可能_會變更其他套件,因為遞迴式 glob 會在套件邊界停止,因此 BUILD
檔案的存在會停止遞迴。
BUILD
檔案的評估作業稱為「套件載入」。這個方法在 PackageFactory
類別中實作,運作時需呼叫 Starlark 解譯器,且需要瞭解可用的規則類別組合。套件載入的結果是 Package
物件。這類別名大多是從字串 (目標名稱) 對應至目標本身。
載入套件期間有很大的複雜問題:Bazel 不需要明確列出每個來源檔案,而是可以執行 glob (例如 glob(["**/*.java"])
)。與殼層不同,它支援向下傳遞至子目錄的遞迴 glob (但不支援子套件)。這項操作需要存取檔案系統,而這可能會造成速度變慢,因此我們會實作各種技巧,讓系統能以平行方式運作,並盡可能提高效率。
以下類別實作了 Globbing:
LegacyGlobber
,快速且無需 Skyframe 的 globberSkyframeHybridGlobber
:使用 Skyframe 的版本,並會回復舊版 globber,以避免「Skyframe 重新啟動」(如後文所述)
Package
類別本身包含一些成員,這些成員專門用於剖析 WORKSPACE 檔案,對實際套件而言並無意義。這屬於設計瑕疵,因為說明一般套件的物件不得包含描述其他內容的欄位。包括:
- 存放區對應
- 已註冊的工具鍊
- 已註冊的執行平台
在理想情況下,剖析 WORKSPACE 檔案與剖析一般套件之間會有更多區隔,因此 Package
就不需要同時滿足兩者的需求。很抱歉,這很難做到,因為這兩者是密切相關的。
標籤、指定目標和規則
套件由目標組成,目標有以下類型:
- 「Files」:建構的輸入或輸出內容。在 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 和麵向使用者的文件中,「規則」只能用來參照規則類別本身;目標僅用於「目標」。另請注意,即使 RuleClass
的名稱含有「類別」,規則類別和該類型目標之間並沒有 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 的功能更強大,但同時也可輕鬆執行 Bad ThingsTM,例如:編寫程式碼的時間或空間複雜度為正反兩 (或較差)、導致 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
複雜一點,原因有二:
- 在大多數情況下,檔案的執行檔案路徑與 execpath 相同。我們會使用這個方法來節省一些 RAM。
- 執行檔樹狀結構中含有各種舊版項目,這些項目也需要呈現。
系統會使用 RunfilesProvider
收集執行檔:這個類別的例項代表已設定目標 (例如程式庫) 的執行檔,以及其間接關閉需求,這些執行檔會像巢狀集合一樣收集 (實際上,這些執行檔是使用巢狀集合在封面下實作):每個目標都會將其依附元件的執行檔合併,並新增一些自己的執行檔,然後將產生的集合向上傳送至依附元件圖表。RunfilesProvider
例項包含兩個 Runfiles
例項,一個用於規則透過「data」屬性依附,另一個用於每種其他類型的傳入依附元件。這是因為目標在透過資料屬性依附時,有時會顯示不同的執行檔,而非其他方式。這是不必要的舊版行為,我們尚未移除。
二進位檔的執行檔案會以 RunfilesSupport
的例項表示。這與 Runfiles
不同,因為 RunfilesSupport
具有實際建構的功能 (而 Runfiles
只是對應)。這需要下列額外元件:
- 輸入的 runfiles 資訊清單。這是 runfiles 樹狀結構的序列化說明。它用於執行檔案樹狀目錄的內容代理程式,而 Bazel 會假設只有在資訊清單內容變更時,執行檔案樹狀目錄才會變更。
- 輸出執行檔案資訊清單。這項屬性由處理執行檔樹狀結構的執行階段程式庫使用,特別是在 Windows 上,因為 Windows 有時不支援符號連結。
- 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
非常接近,但 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
包含資料,使ActionExecutionFunction
的 SkyFrame 重新啟動可節省費用- 本機動作快取包含檔案系統狀態的相關資料
- 遠端執行系統通常也會包含自己的快取
本機動作快取
這個快取是 Skyframe 後方的另一個層級;即使在 Skyframe 中重新執行動作,仍可在本機動作快取中命中。它代表本機檔案系統的狀態,並且已經序列化到磁碟,這表示當 SkyFrame 圖無內容時,即使該伺服器啟動新的 Bazel 伺服器,還是能取得本機動作快取命中。
系統會使用 ActionCacheChecker.getTokenIfNeedToExecute()
方法檢查這個快取是否有命中。
與名稱相反,這是從衍生成果的路徑到產生該成果的動作的對應項目。該動作的說明如下:
- 其輸入和輸出檔案集,以及其總和檢查碼
- 其「動作鍵」通常是執行的命令列,但一般來說,它代表輸入檔案的總和檢查碼未擷取的所有內容 (例如
FileWriteAction
,它是寫入資料的總和檢查碼)
此外,我們也正在開發一項高度實驗性的「自上而下的動作快取」,這項功能會使用傳遞式雜湊,避免多次存取快取。
輸入探索和輸入裁剪
有些動作不只是一組輸入內容,動作的輸入組合可進行兩種變更:
- 動作可能會在執行前發現新的輸入內容,或決定部分輸入內容其實並非必要。標準範例是 C++,在這種情況下,建議您根據 C++ 檔案的傳遞閉包,根據經驗判斷檔案使用的標頭檔案,這樣就不必將每個檔案都傳送至遠端執行者;因此,我們可以選擇不將每個標頭檔案都註冊為「輸入」檔案,而是掃描來源檔案,找出傳遞式納入的標頭,並只將這些標頭檔案標示為
#include
陳述式中提及的輸入檔案 (我們會高估,因此不必實作完整的 C 前置處理器)。這個選項目前在 Bazel 中硬連結為「false」,且只在 Google 中使用。 - 動作可能會發現在執行期間未使用某些檔案。在 C++ 中,這稱為「.d 檔案」:編譯器會在事後指出使用了哪些標頭檔案,為了避免比 Make 更糟糕的增量,Bazel 會利用這項事實。這比依賴編譯器的納入掃描器提供更準確的估計值。
這些函式是透過「動作」上的方法來實作:
- 系統會呼叫
Action.discoverInputs()
。它應會傳回一組確定需要的巢狀 Artifact。這些必須是來源構件,這樣在動作圖中就不會出現沒有對應項目的依附元件邊緣。 - 透過呼叫
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
的工具計算總和檢查碼。這項作業需要知道哪些動作輸入內容代表工具的一部分,哪些代表輸入內容;這項作業由動作建立者決定: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 叫用執行,要求在「主要」建構之後執行測試。這可透過 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
,以便叫用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//
),以及另一個二進位檔存放區中的另一個 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
和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 的其餘部分) 與用戶端的 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
檔案,然後各種輔助方法可要求已設定的目標、變更設定,並斷言分析結果的各種內容。