依附元件管理

回報問題 查看來源

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

處理模組和依附元件

使用 Bazel 等構件型建構系統的專案會分為一組模組,而模組會透過 BUILD 檔案表示彼此的依附元件。將這些模組和依附元件妥善整理,可能會對建構系統的效能以及維護工作量產生重大影響。

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

建構以構件為基礎的建構作業時,第一個想出的問題是決定個別模組應涵蓋的功能。在 Bazel 中,模組會以目標表示,例如 java_librarygo_binary 等可建構的單元。在極端情況下,只要將一個 BUILD 檔案放在根層級,再以遞迴方式繪製該專案的所有來源檔案,即可在單一模組中納入整個專案。另一方面,幾乎每個來源檔案都可以設為各自的模組,有效要求每個檔案都列在 BUILD 檔案中每兩個依附的各檔案。

大多數專案都位於這些極端之間,因此在選擇方面,必須在效能和可維護性之間取得平衡。為整個專案使用單一模組可能表示除了新增外部依附元件時,您完全不需要輕觸 BUILD 檔案,但這意味著建構系統必須一次建構整個專案。這表示這個 API 無法平行處理或發布建構的某些部分,也無法快取建構的部分。每個檔案一個模組則相反:建構系統對建構的快取和排程步驟具有最大彈性,但工程師必須在變更依附元件清單時投入更多心力維護依附元件清單。

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

較小的建構目標實際上會開始大規模顯示,因為這類目標可加快分散式建構的速度,也比較不需要重新建構目標。測試進入相片後,優點更是驚人,因為精細的目標越精細,建構系統就能更聰明地執行可能受任何指定變更影響的一小部分測試。由於 Google 相信使用規模較小目標帶來系統方面的效益,因此我們已投注心力開發可自動管理 BUILD 檔案的工具,以避免對開發人員造成負擔,因此大幅減少了缺點。

其中部分工具 (例如 buildifierbuildozer) 可透過 buildtools 目錄中的 Bazel 取得。

最小化模組顯示設定

Bazel 和其他建構系統允許每個目標指定瀏覽權限,這是一種屬性來決定其他目標可能會依附於該屬性。私人目標只能在自己的 BUILD 檔案中參照。目標可能會將明確定義的 BUILD 檔案清單目標授予更廣泛的瀏覽權限,或者在公開瀏覽權限的情況下,這些目標可能會指派給工作區中的每個目標。

與大部分的程式設計語言一樣,一般而言,最好盡可能盡量減少瀏覽權限。一般來說,Google 團隊只有在這些目標代表 Google 任何團隊廣泛使用的程式庫時,才會公開目標。如果團隊要求其他人在運用程式碼前協調,則應保留客戶目標許可清單,做為目標可見性。每個團隊的內部實作目標都僅限於團隊擁有的目錄,而大多數 BUILD 檔案都只有一個非私人目標。

管理依附元件

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

內部依附元件

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

遞移依附元件

圖 1:遞移依附元件

在基礎工具上,這個問題不會有問題;B 和 C 在建構時都會連結至目標 A,因此在 C 中定義的任何符號都會得知 A。Bazel 已提供這樣的技術多年,但隨著 Google 的發展,我們勢必會遇到一些問題。假設 B 經重構,使其不再需要依附 C。如果在 C 上移除 B 的依附元件時,又透過 B 上的依附元件使用 C 的任何其他目標,則 A 和任何其他目標都會中斷。實際上,目標的依附元件成為公開合約的一部分,並且永遠無法安全變更。這意味著隨著時間的推移,Google 的建構作業也會開始放慢速度。

最後,Google 在 Bazel 採用了「嚴格的遞移依附元件模式」,因此解決了這個問題。在這個模式下,Bazel 會偵測目標嘗試在不直接仰賴符號的情況下嘗試參照符號;如果參照該符號,則會失敗並顯示錯誤,並提供可用來自動插入依附元件的殼層指令。在 Google 的整個程式碼集中導入這項變更,以及逐一重構每個數百萬個建構目標來明確列出其依附元件需要花費多年時間,但這項做法很值得一試。現在已可加快建構速度,因為目標的非必要依附元件較少,工程師也能移除不需要的依附元件,不必擔心會破壞依附目標。

和往常一樣,強制執行嚴格的遞移依附元件往往需要取捨。使用時,建構檔案會變得更加精簡,因為現在常用程式庫是在許多地方明確列出 (而非在事件上提取),工程師需要花費更多心力在 BUILD 檔案中加入依附元件。自此之後,我們開發了多項工具,自動偵測許多缺少的依附元件,並在無須開發人員介入的情況下將依附元件新增到 BUILD 檔案,藉此減少手動作業。然而,即使不使用這類工具,我們仍認為程式碼集擴大的權衡利弊得失:在 BUILD 檔案中明確新增依附元件是一次性的費用,但處理隱含的遞移依附元件只要存在建構目標,也可能會導致持續發生問題。根據預設,Bazel 會在 Java 程式碼上強制執行嚴格的遞移依附元件

外部依附元件

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

自動與手動依附元件管理

建構系統可允許手動或自動管理外部依附元件的版本。手動管理時,建構檔案會明確列出想要從構件存放區下載的版本,通常使用語意版本字串,例如 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 的來源控制系統是專為處理極大的單聲道存放區而打造,因此不一定適用於所有機構。