原文:How Rust Solved Dependency Hell
每隔一段時間我就會參與一個關於依賴管理和版本的對話,通常是在工作中,其中會出現“依賴地獄”的主題。如果你對這個術語不熟悉,那麼我建議你查一下。簡要總結可能是:“處理應用程式依賴版本和依賴衝突所帶來的挫敗感”。帶著這個,讓我們先獲得關於依賴解析的一些技術。
問題
在討論包應該具有哪種依賴關係以及哪些依賴關係可能導致問題時,本主題通常會進入討論。作為一個真實的例子,在 Widen Enterprises,我們有一個內部的,可重用的Java框架,它由幾個軟體包組成,為我們提供了建立許多內部服務的基礎(如果你願意的話,微服務)。這很好,但是如果你想建立一個依賴於框架中某些東西的可重用共享程式碼庫呢?如果你嘗試在應用程式中使用這樣的庫,最終可能會得到如下依賴關係圖:
就像在這個例子中一樣,每當你試圖在服務中使用庫時,你的服務和庫很可能依賴於不同版本的框架,這就是“依賴地獄”的開始。
現在,在這一點上,一個好的開發平臺將為你提供以下兩種選擇的組合:
- 使構建失敗並警告我們
framework
版本21.1.1
和21.2.0
相互衝突。 - 使用語義版本控制允許包定義與其相容的 一系列 版本。如果幸運的話,兩個軟體包都相容的版本集是非空的,你最終可以在應用程式中自動使用其中一個版本。
這兩個看起來都合理,對吧?如果兩個軟體包確實彼此不相容,那麼我們根本無法在不修改其中一個的情況下將它們一起使用。這是一個艱難的情況,但替代方案往往更糟糕。事實上,Java是不該學習的一個很好的例子:
- 預設行為是允許將依賴項的多個版本新增到類路徑(Java的定位類的方式)。當應用程式需要庫中的類時,實際使用哪個版本?在實踐中,類的載入順序因環境而異,甚至以非確定的方式執行,因此你實際上不知道將使用哪一個。哎呀!
- 我們在Widen使用的另一個選擇是強制版本對齊。這類似於之前的第二個合理選擇,在Java中,依賴關係無法表達相容性範圍,因此我們只選擇較新的可能依賴項並祈禱它仍然有效。在前面顯示的依賴關係圖示例中,我們將強制
app
升級到framework 21.2.0
。
這看起來像是一個雙輸的情況,所以你可以想象,這對新增依賴項非常不利,並且使之成為一個事實上的策略,除了實際的應用程式之外什麼都不允許依賴我們的核心框架。
Rust的解決方案
在進行這些討論時,我會經常提到這是一個不適用於所有語言的問題,作為一個例子,Rust“解決”了這個問題。我常常拿Rust如何解決世界上所有的問題開玩笑,但在那裡通常有一個真實的核心。因此,當我說Rust“解決”了這個問題以及它是如何工作的時候,讓我們深入瞭解一下我的意思。
Rust的解決方案涉及相當多的動人的部分,但它基本上歸結為挑戰我們在此之前做出的核心假設:
最終應用程式中只應存在任何給定包的一個版本。
Rust挑戰了這一點,以便重構問題,看看是否有一個在依賴地獄之外更好的解決方案。Rust平臺主要有兩個功能可以協同工作,為解決這些依賴問題提供基礎,現在我們將分別研究並看看最終結果是怎樣的。
Cargo和Crates
難題的第一部分當然是Cargo,Rust官方依賴管理器。Cargo類似於NPM或Maven之類的工具,並且有一些有趣的功能使它成為一個真正高質量的依賴管理器(這裡我最喜歡的是Composer,一個非常精心設計的PHP依賴管理器)。Cargo負責下載專案依賴的Rust庫,稱為crates,並協調呼叫Rust編譯器以獲得最終結果。
請注意,crates是編譯器中的第一類構造。這在以後很重要。
與NPM和Composer一樣,Cargo允許你根據語義版本控制的相容性規則指定專案相容的一系列依賴項版本。這允許你描述與你的程式碼相容(或可能)相容的一個或多個版本。例如,我可能會新增
[dependencies]
log = "0.4.*"
複製程式碼
到Cargo.toml
檔案,表明我的程式碼適用於0.4
系列中log
包的任何補丁版本。也許在最終的應用程式中,我們得到了這個依賴樹
因為在my-project
中我宣告瞭與log
版本0.4.*
的相容性,我們可以安全地為log
選擇版本0.4.4
,因為它滿足所有要求。(如果log
包遵循語義版本控制的原則,這個原則對於已釋出的庫而言並不總是如此,那麼我們可以確信這個釋出不包括任何會破壞我們程式碼的重大更改。)你可以在Cargo文件中找到一個更好地解釋版本範圍以及它們如何應用於Cargo。
太棒了,所以我們可以選擇滿足每個專案版本要求的最新版本,而不是選擇避開遇到版本衝突或只是選擇更新的版本並祈禱。但是,如果我們遇到無法解決的問題,例如:
沒有可以選擇滿足所有要求的log
版本!我們接下來做什麼?
名字修飾
為了回答這個問題,我們需要討論名字修飾。一般來說,名字修飾是一些編譯器用於各種語言的過程,它將符號名稱作為輸入,並生成一個更簡單的字串作為輸出,可用於在連結時消除類似命名符號的歧義。例如,Rust允許你在不同模組之間重用識別符號:
mod en {
fn greet() {
println!("Hello");
}
}
mod es {
fn greet() {
println!("Hola");
}
}
複製程式碼
這裡我們有兩個不同的函式,名為greet()
,但當然這很好,因為它們在不同的模組中。這很方便,但通常應用程式二進位制格式沒有模組的概念;相反,所有符號都存在於單個全域性名稱空間中,非常類似於C中的名稱。由於greet()
在最終二進位制檔案中不能顯示兩次,因此編譯器可能使用比原始碼更明確的名稱。例如:
en::greet()
成為en__greet
es::greet()
成為es__greet
問題解決了!只要我們確保這個名字修飾方案是確定性的並且在編譯期間到處使用,程式碼就會知道如何獲得正確的函式。
現在這不是一個完全完整的名字修飾方案,因為我們還沒有考慮很多其他的東西,比如泛型型別引數,過載等等。此功能也不是Rust獨有的,並且確實在C++和Fortran等語言中使用了很長時間。
名字修飾如何幫助Rust解決依賴地獄?這一切都在Rust的名字管理體系中,這似乎在我所研究的語言中相當獨特。那麼讓我們來看看?
在Rust編譯器中查詢名字修飾的程式碼很簡單;它位於一個名為symbol_names.rs
的檔案中。如果你想學習更多內容,我建議你閱讀這個檔案中的註釋,但我會包括重點。似乎有四個基本元件包含在一個修飾符號名稱中:
- 符號的完全限定名稱。
- 通用型別引數。
- 包含符號的crate的名稱。(還記得crates在編譯器中是一流的嗎?)
- 可以通過命令列傳入的任意“歧義消除器(disambiguator)”字串。
使用Cargo時,Cargo本身會將“歧義消除器”提供給編譯器,所以讓我們看一下compilation_files.rs
包含的內容:
- 包名字
- 包源
- 包版本
- 啟用編譯時功能
- 一堆其他的東西
這個複雜系統的最終結果是,即使是不同版本的crate中的相同功能也具有不同的修飾符號名稱,因此只要每個元件知道要呼叫的函式版本,就可以在單個應用程式中共存。
合在一起
現在回到我們之前的“無法解決的”依賴圖:
藉助依賴範圍的強大功能,以及Cargo和Rust編譯器協同工作,我們現在可以通過在我們的應用程式中包含log 0.5.0
和log 0.4.4
來實際解決此依賴關係圖。app
內部使用log
的任何程式碼都將被編譯以達到從0.5.0
版生成的符號,而my-project
中的程式碼將使用為0.4.4
版生成的符號。
現在我們看到了大局,這實際上看起來非常直觀,並解決了一大堆依賴問題,這些問題會困擾其他語言的使用者。這個解決方案並不完美:
- 由於不同版本生成不同的唯一識別符號,因此我們無法在庫的不同版本之間傳遞物件。例如,我們無法建立一個
log 0.5.0
的LogLevel
並將其傳遞給my-project
使用,因為它期望LogLevel
來自log 0.4.4
,並且它們必須被視為單獨的型別。 - 對於庫的每個例項,任何靜態變數或全域性狀態都將被複制,如果沒有一些特殊方法,它們就無法通訊。
- 我們的二進位制大小必然會因為我們應用程式中包含的庫的每個例項而增加。
由於這些缺點,Cargo僅在需要時才採用這種技術來解決依賴圖。
為了解決一般用例,這些似乎值得為Rust做出權衡,但對於其他語言,採用這樣的東西可能會更加困難。以Java為例,Java嚴重依賴於靜態欄位和全域性狀態,因此簡單地大規模採用Rust的方法肯定會增加破壞程式碼的次數,而Rust則將全域性狀態限制在最低限度。這種設計也沒有對在執行時或反射時載入任意庫進行說明,這兩者都是許多其他語言提供的流行功能。
結論
Rust在編譯和打包方面的精心設計以(主要)無痛依賴管理的形式帶來紅利,這通常消除了可能成為開發人員在其他語言中最糟糕的噩夢的整類問題。當我第一次開始玩Rust的時候,我當然很喜歡我所看到的,深入瞭解內部,看到巨集大的架構,周到的設計,以及合理的權衡取捨對我來說更令人印象深刻。這只是其中的一個例子。
即使你沒有使用Rust,希望這會讓你對依賴管理器,編譯器以及他們必須解決的棘手問題給予新的重視。(雖然我鼓勵你至少嘗試一下Rust,當然......)
GitHub repo: qiwihui/blog
Follow me: @qiwihui
Site: QIWIHUI