依附元件管理

回報問題 查看原始碼 Nightly · 7.4 . 7.3 · 7.2 · 7.1 · 7.0 · 6.5

瀏覽先前的頁面時,一個主題會不斷重複:自行管理程式碼相當簡單,但管理依附元件卻不容易。依附元件有很多種:有時工作會有依附元件 (例如「在將版本標示為完成之前推送說明文件」),有時還會有依附元件依附關係 (例如「我需要具備最新版電腦視覺程式庫才能建構程式碼」)。有時,您的外部依附元件是貴機構程式碼集的其他部分,或由其他團隊擁有的程式碼。但無論如何,「我需要那個東西才能擁有這個」的概念會不斷重複出現在建構系統的設計中,而管理依附元件可能是建構系統最基本的任務。

處理模組和依附元件

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

外部依附元件

如果依附元件不是內部,則該依附元件必須是外部項目。外部依附元件是指在建構系統外建構及儲存的構件。依附元件是從構件存放區 (通常是透過網際網路存取) 直接匯入,並依原樣使用,而非從原始碼建構。外部依附元件和內部依附元件之間最大的差異之一,在於外部依附元件有版本,而這些版本與專案的來源程式碼無關。

自動與手動依附元件管理

建構系統可讓外部依附元件的版本以手動或自動方式管理。手動管理時,Build 檔案會明確列出要從構件存放區下載的版本,通常會使用 1.1.4語意版本字串。如果自動代管,來源檔案會指定一系列可接受的版本,且建構系統一律會下載最新的版本。舉例來說,Gradle 允許宣告依附元件版本為「1.+」,以指定只要依附元件的大版本為 1,任何次要或修補版本皆可接受。

自動管理的依附元件對小型專案來說或許很方便,但對於規模較大或由多位工程師共同處理的專案,通常會造成災難。自動管理的依附元件問題在於,您無法控制版本更新時間。我們無法保證外部廠商不會發布重大更新 (即使他們聲稱會使用語意版本),因此某天可用的版本可能在下次更新後無法運作,而且我們也無法輕易偵測變更內容或將版本回溯至可運作狀態。即使建構作業未中斷,可能還是無法追蹤細微的行為或效能變更。

相較之下,由於手動管理的依附元件需要變更來源控管,因此可以輕鬆發現及還原,而且可以檢查較舊版本的存放區,以便使用較舊的依附元件進行建構。Bazel 要求您手動指定所有依附元件的版本。即使規模適中,手動版本管理的額外負擔也值得,因為這能帶來穩定性。

單一版本規則

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

允許多個版本的最大問題是菱形依附元件問題。假設目標 A 依附於目標 B,以及外部程式庫的 v1。如果目標 B 稍後重構,以便在相同外部程式庫的 v2 上新增依附元件,目標 A 就會中斷,因為它現在會隱含地依附相同程式庫的兩個不同版本。從目標新增依附元件至具有多個版本的任何第三方程式庫,從實務角度來看,這絕非安全的做法,因為目標的任何使用者都可能已依附於其他版本。遵循「單一版本規則」可避免發生這種衝突,如果目標在第三方程式庫中新增依附元件,任何現有的依附元件都會位於該版本,因此可以順利共存。

遞移外部依附元件

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

這是非常方便的做法:在新的程式庫中新增依附元件時,必須追蹤各個程式庫的遞移依附元件並手動新增所有依附元件,是相當麻煩的做法。但還有一大缺點:由於不同的程式庫可能會依附同一個第三方程式庫的不同版本,因此這項策略一定違反一版本規則,並會導致鑽石依附元件問題。如果目標依附元件會使用相同依附元件的不同版本,您就無法得知會取得哪個版本。這也表示,如果新版本開始擷取部分依附元件的衝突版本,更新外部依附元件可能會導致整個程式碼集出現看似不相關的失敗情形。

因此,Bazel 不會自動下載傳遞依附元件。可惜的是,這沒有萬無一失,幸好,Bazel 提供的工具可自動產生這類檔案,其中包含一組 Maven 構件間接依附元件。您可以執行這項工具一次,為專案產生初始 WORKSPACE 檔案,然後手動更新該檔案,以調整每個依附元件的版本。

這裡的選擇又是便利性和可擴充性之間的取捨。小型專案可能不想自行管理傳遞式依附元件,因此可以使用自動傳遞式依附元件。隨著組織和程式碼庫規模的擴大,這項策略的吸引力也越來越低,衝突和意外結果的發生頻率也越來越高。相較於處理自動依附元件管理所造成的問題,手動管理依附元件的成本大幅低很多。

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

外部依附元件通常由第三方提供,這些第三方會發布穩定版的程式庫,但可能不會提供原始碼。部分機構也可能選擇將部分自有程式碼做為構件提供,讓其他程式碼片段可依附這些構件,而非內部依附元件。理論上,如果構件的建構速度很慢,但下載速度很快,這種作法就可以加快建構速度。

不過,這也會帶來大量額外負擔和複雜性:有人需要負責建構每個構件,並將其上傳至構件存放區,而用戶端也需要確保能隨時取得最新版本。由於系統的不同部分會從存放區的不同位置建構,且來源樹狀結構不再一致,因此偵錯作業也變得更加困難。

如前所述,使用支援遠端快取的建構系統是解決建構時間過長的構件問題。這種建構系統會將每個建構作業的產出結果保存到工程師共用的地點,因此如果開發人員依賴其他人最近建構的構件,建構系統會自動下載該構件,而非建構該構件。這樣一來,就能直接依附構件,享有所有效能優勢,同時也能確保建構作業的一致性,就像一律從相同來源建構一樣。這是 Google 內部使用的策略,並且可以將 Bazel 設定為使用遠端快取。

外部依附元件的安全性和可靠性

依賴第三方來源的構件本身就存在風險。如果第三方來源 (例如構件存放區) 發生異常,就會造成可用性風險,因為如果無法下載外部依附元件,整個建構作業可能會停擺。還有安全風險:如果第三方系統遭攻擊者入侵,攻擊者可將參照的成果替換為自己的其中一項設計,讓攻擊者能夠將任意程式碼插入您的建構作業。您可以將任何依附元件複製到您控管的伺服器,並阻止建構系統存取 Maven Central 等第三方構件存放區,藉此緩解這兩個問題。但這些鏡像需要花費心力和資源才能維護,因此是否使用鏡像,通常取決於專案規模。您也可以要求在原始碼存放區中指定每個第三方構件的雜湊,藉此完全避免安全性問題,且不必付出太多成本,因為如果有人竄改構件,這麼做就會導致建構作業失敗。另一個完全避開這個問題的做法,就是將專案的依附元件外包。當專案供應商提供其依附元件時,會將依附元件與專案的原始碼一起納入原始碼控制,以原始碼或二進位檔的形式。這實際上表示專案的所有外部依附元件都會轉換為內部依附元件。Google 在內部使用這種方法,檢查 Google 中所有參照的第三方程式庫,並將這些程式庫放入 Google 來源樹狀結構根目錄中的 third_party 目錄。不過,這種做法只適用於 Google,因為 Google 的來源控管系統是專門用於處理極大型單一存放區,因此供應商可能不是所有機構的選項。