依附元件管理

瀏覽上一頁時,其中一個主題會不斷重複:管理程式碼相當簡單,但管理其依附元件非常困難。依附元件分為多個類型:有時工作會有依附元件 (例如「在我將版本標示為完成前推送說明文件」),有時成果上會有依附元件 (例如「我需要最新版本的電腦視覺程式庫,才能建構程式碼」)。有時,您可能在程式碼集的另一個部分 (或第三方擁有的其他程式碼部分) 擁有內部依附元件,或是第三方擁有的其他程式碼 (您的外部依附元件或機構擁有的資料)。但在任何情況下,「我必須先取得必要權限,才能取得這項資訊」,在建構系統設計中就會重複出現,管理依附元件可能是建構系統的重要工作。

處理模組與依附元件

使用 Bazel 等以構件為基礎的建構系統的專案會拆分為一組模組,其中的模組會透過 BUILD 檔案代表彼此的依附元件。妥善整理這些模組和依附元件,對於建構系統的效能以及維護需要的工作量,有極大影響。

使用精細的模組和 1:1:1 規則

建構以構件為基礎的建構作業時,第一個問題就是決定個別模組應涵蓋的功能。在 Bazel 中,「模組」會以指定可建構單元 (例如 java_librarygo_binary) 的目標表示。在極端的情況下,整個專案可能會包含在單一模組中,做法是將一個 BUILD 檔案放在根層級,並將該專案的所有來源檔案以遞迴方式排列。另一方面,幾乎每個來源檔案都可以建立到自己的模組中,進而要求每個檔案依其依附的 BUILD 檔案有效列出每個檔案。

大多數專案都會在這類極端之間,因此在選擇權時,須在效能和可維護性之間取得平衡。如果針對整個專案使用單一模組,可能表示除了新增外部依附元件時,您不需要輕觸 BUILD 檔案,但這表示建構系統必須一次建構整個專案。這表示它將無法平行處理或發布建構的特定部分,也無法快取已建構的部分。相反地,每個檔案只使用一個模組:建構系統對建構的快取和排程步驟具有最大彈性,但每當變更依附元件清單時,工程師都需要投入更多心力來維護依附元件清單。

雖然確切的精細程度因語言而異 (甚至通常在語言中),但相較於通常在以工作為基礎的建構系統中編寫的模組,Google 傾向偏好較小的模組。Google 的一般實際工作環境二進位檔通常取決於數萬個目標,即使是中等規模的團隊,也能在其程式碼集內擁有數百個目標。對於具有強大封裝概念的 Java 等語言,每個目錄通常包含單一套件、目標和 BUILD 檔案 (Pants 是另一個以 Bazel 為基礎的建構系統,稱之為 1:1:1 規則)。封裝慣例較弱的語言通常會為每個 BUILD 檔案定義多個目標。

規模較小的建構目標之所以會開始大規模顯示,是因為它們能加快分散式建構作業,並降低重建目標的頻率。測試完成後,就能更引人注目,因為更精細的目標表示建構系統能夠更聰明地執行可能受到任何特定變更影響的有限測試子集。Google 深信使用小型目標的系統性優勢,因此我們投注心力改善可自動管理 BUILD 檔案的工具,避免對開發人員造成負擔,以減輕開發人員的負擔。

其中部分工具 (例如 buildifierbuildozer) 可與 buildtools 目錄中的 Bazel 搭配使用。

將模組可見性降到最低

Bazel 和其他建構系統可讓每個目標指定可見度,這個屬性決定了其他目標可能需依賴的目標。私人目標只能在其 BUILD 檔案中參照。目標可授予明確定義 BUILD 檔案清單的目標瀏覽權限,或在公開瀏覽權限的情況下,授予工作區中的每個目標。

與大部分的程式設計語言一樣,最好盡可能減少瀏覽權限。一般而言,只有在目標代表 Google 任何團隊提供廣泛使用的程式庫時,Google 團隊才能將目標設為公開。如果團隊要求其他人在使用他們的程式碼前與其協調,就會維持客戶目標的許可清單,做為目標瀏覽權限。每個團隊的內部實作目標僅限於團隊擁有的目錄,且大部分的 BUILD 檔案都只有一個非私人目標。

管理依附元件

模組必須能夠互相參照。將程式碼集細分為精細模組的缺點,就是您必須管理這些模組之間的依附元件 (但工具可自動執行這項作業)。表示這些依附元件通常成了 BUILD 檔案中的大量內容。

內部依附元件

在細分為精細模組的大型專案中,大部分的依附元件可能都是內部依附元件,也就是在同一個原始碼存放區中定義及建構的另一個目標上。內部依附元件與外部依附元件不同,其是由來源建構,而不是在執行建構時下載為預先建構的成果。這也表示內部依附元件沒有「版本」的概念,目標及其所有內部依附元件一律是以存放區中的同一個修訂版本/修訂版本建構而成。處理內部依附元件的問題之一,就是如何處理遞移依附元件 (圖 1)。假設目標 A 依附於目標 B,而目標 B 依附於通用程式庫目標 C。是否應該指定 A 可以使用目標 C 中定義的類別?

遞移依附元件

圖 1:遞移依附元件

就基礎工具而言,這並沒有問題。在建構時,B 和 C 都會連結到目標 A,因此 C 中定義的任何符號均為 A。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 就會無法運作,因為現在其依附於同一程式庫的兩個不同版本。但實際上,只要將目標從目標新增到具有多個版本的任何第三方程式庫,都不算安全,因為該目標的任何使用者可能已經取決於不同的版本。遵循 One-版本規則會導致這種衝突不可能。如果目標在第三方程式庫上新增了依附元件,所有現有的依附元件都會位於同一個版本上,因此會發生共存。

遞移外部依附元件

處理外部依附元件的遞移依附元件可能特別困難。許多成果存放區 (例如 Maven Central) 都可讓成果指定存放區中其他構件版本的依附元件。根據預設,Maven 或 Gradle 等建構工具通常會以遞迴方式下載各個遞移依附元件,也就是說,在專案中新增單一依附元件可能會導致系統一次下載數十個成果。

這個做法非常方便:在新程式庫中新增依附元件時,必須追蹤每個程式庫的遞移依附元件,並手動新增所有依附元件,會相當困難。但也有很大的缺點:由於不同程式庫可能會依附同一個第三方程式庫的不同版本,因此這個策略不一定違反 One-Version 規則,並會導致鑽石依附元件問題。如果您的目標依附於兩個外部程式庫,這些程式庫使用相同依附元件的不同版本,則系統不會告知您將取得哪個版本。這也表示,如果新版本開始提取其某些依附元件的衝突版本,更新外部依附元件可能會導致整個程式碼集中發生看似不相關的故障。

因此,Bazel 不會自動下載遞移依附元件。幸運的是,根本沒有萬靈丹。Bazel 的替代方案為要求一個全域檔案,當中列出存放區的外部依附元件,以及用於該依附元件的所有明確版本。幸好,Bazel 提供的工具能夠自動產生這類檔案,內含一組 Maven 構件的遞移依附元件。執行此工具一次即可產生專案的初始 WORKSPACE 檔案,接著您就可以手動更新該檔案,並調整每個依附元件的版本。

不過,選擇方便性與擴充性也是其中一個選擇。小型專案可能不想自行管理遞移依附元件,並且可能可以使用自動遞移依附元件。隨著機構和程式碼集的成長,這項策略變得越來越少,吸引力也會越來越高,且衝突和未預期的結果也越來越頻繁。在較大的規模時,手動管理依附元件的成本遠低於處理自動依附元件管理造成問題的成本。

使用外部依附元件快取建構結果

外部依附元件最常由發布穩定版程式庫的第三方提供,或許不需要提供原始碼。部分機構也可能選擇將自己的部分程式碼做為構件使用,讓其他程式碼片段依附於第三方,而非內部依附元件。在理論上,如果構件建構速度很慢,但下載很快,就能加快建構速度。

不過,這也會帶來大量負擔和複雜度:需要負責建構每個成果並上傳至成果存放區,而用戶端必須確保使用的是最新版本。此外,偵錯也會變得更加困難,因為系統的不同部分都是根據存放區的不同點建構,且來源樹狀結構無法保持一致檢視。

如要解決構件花費長時間建構的問題,更好的方法就是使用支援遠端快取的建構系統,如前文所述。這類建構系統會將每個版本產生的成果儲存到工程師共用的位置,因此如果開發人員依附的構件是他人最近建構的成果,建構系統會自動下載該構件,而非建構成果。這不僅能提供直接依附於構件的所有效能優勢,同時還能確保建構作業的一致性,如同使用相同來源建構的一樣。這是 Google 內部使用的策略,而且 Bazel 可設為使用遠端快取。

外部依附元件的安全性和穩定性

視第三方來源的產物而定,本質上具有風險。如果第三方來源 (例如構件存放區) 停止運作,則會有可用性風險,這是因為整個建構作業若無法下載外部依附元件,可能會停止運作。還有一個安全性風險:如果第三方系統遭到攻擊者入侵,攻擊者便可將參照的構件替換成自己的設計,藉此將任意程式碼插入建構作業中。只要將您依賴的任何構件鏡像到由您控管的伺服器,並禁止建構系統存取 Maven Central 等第三方構件存放區,即可解決這兩個問題。需要權衡的是,這些鏡像需要投注心力與資源維護,因此要選擇是否使用這些項目通常都視專案的規模而定。要求來源存放區必須指定每個第三方成果的雜湊值,才能完全避免安全性問題,因為遭竄改後會導致建構作業失敗。完全消除問題的替代方案,是與專案的依附元件供應商聯絡。當專案提供依附元件時,會一起檢查來源控制和專案的原始碼,不論是以來源或二進位檔形式進行檢查。這意味著,專案的所有外部依附元件都會轉換為內部依附元件。Google 會在內部使用此方法,在 Google 來源樹狀結構的 third_party 目錄中,檢查整個 Google 參照的第三方程式庫。不過,這只適用於 Google 的情況,因為 Google 的原始碼控制系統是專為處理極大型的單聲道存放區而設計,因此供應商可能無法讓所有機構採用。