依附元件管理

瀏覽先前的頁面時,系統會重複執行一個主題:自行管理程式碼相當容易,但要管理其依附元件非常困難。依附元件有很多種:有時則是任務的依附元件 (例如「在將版本標示為已完成前推送說明文件」);有時則是構件的依附元件 (例如「我需要最新版本的電腦視覺程式庫才能建構程式碼」)。有時您的內部依附元件是由其他部分或第三方程式碼集所擁有。但無論如何,在建構系統設計時,「我都需要之前先做到」是會在建構系統設計時重複出現的概念,而管理依附元件可能是建構系統最主要的工作。

處理模組和依附元件

使用以構件為基礎的建構系統 (例如 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 程式碼上強制執行嚴格的遞移依附元件

外部依附元件

如果不是內部的依附元件,則必須設為外部。外部依附元件是指在建構系統外建構和儲存的構件上。依附元件會直接從成果存放區匯入 (通常透過網際網路存取),且原封不動地使用,而非從原始碼建構。外部與內部依附元件的最大差異之一,就是外部依附元件有版本,這些版本獨立於專案的原始碼之外。

自動與手動依附元件管理

建構系統可允許手動或自動管理外部依附元件的版本。手動管理時,buildfile 會從成果存放區中明確列出想要下載的版本,通常使用語意版本字串,例如 1.1.4。自動管理時,來源檔案會指定可接受的版本範圍,而建構系統一律會下載最新版本。例如,Gradle 允許宣告依附元件版本為「1.+」,藉此指定依附元件的任何次要或修補程式版本只要版本為 1 就可接受的。

自動代管的依附元件非常適合小型專案使用,但通常適用於不同規模專案或多位工程師參與的災難,自動代管依附元件的問題在於,您無法控製版本更新的時間。沒有任何方法可以保證外部方不會做出破壞性更新 (即使他們聲稱使用語意版本管理)。因此,有一天的工作可能會遭下一個故障,而且不容易偵測變更的內容或復原至工作狀態。即使建構作業不會中斷,也可能存在難以追蹤的行為或效能變化。

相反地,由於手動管理的依附元件需要變更原始碼控制系統,因此很容易找到並復原,而您也可以查看舊版的存放區,使用舊版依附元件進行建構。Bazel 要求您必須手動指定所有依附元件的版本。即使規模較大,手動版本管理的負擔也很值得將其提供的穩定性列入考量。

單一版本規則

不同版本的程式庫通常會以不同的構件表示,因此理論上來說,同一外部依附元件的不同版本無法同時以不同名稱在建構系統中宣告。如此一來,每個目標就可以選擇要使用的依附元件版本。這會造成許多實務問題,因此 Google 對程式碼集中的所有第三方依附元件強制執行嚴格的單一版本規則

允許多個版本的最大問題是鑽石依附元件問題。假設目標 A 依附於目標 B 與外部程式庫的 v1。如果目標 B 之後重構,為同一個外部程式庫的 v2 新增依附元件,目標 A 就會中斷,因為現在以隱含方式依賴同一個程式庫的兩個不同版本。實際上,將目標從目標新增至具有多個版本的任何第三方程式庫並不安全,因為該目標的使用者可能已依附於不同版本。遵守 One-Version 規則後,這項衝突就不可能:如果目標新增了第三方程式庫的依附元件,則現有依附元件便已存在於相同版本上,因此可以順利共存。

遞移外部依附元件

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

這非常方便:在新的程式庫中新增依附元件時,必須追蹤每個程式庫的遞移依附元件,並手動新增所有依附元件,可說是相當麻煩。但也有很大的缺點:因為不同的程式庫可能會依附同一第三方程式庫的不同版本,因此這種策略不一定違反單一版本規則,並會導致鑽石依附元件問題。如果您的目標仰賴兩個外部程式庫,且這些程式庫使用相同依附元件的不同版本,系統將不會判斷您可獲得哪個版本。這也表示,如果新版本開始提取衝突的某些依附元件版本,則更新外部依附元件可能會導致整個程式碼集,造成看似不相關的失敗情形。

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

但再次提醒您,便利性和擴充性才是理想的選擇。小型專案可能比較不需要自行管理遞移依附元件的相關事宜,也可能無法使用自動遞移依附元件。隨著機構和程式碼集的成長,這項策略的吸引力會降低,而衝突和意外結果的發生頻率也會提高。在大規模的規模上,手動管理依附元件的成本遠比自動依附元件管理造成的問題處理成本低上許多。

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

外部依附元件通常由第三方提供,這類第三方會發布程式庫的穩定版本,但可能並未提供原始碼。部分機構也可能選擇將部分自己的程式碼做為構件提供使用,以便其他程式碼以第三方 (而非內部依附元件) 的形式依附。如果構件的建構速度慢但下載速度很快,理論上也可能會加快建構速度。

但是,這也會造成大量負擔和複雜性:需要有人負責建構每個構件,並上傳至構件存放區,而用戶端必須確保隨時保持在最新版本中。除錯也更加困難,因為系統的不同部分是根據存放區中的不同點建構,而不再對來源樹狀結構提供一致的檢視。

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

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

視第三方來源的成果而定,本身俱有風險。如果第三方來源 (例如構件存放區) 停止運作,會發生可用性風險,因為如果整個版本無法下載外部依附元件,整個版本可能會停止運作。除此之外也會有安全風險:如果攻擊者入侵第三方系統,攻擊者可將參照的成果替換成自己的設計,讓對方能夠在您的建構作業中插入任意程式碼。您可以為依賴於控管的任何構件建立鏡像到您控制的伺服器,並禁止建構系統存取第三方成果存放區 (例如 Maven Central),藉此緩解這兩個問題。需要權衡的是,這類鏡像需要投入心力和資源來維護,因此決定要使用的情況是否經常取決於專案的規模。您也可以要求在原始碼存放區中指定每個第三方構件的雜湊值,藉此完全避免安全問題。這些問題會造成版本遭到竄改,造成建構作業失敗。另一個完全不可能解決這個問題的替代方案,就是針對專案的依附元件廠商提供廠商協助。當專案廠商將其依附元件時,會將其做為來源或二進位檔,與專案的原始碼一併進行來源控制檢查。這表示專案的所有外部依附元件都會轉換成內部依附元件。Google 會在內部使用此方法,檢查整個 Google 參照的所有第三方程式庫,並貼到 Google 來源樹狀結構的 third_party 目錄中。不過,這種做法只能在 Google 運作,因為 Google 的來源控制系統是專為處理超大型單一存放區而設計,因此並非所有機構都能使用供應商服務。