瀏覽先前的頁面時,您會發現一個不斷重複的主題:管理自己的程式碼相當簡單,但管理程式碼的依附元件則困難得多。相依項目種類繁多,有時是工作相依項目 (例如「在將版本標示為完成前,先推送文件」),有時是構件相依項目 (例如「我需要最新版本的電腦視覺程式庫才能建構程式碼」)。有時,您會對程式碼庫的其他部分有內部相依項目,有時則會對其他團隊 (貴機構或第三方) 擁有的程式碼或資料有外部相依項目。但無論如何,「我需要這個,才能擁有那個」的概念,在建構系統的設計中不斷重複出現,而管理依附元件或許是建構系統最基本的工作。
處理模組和依附元件
使用 Bazel 等構件建構系統的專案會分成一組模組,模組會透過 BUILD
檔案表示彼此的依附元件。妥善整理這些模組和依附元件,對建構系統的效能和維護工作量都有極大影響。
使用精細模組和 1:1:1 規則
以構件為基礎建構時,首先會遇到的問題是決定個別模組應包含多少功能。在 Bazel 中,模組會以目標表示,指定可建構的單元,例如 java_library
或 go_binary
。在其中一個極端情況下,整個專案可能會包含在單一模組中,方法是在根層級放置一個 BUILD
檔案,並以遞迴方式將該專案的所有來源檔案全域化。在另一個極端情況下,幾乎每個來源檔案都可以成為自己的模組,實際上需要每個檔案在 BUILD
檔案中列出所依附的每個其他檔案。
大多數專案都介於這兩個極端之間,因此選擇時需要在效能和可維護性之間取捨。如果整個專案都使用單一模組,您可能永遠不需要修改 BUILD
檔案 (新增外部依附元件時除外),但這表示建構系統一律必須一次建構整個專案。這表示無法平行處理或分配建構作業的部分內容,也無法快取已建構的部分內容。每個檔案一個模組則相反:建構系統在快取和排程建構步驟方面具有最大彈性,但工程師每次變更參照的檔案時,都需要花費更多心力維護依附元件清單。
雖然精細程度因語言而異 (甚至在同一種語言內也可能不同),但 Google 傾向於使用比一般工作型建構系統中更小的模組。Google 的一般生產二進位檔通常取決於數萬個目標,即使是中等規模的團隊,程式碼庫中也可能擁有數百個目標。對於 Java 等語言,由於內建的封裝概念很強大,因此每個目錄通常只會包含單一套件、目標和 BUILD
檔案 (Pants 是另一個以 Bazel 為基礎的建構系統,將此稱為 1:1:1 規則)。包裝慣例較弱的語言通常會為每個 BUILD
檔案定義多個目標。
縮小建構目標的優點在於,這類目標可加快分散式建構速度,並減少重建目標的需求,因此大規模建構時特別明顯。測試開始後,優勢會更加明顯,因為更精細的目標表示建構系統可以更聰明地只執行可能受到任何指定變更影響的一小部分測試。Google 認為使用較小的目標可帶來系統性優勢,因此我們投入資源開發工具,自動管理 BUILD
檔案,避免增加開發人員負擔,以減輕這項做法的缺點。
其中部分工具 (例如 buildifier
和 buildozer
) 位於 buildtools
目錄,可搭配 Bazel 使用。
盡量減少模組顯示
Bazel 和其他建構系統允許每個目標指定可見度,這項屬性會決定哪些其他目標可以依附於該目標。私人目標只能在自己的 BUILD
檔案中參照。目標可授予明確定義的 BUILD
檔案清單目標更廣泛的曝光度,或在公開曝光度的情況下,授予工作區中的每個目標。
與大多數程式設計語言一樣,盡可能縮小可見度通常是最佳做法。一般來說,只有當目標代表 Google 任何團隊都能使用的廣泛程式庫時,Google 團隊才會公開目標。如果團隊要求其他使用者必須先與他們協調,才能使用代碼,則會維護允許的目標顧客名單,做為目標對象的顯示設定。每個團隊的內部實作目標僅限於團隊擁有的目錄,且大多數 BUILD
檔案只會有一個非私人的目標。
管理依附元件
模組必須能夠互相參照。將程式碼集細分成模組的缺點是,您需要管理這些模組之間的依附元件 (不過工具可以協助自動執行這項作業)。通常,表示這些依附元件會成為 BUILD
檔案中的大部分內容。
內部依附元件
在細分成細微模組的大型專案中,大多數依附元件可能都是內部依附元件,也就是在同一個來源存放區中定義及建構的其他目標。內部依附元件與外部依附元件不同,因為內部依附元件是從來源建構,而不是在執行建構作業時下載為預先建構的構件。這也表示內部依附元件沒有「版本」的概念,目標及其所有內部依附元件一律會在存放區的相同提交/修訂版本中建構。就內部依附元件而言,應謹慎處理的一項問題是,如何處理遞移依附元件 (圖 1)。假設目標 A 依附於目標 B,而目標 B 依附於通用程式庫目標 C。目標 A 應該可以使用目標 C 中定義的類別嗎?
圖 1. 遞移依附元件
就基礎工具而言,這沒有問題;建構目標 A 時,B 和 C 都會連結至目標 A,因此 A 會知道 C 中定義的任何符號。Bazel 允許這樣做已有很多年,但隨著 Google 成長,我們開始發現問題。假設 B 經過重構,不再需要依附於 C。如果隨後移除了 B 對 C 的依附元件,A 和任何透過 B 依附元件使用 C 的目標都會中斷。實際上,目標的依附元件已成為公開合約的一部分,且永遠無法安全變更。這表示依附元件會隨著時間累積,Google 的建構作業也開始變慢。
Google 最終在 Bazel 中導入「嚴格遞移依附元件模式」,解決了這個問題。在這個模式中,Bazel 會偵測目標是否嘗試參照符號,但未直接依附於該符號,如果偵測到這種情況,就會失敗並顯示錯誤,以及可用於自動插入依附元件的殼層指令。在 Google 的整個程式碼集推出這項變更,並重構數百萬個建構目標,明確列出其依附元件,是一項耗時多年的工作,但非常值得。由於目標的非必要依附元件較少,建構速度現在快得多,工程師也能放心移除不需要的依附元件,不必擔心會破壞依附於這些元件的目標。
與往常一樣,強制執行嚴格的遞移依附元件需要取捨。這會讓建構檔案變得更冗長,因為現在許多地方都需要明確列出常用程式庫,而不是順便帶入,工程師也需要花費更多心力在 BUILD
檔案中新增依附元件。我們隨後開發了相關工具,可自動偵測許多缺少的依附元件,並將其新增至 BUILD
檔案,無需開發人員介入,藉此減少這類工作。但即使沒有這類工具,我們也發現隨著程式碼規模擴大,這種取捨非常值得:在 BUILD
檔案中明確新增依附元件是一次性成本,但只要建構目標存在,處理隱含的遞移依附元件就可能造成持續性問題。Bazel 預設會對 Java 程式碼強制執行嚴格的轉換依附元件。
外部依附元件
如果依附元件不是內部元件,就必須是外部元件。外部依附元件是指建構及儲存在建構系統外部的構件。依附元件會直接從構件存放區匯入 (通常透過網際網路存取),並直接使用,而不是從來源建構。外部和內部依附元件之間最大的差異之一,在於外部依附元件有版本,且這些版本獨立於專案的原始碼。
自動與手動依附元件管理
建構系統可讓您手動或自動管理外部依附元件的版本。手動管理時,建構檔案會明確列出要從構件存放區下載的版本,通常使用語意版本字串,例如 1.1.4
。如果由系統自動管理,來源檔案會指定可接受的版本範圍,建構系統一律會下載最新版本。舉例來說,Gradle 允許將依附元件版本宣告為「1.+」,指定只要依附元件的主要版本為 1,任何次要或修補程式版本都可接受。
自動管理依附元件對小型專案來說很方便,但對於規模不小或由多位工程師共同開發的專案,通常會造成災難。自動管理依附元件的問題在於,您無法控制版本更新時間。無法保證外部人員不會進行重大更新 (即使他們聲稱使用語意化版本控管),因此某個建構版本可能前一天還能正常運作,隔天就無法使用,而且很難偵測到變更內容,或將其還原至正常運作狀態。即使建構不會中斷,也可能出現難以追蹤的細微行為或效能變化。
相較之下,手動管理的依附元件需要變更來源控制項,因此可以輕鬆探索及回溯,而且可以查看舊版存放區,並使用舊版依附元件建構。Bazel 要求手動指定所有依附元件的版本。即使是中等規模,手動管理版本的額外負擔也相當值得,因為這樣做可確保穩定性。
單一版本規則
程式庫的不同版本通常會以不同的構件表示,因此從理論上來說,相同外部依附元件的不同版本,應該都能在建構系統中以不同名稱宣告。這樣一來,每個目標就能選擇要使用的依附元件版本。這在實務上會造成許多問題,因此 Google 對於程式碼集中的所有第三方依附元件,都強制執行嚴格的單一版本規則。
允許多個版本會導致菱形依附元件問題,這是最大的問題。假設目標 A 依附於目標 B 和外部程式庫的 v1。如果稍後重構目標 B,加入相同外部程式庫的 v2 依附元件,目標 A 就會中斷,因為現在目標 A 會隱含依附於相同程式庫的兩個不同版本。實際上,從目標將新依附元件新增至任何具有多個版本的第三方程式庫,都是不安全的做法,因為該目標的任何使用者可能已依附於不同版本。遵循單一版本規則可避免這類衝突。如果目標新增第三方程式庫的依附元件,現有依附元件會使用相同版本,因此可順利共存。
遞移外部依附元件
處理外部依附元件的遞移依附元件可能特別困難。許多構件存放區 (例如 Maven Central) 允許構件指定存放區中其他構件特定版本的依附元件。Maven 或 Gradle 等建構工具預設會遞迴下載每個暫時性依附元件,也就是說,在專案中新增單一依附元件,可能會導致下載數十個構件。
這項功能非常方便,因為新增程式庫的依附元件時,如果必須追蹤該程式庫的每個遞移依附元件,並手動全部新增,會非常麻煩。但這種做法也有很大的缺點:由於不同程式庫可能依附於相同第三方程式庫的不同版本,因此這種策略必然會違反「單一版本規則」,並導致菱形依附元件問題。如果目標依附於兩個外部程式庫,而這兩個程式庫使用相同依附元件的不同版本,您無法判斷會取得哪個版本。這也表示,如果新版本開始提取某些依附元件的衝突版本,更新外部依附元件可能會導致整個程式碼集發生看似不相關的失敗。
因此,Bazel 不會自動下載遞移依附元件。很遺憾,沒有萬靈丹。Bazel 的替代方案是要求使用全域檔案,列出存放區的每個外部依附元件,以及整個存放區中用於該依附元件的明確版本。幸好,Bazel 提供的工具可自動產生這類檔案,其中包含一組 Maven 構件的遞移依附元件。這個工具可以執行一次,為專案產生初始 WORKSPACE
檔案,然後手動更新該檔案,調整每個依附元件的版本。
同樣地,這裡的選擇是便利性與擴充性。小型專案可能不想自行管理遞移依附元件,或許可以使用自動遞移依附元件。隨著機構和程式碼庫成長,這種策略的吸引力會越來越低,衝突和非預期結果也會越來越常發生。如果規模較大,手動管理依附元件的成本遠低於處理自動依附元件管理所造成問題的成本。
使用外部依附元件快取建構結果
外部依附元件通常由第三方提供,他們會發布程式庫的穩定版本,但可能不會提供原始碼。部分機構也可能會選擇將自己的部分程式碼做為構件提供,讓其他程式碼將這些程式碼視為第三方依附元件,而非內部依附元件。如果構件建構速度緩慢,但下載速度很快,理論上這樣做可以加快建構速度。
不過,這也會帶來許多額外負擔和複雜性:必須有人負責建構每個構件,並將其上傳至構件存放區,而用戶端則必須確保自己使用最新版本。由於系統的不同部分是從存放區的不同時間點建構而成,且來源樹狀結構不再有一致的檢視畫面,因此偵錯作業也會變得更加困難。
如要解決構件建構時間過長的問題,更好的做法是使用支援遠端快取的建構系統,如前所述。這類建構系統會將每次建構作業產生的構件儲存至工程師共用的位置,因此如果開發人員依附於其他人最近建構的構件,建構系統會自動下載該構件,而不是自行建構。這可提供直接依附於構件的所有效能優勢,同時確保建構作業的一致性,如同建構作業一律從相同來源建構一樣。這是 Google 內部使用的策略,且 Bazel 可設定為使用遠端快取。
外部依附元件的安全性和可靠性
依附第三方來源的構件本質上就具有風險。如果第三方來源 (例如構件存放區) 發生故障,可能會導致可用性風險,因為如果無法下載外部依附元件,整個建構作業可能會停滯。此外,如果第三方系統遭到攻擊者入侵,攻擊者可能會將參照構件替換成自己設計的構件,進而將任意程式碼插入建構作業,因此存在安全風險。如要解決這兩個問題,可以將您依附的任何構件鏡像到您控管的伺服器,並禁止建構系統存取 Maven Central 等第三方構件存放區。但維護這些鏡像需要投入心力與資源,因此是否使用這些鏡像通常取決於專案規模。您也可以在來源存放區中指定每個第三方構件的雜湊,這樣一來,如果構件遭到竄改,建構作業就會失敗,從而完全避免安全性問題,且幾乎不會造成額外負擔。另一個完全避開這個問題的替代做法,是提供專案依附元件。專案供應商提供依附元件時,會將這些元件連同專案的原始碼一併簽入原始碼控管系統,形式可以是原始碼或二進位檔。這表示專案的所有外部依附元件都會轉換為內部依附元件。Google 內部會使用這種方法,將 Google 中參照的每個第三方程式庫,都檢查到 Google 來源樹狀結構根目錄的 third_party
目錄中。不過,這項做法只適用於 Google,因為 Google 的來源控管系統是專為處理極大型單一存放區而建構,因此並非所有機構都能採用供應商化做法。