Rush Stack商店部落格活動
跳到主要內容

幽靈相依性

Rush 的文件偶爾會提到「幽靈」和「分身」。想要進一步了解 JavaScript 套件管理員如何運作嗎?

一些歷史和一些理論

大家都知道軟體套件可以相依於其他套件,而產生的相依性圖表是電腦科學中的一種有向無環圖。與樹狀資料結構不同,有向無環圖可以有重新結合的菱形分支。例如,程式庫 A 可能會從程式庫 BC 匯入定義,但接著 BC 都可以從 D 匯入,這會在這四個套件之間建立「菱形相依性」。依照慣例,程式語言的模組解析器會藉由追蹤此圖表的邊緣來查詢匯入的套件,而且 (在其他系統中) 套件本身會位於可由許多專案共用的中央儲存空間中。

由於歷史因素,NodeJS 和 NPM 採用了不同的方法,藉由在磁碟上實際呈現此圖表:NPM 使用實際的套件資料夾副本來模擬圖表的頂點,而圖表的邊緣則由子資料夾關聯性所暗示。但是資料夾樹狀結構的分支無法重新結合形成菱形。為了處理此問題,NodeJS 新增了一個特殊解析規則,其作用是引入額外的圖表邊緣 (指向所有父資料夾的直接子系)。從電腦科學的角度來看,此規則放寬了檔案系統的樹狀資料結構,有兩種方式:(1) 它現在可以表示某些 (但不是所有) 有向無環圖,而且 (2) 我們會取得一些額外的邊緣,這些邊緣並不對應任何已宣告的套件相依性。這些額外的邊緣稱為「幽靈相依性」。

NPM 的方法有許多與傳統套件管理員不同的獨特特性

  • 每個 (根層級) 專案都會取得自己的 node_modules 樹狀結構,其中包含許多套件資料夾副本。即使是很小的 NodeJS 專案,其資料夾下也可能複製了 10,000 多個檔案。

  • 在 NPM 2.x 中,node_modules 資料夾樹狀結構非常深且重複,這可將幽靈相依性降至最低。NPM 3.x 改進了安裝演算法以使樹狀結構扁平化,這消除了許多重複,但代價是引入了更多幽靈相依性 (額外的圖表邊緣)。在某些情況下,新演算法也會選擇稍舊版本的套件 (同時仍滿足 SemVer),以進一步減少套件資料夾的重複。

  • 已安裝的 node_modules 樹狀結構不是唯一的。有許多種可能的方式可將套件資料夾排列成樹狀結構,以逼近有向無環圖,而且沒有唯一的「正規化」排列。您取得的樹狀結構取決於您的套件管理員選擇遵循的任何啟發式方法。NPM 自己的啟發式方法甚至對您新增套件的順序很敏感。

node_modules 樹狀結構是一種不尋常且在理論上很有趣的資料結構。但讓我們專注於可能導致實際麻煩的三個後果,而且在大型且非常活躍的單一儲存庫中可能特別難以診斷。我們也會說明 Rush 如何改進這些問題 -- 減輕這些問題是建立 Rush 工具的最初動機之一!

幽靈相依性

NPM phantom dependency

當專案使用未在其 package.json 檔案中定義的套件時,就會發生「幽靈相依性」。請考慮此範例

my-library/package.json

{
"name": "my-library",
"version": "1.0.0",
"main": "lib/index.js",
"dependencies": {
"minimatch": "^3.0.4"
},
"devDependencies": {
"rimraf": "^2.6.2"
}
}

但假設程式碼看起來像這樣

my-library/lib/index.js

var minimatch = require('minimatch');
var expand = require('brace-expansion'); // ???
var glob = require('glob'); // ???

// (more code here that uses those libraries)

等一下 -- 其中兩個程式庫並未在 package.json 檔案中宣告為相依性。這到底是如何運作的!?事實證明,brace-expansionminimatch 的相依性,而 globrimraf 的相依性。在安裝期間,NPM 已將其資料夾展平到 my-library/node_modules 下。NodeJS 的 require() 函數會在那裡找到它們,因為它會探查資料夾,而不會考慮 package.json 檔案。這可能違反直覺,但它似乎運作良好。也許這是一個功能而不是錯誤?

不幸的是,此專案的遺失宣告最好被視為錯誤。這可能會導致非預期的故障或錯誤

  • 不相容的版本: 雖然我們的程式庫的 package.json 宣告它需要 minimatch 版本 3,但我們無法決定我們將取得的 brace-expansion 版本。 SemVer 系統minimatch 的 PATCH 版本併入 brace-expansion 程式庫的主要升級是完全合法的,只要它不影響 minimatch 的 API 簽章即可。實際上,我們作為 my-library 的開發人員可能永遠不會遇到此情況 -- 相反地,它會被可憐的受害者發現,他們稍後在某些非常不同的 node_modules 排列中安裝我們已發佈的程式庫,這些排列具有比我們定期測試更新 (或更舊) 的版本限制。

  • 遺失的相依性: glob 套件來自我們的 devDependencies,這表示它只會安裝給使用 my-library 專案的開發人員。對於其他使用者而言,require("glob") 應該會立即失敗並出現錯誤,因為根本不會為他們安裝 glob。我們應該會在發佈 my-library 套件後立即聽到這件事,對嗎?不完全是。實際上,大多數使用者也可能因為某些原因而有 glob (例如,他們自己使用 rimraf),因此它可能會看似運作。只有一小部分使用者會遇到失敗的匯入錯誤,讓它看起來像他們在回報難以重現的怪異問題。

Rush 如何提供協助: Rush 的符號連結策略確保每個專案的 node_modules 只包含其宣告的直接相依性。這會在建置時立即找出幽靈相依性。如果您使用 PNPM 套件管理員,相同的保護也會套用至所有間接相依性 (能夠藉由使用 pnpmfile.js 來解決任何「不良」套件)。

幽靈 node_modules 資料夾

假設我們有一個單一儲存庫,而有人新增了像這樣的根層級 package.json 檔案

my-monorepo/package.json:

{
"name": "my-monorepo",
"version": "0.0.0",
"scripts": {
"deploy-app": "node ./deploy-app.js"
},
"devDependencies": {
"semver": "~5.6.0"
}
}

這允許使用者執行 npm run deploy-app,而我們的指令碼會自動部署單一儲存庫中的所有專案。(如果您使用 Rush,請勿執行此動作!請改為定義自訂命令。) 請注意,這個假設的指令碼需要使用 semver 程式庫,因此已將它新增至 devDependencies 清單。系統會要求使用者在 npm run deploy-app 之前在儲存庫根資料夾中執行 npm install

產生的已安裝資料夾看起來會像這樣

- my-monorepo/
- package.json
- node_modules/
- semver/
- ...
- my-library/
- package.json
- lib/
- index.js
- node_modules/
- brace-expansion
- minimatch
- ...

但請回想一下,NodeJS 的模組解析器會探查父資料夾中的相依性。這表示我們的 my-library/lib/index.js 可以呼叫 require("semver") 並找到 semver 套件,即使它未出現在 my-library/node_modules 下的任何位置。這是一種更陰險的方式來取得意外的幽靈相依性 -- 它有時可以找到甚至不在您的 Git 工作目錄下的 node_modules 資料夾!

Rush 如何提供協助: Rush 會為您提供保障。如果找到任何幽靈 node_modules 資料夾,rush install 命令會掃描所有潛在的父資料夾並發出警告。