1. 程式人生 > >深入理解NPM依賴模型

深入理解NPM依賴模型

npm是目前前端開發領域最主流的包管理器。雖然有其他選擇,但npm的江湖地位短期內已經無法撼動。即便是強大如Bower這樣可以管理前端所有檔案的工具也被推到了一邊,對我來說最有趣的是npm相對新穎的依賴管理方法。不幸的是,根據我的經驗,它實際上並沒有那麼好理解,所以我嘗試在這裡澄清它是如何工作的,以及它如何影響使用者或包開發者的。

基礎回顧

站在較高的層次上來說,npm與其他程式語言的包管理器並沒有太大不同:包依賴於其他包,它們用版本範圍表示這些依賴關係。npm碰巧使用semver版本控制方案來表達這些範圍,但它執行版本解析的方式大多不重要; 重要的是包可以依賴於範圍而不是特定版本的包。

這在任何生態系統中都非常重要,因為將庫鎖定到一組特定的依賴項可能會導致嚴重的問題,但與其他類似的軟體包系統相比,它在npm的情況下實際上要少得多。實際上,對於庫作者來說,將依賴關係固定到特定版本而不影響依賴包或應用程式通常是安全的。棘手的一點是確定何時這是安全的,什麼時候不安全,這就是我經常發現人們出錯的原因。

重複依賴和依賴樹

npm的大多數使用者(或者至少是大多數軟體包作者)最終都知道,與其他軟體包管理器不同,npm安裝了一個依賴樹。也就是說,每個安裝的軟體包都有自己的一組依賴項,而不是強制每個軟體包共享相同的規範軟體包。顯然,現在幾乎每個包管理器都必須在某個時刻對依賴樹進行建模,因為這就是程式設計師表達依賴關係的方式。

例如,考慮兩個包,foobar。 它們中的每一個都有自己的依賴項集,可以表示為樹:

foo
├── hello ^0.1.2
└── world ^1.0.7

bar
├── hello ^0.2.8
└── goodbye ^3.4.0

想象一下依賴於foo和bar的應用程式。顯然,world

goodbye 的依賴關係完全不相關,所以npm如何處理它們相對無趣。 但是,請考慮hello的情況:兩個包都需要衝突的版本。

大多數軟體包管理器(包括RubyGems/Bundler,pip和Cabal)都會在這裡進行barf,報告版本衝突。這是因為,在大多數軟體包管理模型中,一次只能安裝任何特定軟體包的一個版本。 從這個意義上說,軟體包管理器的主要職責之一是找出一組將同時滿足每個版本約束的軟體包版本。

相比之下,npm有一個更容易的工作:它完全可以安裝同一個包的不同版本,因為每個包都有自己的依賴集。在前面提到的示例中,生成的目錄結構如下所示:

node_modules/
├── foo/
│   └── node_modules/
│       ├── hello/
│       └── world/
└── bar/
    └── node_modules/
        ├── hello/
        └── goodbye/

值得注意的是,目錄結構非常接近實際的依賴樹。上面的圖是一個簡化:在實踐中,每個傳遞依賴都有自己的node_modules目錄,依此類推,但目錄結構很快就會變得非常混亂。(此外,npm 3執行一些優化以嘗試在可能的情況下共享依賴關係,但最終不需要實際理解模型)

當然,這個模型非常簡單。顯而易見的效果是每個軟體包都有自己的小沙箱,對於像ramda,lodash或underscore這樣的實用程式庫來說,它非常出色。如果foo取決於[email protected]^0.19.0但是bar取決於[email protected]^0.22.0,它們可以完全和平共存而沒有任何問題。

乍一看,只要底層執行時支援所需的模組載入方案,該系統顯然優於替代的平面模型。然而,它並非沒有缺點。

最明顯的缺點是程式碼大小顯著增加,因為同一個軟體包的許多副本可能具有不同的版本。程式碼大小的增加通常意味著不僅僅是一個更大的程式 - 它可以對效能產生重大影響。較大的程式不能輕易地適應CPU快取,只需要一些快取頁的寫入和讀取就可以顯著降低速度。然而,這主要只是一種權衡,因為你犧牲了效能,而不是程式的正確性。

更加隱蔽的問題(以及我在npm生態系統中看到的那個沒有太多考慮的問題)是依賴隔離如何影響跨包通訊。

依賴性隔離和傳遞包邊界的值

使用ramda的早期示例是npm的預設依賴關係管理方案真正可圈可點的地方,因為Ramda只提供了一堆簡單的函式。通過這些是完全無害的。事實上,兩個不同版本的Ramda混合功能將是完全可以的!不幸的是,並非所有情況都如此簡單。

考慮一下React,React元件通常不是普通的舊資料;它們是複雜的值,可以通過各種方式進行擴充套件,例項化和呈現。React使用內部私有格式表示元件結構和狀態,使用精心安排的鍵和值的混合以及JavaScript的物件系統的一些更強大的功能。這個內部結構可能會在React版本之間發生很大的變化,因此使用[email protected]定義的React元件可能無法正常使用[email protected]

考慮到這一點,請考慮兩個包定義自己的React元件並將其匯出供消費者使用。檢視它們的依賴樹,我們可能會看到如下內容:

awesome-button
└── react ^0.3.0

amazing-modal
└── react ^15.3.1

鑑於這兩個軟體包使用了非常不同的React版本,npm將根據請求為每個軟體包提供自己的React副本,並且可以愉快地安裝軟體包。但是,如果您嘗試將這些元件一起使用,它們根本不起作用!較新版本的React根本無法理解舊版本的元件,因此您可能會遇到執行時錯誤,這些錯誤資訊往往莫名其妙。

什麼地方出了錯?好吧,當包的依賴性純粹是實現細節時,依賴性隔離很有效,這些細節從外部是感知不到的。但是,只要包的依賴性作為其介面的一部分公開,依賴性隔離不僅會出現細微錯誤,而且會在執行時導致完全失敗。在傳統的依賴關係管理下,這種情況要好得多,他們會在你嘗試安裝兩個他們不能一起工作的軟體包時告訴你,而不是等著你自己解決這個問題。

這可能聽起來不是太糟糕 - 畢竟,JavaScript是一種非常動態的語言,所以靜態的形式保證可用性幾乎都很少,而且如果出現這些問題,你的測試應該能夠捕獲這些問題 - 但是當兩個軟體包在理論上可以一起工作很好時,它會導致不必要的問題,但是因為npm為每個人分配了一個特定包的副本(也就是說,它不夠聰明,弄清楚它可以給他們兩個相同的副本),所以事情就會崩潰。

特別是在npm之外看,並且在應用於其他語言時考慮這個模型,這一點越來越明顯,這是行不通的。這篇部落格文章的靈感來自一個討論應用於Haskell的npm模型的Reddit執行緒,這個漏洞被吹捧為它無法用於這種靜態語言的原因。

由於JavaScript生態系統的發展方式,大多數人都可以較為輕鬆地擺脫這種不正確行為所帶來的潛在問題,而不引入任何新的問題。具體來說,JavaScript傾向於依賴duck型別而不是像instanceof這樣的更嚴格的檢查,因此滿足相同協議的物件仍然是相容的,即使它們的實現不完全相同。 但是,npm實際上為這個問題提供了一個強大的解決方案,允許包作者明確表達這些“跨介面”依賴關係。

對等依賴

通常,npm包依賴項列在包的package.json檔案中的“dependencies”鍵下。但是,有一個名為“peerDependencies”的另一個較少使用的金鑰,其格式與普通依賴項列表相同。區別在於npm如何執行依賴項解析:包不是獲取自己的對等依賴項副本,而是期望依賴項由其依賴項提供。

這實際上意味著使用像Bundler和Cabal這樣的工具使用的“傳統”依賴解析機制有效地解決了對等依賴:必須有一個滿足每個人約束的規範版本。從npm 3開始,事情就不那麼簡單了(具體來說,除非依賴包明確地依賴於對等包本身,否則不會自動安裝對等依賴關係),但基本思想是相同的。這意味著包作者必須為他們安裝的每個依賴項做出選擇:它應該是普通依賴還是對等依賴?

這是我認為人們往往會有點疑惑的地方,即使是那些熟悉對等依賴機制的人。幸運的是,答案相對簡單:在程式包可見的任何位置都可以看到有問題的依賴項?

這有時很難在JavaScript中看到,因為“型別”是不可見的; 也就是說,它們是動態的,很少明確地寫出來。但是,僅僅因為型別是動態的並不意味著它們在執行時(並且在各種程式設計師的頭腦中)不存在,所以規則仍然成立:如果包的公共介面中的函式型別以某種方式依賴於依賴性,它應該是對等依賴。

為了使這更具體一點,讓我們看幾個例子。 首先,讓我們看看一些簡單的案例,從ramda的一些用法開始:

import { merge, add } from 'ramda'

export const withDefaultConfig = (config) =>
  merge({ path: '.' }, config)

export const add5 = add(5)

這裡的第一個例子非常明顯:在withDefaultConfig中,merge純粹用作實現細節,因此它是安全的,並且它不是模組介面的一部分。 在add5中,這個例子有點棘手:add(5)的結果是由Ramda建立的部分應用函式,所以從技術上講,Ramda建立的值是該模組介面的一部分。但是,add5與外界的合約只是它是一個JavaScript函式,它的引數增加了五個,並且它不依賴於任何特定於Ramda的功能,所以ramda可以安全地成為非同類依賴。

現在讓我們看一下使用jpeg影象庫的另一個例子:

import { Jpeg } from 'jpeg'

export const createSquareBuffer = (size, cb) =>
  createSquareJpeg(size).encode(cb)

export const createSquareJpeg = (size) =>
  new Jpeg(Buffer.alloc(size * size, 0), size, size)

在這種情況下,createSquareBuffer函式使用普通的Node.js Buffer物件呼叫回撥,因此jpeg庫是一個實現細節。如果這是該模組公開的唯一函式,則jpeg可以安全地成為非對等依賴項。但是,createSquareJpeg函式違反了該規則:它返回一個Jpeg物件,該物件是一個不透明的值,其結構由jpeg庫專門定義。 因此,具有上述模組的包必須將jpeg列為對等依賴項。

這種限制也是相反的。例如,請考慮以下模組:

import { writeFile } from 'fs'

export const writeJpeg = (filename, jpeg, cb) =>
  jpeg.encode((image) => fs.writeFile(filename, image, cb))

上面的模組甚至沒有匯入jpeg包,但它隱含地依賴於Jpeg介面的encode方法。因此,儘管在程式碼中的任何地方都沒有明確地使用它,但是包含上述模組的包應該包括jpeg作為對等依賴。

關鍵是要仔細考慮您的模組與其依賴的互動細節。如果這些互動以任何方式涉及其他包,則它們應該是對等依賴關係。如果他們不這樣做,他們應該是普通的依賴。

將npm模型應用於其他語言

包管理的npm模型比其他語言更復雜,但它提供了一個真正的優勢:實現細節保留為實現細節。在其他系統中,當你個人知道包管理器報告的版本衝突不是真正的問題,但是因為包系統必須選擇一個規範版本時,很有可能發現自己處於“依賴地獄”中,沒有辦法在不調整依賴項中的程式碼的情況下取得進展。這非常令人沮喪。

這種依賴性隔離並不是現有的最先進的包管理形式 - 實際上遠非如此 - 但它肯定比其他大多數主流系統更強大。當然,大多數其他語言只是通過更改包管理器就無法採用npm模型:擁有全域性包名稱空間可以防止在執行時級別安裝同一包的多個版本。npm能夠做它的功能是因為Node本身支援它。

也就是說,對等和非對等依賴之間的二分法有點令人困惑,特別是對於不是包作者的人。弄清楚哪個包需要進入哪個組並不總是明顯或微不足道的。幸運的是,其他語言也許可以提供幫助。

返回Haskell,其強大的靜態型別系統可能會完全自動地檢測到這種區別,而當暴露的介面中使用的包未被列為對等依賴時,Cabal實際上可能會報告錯誤(就像它當前阻止匯入的方式一樣)傳遞依賴而不明確依賴它。這將允許幫助程式功能包繼續保持實現細節,同時仍保持強大的介面安全性。這可能需要做很多工作才能正確管理型別類例項的全域性特性,這可能會比天真的方法更加複雜 - 但它會增加一層目前尚不存在的靈活性。

從JavaScript的角度來看,npm已經證明它可以成為一個有能力的包管理器,儘管不斷增長的,不斷變化的JS生態系統給它帶來了巨大的負擔。作為一個軟體包作者自己,我會懇請其他使用者仔細考慮對等依賴關係功能,並努力使用它來編碼他們的介面契約 - 這是npm模型中一個常被誤解的寶石,我希望這篇博文有助於解決這個問題。