開發上為了方便,常常使用別人開發好的套件,但是最近遇到幾次衝突,發現套件的版本管理沒有想像中簡單,以下將釐清 npm / gem+bundler 在套件的
- 安裝
- 載入方式
- 子套件的版本衝突 做更深入的了解與比對
實驗方式會準備 test_module_1 / test_module_2,分別使用 depend_module version 1 / version 2,最後同時使用 test_module_1 & 2
|
|
TLDR;NPM 可以在不同模組引用不同的版本;而 Gem 不行;Golang 可以透過 replace 指定多個版本
NPM
NPM install
節錄 NPM 7.x npm-install 官方文件部分內容
$npm install
主要是協助安裝 package 所相依的 packages,所謂的 package 是
- 資料夾中有 package.json
- gzip 壓縮的 tar 檔 (1),也就是把有 package.json 的資料夾壓縮
- 指向 (2) 的 url,例如
$npm install https://github.com/indexzero/forever/tarball/v0.5.6
- 指向 (1) 的 git remote url
- 被發佈到 registry 的 (3),例如
npm install git+ssh://git@github.com:npm/cli#semver:^5.0
等
在 package 路徑下執行 $npm install,則會安裝 packages 在 node_modules 中;-g
會安裝到 global 環境下;--production
則不會安裝 devDependencies 下的 package
如果要安裝同一個 package 不同版本並同時使用,在 npm 6.9.0 以後可以用 alias
|
|
載入 Nodejs require
節錄自 Nodejs v16.4.2 Modules: CommonJS modules,這邊先探討 Commonjs Module 而不是 ESM
在 Nodejs 中,每一個 file 就是一個獨立的 module
,可以透過 require 來引入,當 require(X) 在 Path Y 下發生時,會嘗試以下步驟
- 如果 X 是 core module,則返回 core module 並結束
- 如果 X 是 / 開頭,則將 Y 設定為 filesystem root (沒用過)
- 如果 X 是 ./、 ../ 、 / 開頭,則嘗試載入對應路徑的檔案或資料夾
- 如果 X 是 # 開頭,則往上層找到最近有 package.json 的地方 (稱為 scope),並走 ESM 載入方式
- 找到 scope,比對 package.json 中定義,看是不是要載入自己
- 不斷地往上一層路徑找到 node_modules,並查詢有沒有對應的 package
實際載入的過程挺複雜的,只要載入一次後 package 就會被 cache 起來,可以從 require.cache
中看到被 cache 的狀況,所以如果一個套件被多個套件引用不同的版本,有可能因為 cache 而導致某些套件使用錯誤的版本嗎?
看起來是不會的,因為文件提到 module cache 是基於他們被解析的 filename,因此不同版本的 package 會安裝在不同的 node_modules 下,所以解析的 filename 自然也不同
Modules are cached based on their resolved filename. Since modules may resolve to a different filename based on the location of the calling module (loading from node_modules folders), it is not a guarantee that require(‘foo’) will always return the exact same object, if it would resolve to different files.
版本實驗
本地端使用 Nodejs@14.15.4 / NPM@6.14.0,我發佈了
- depend_module 1.0.0 / 2.0.0
- yuan_test_module_1 require depend_module@1
- yuan_test_module_2 require depend_module@2
最後
|
|
出乎我意料之外,node_modules 的結構是
|
|
我沒有預期第一層結構中會有 depend_module,以為都會是在個別的 test_module 下,再回來看 npm 文件說明
- package{dep} structure: A{B,C}, B{C}, C{D},沒有版本衝突,則預設都安裝在最上層
|
|
- A{B,C}, B{C,D@1}, C{D@2},D@1 安裝在最上層,D@2 則安裝在 C 下面
|
|
這樣可以做到預設共享相同版本的 module 避免重複下載,卻也不用擔心多個版本衝突的問題
環狀相依
如果 package A require package B 而 package B 又 require package A,變成環狀相依的情況,在 Nodejs 中這樣不會拋出錯誤,只是行為可能不如預期
官網的案例中
- main.js require a.js / b.js,因為 a.js 先被 require 則先被載入
- 在 a.js 中,執行到 require b.js,則開始載入 b.js
- 在 b.js 中,執行到 require a.js,則為了避免無限迴圈,此時會回傳
步驟(2)載入到一半的 a.js
,接著繼續完成 b.js 載入 - 回到 a.js,此時的 b.js 引用是完整的,繼續載入 a.js
- 回到 main.js 中,此時在 main.js 中 a.js / b.js 都是完整的引用
|
|
Gem / Bundler
接著來看 Ruby 生態如何用 Gem Bundler 管理套件,以下內容主要參考 Understanding How Rbenv, RubyGems And Bundler Work Together,首先先安裝 rbenv,用於管理主機上多個 Ruby 版本
RubyGem 在 Ruby 1.9 後就整合了,gem 安裝路徑可以透過 $gem env
中的 “INSTALLATION DIRECTORY” 路徑,不同版本有被分開不同的路徑安裝,但不像 npm 會在每個專案下都有獨立的 node_modules
載入
有三種方式
- load:
類似於 require,但會重複載入 - require:
到 $LOAD_PATH 下檢查是否有載入對應的 gem,沒有則去系統安裝路徑載入 gem 下的 lib,並加到 $LOAD_PATH 中,詳見龍哥的 Ruby 語法放大鏡之「你知道 require 幫你做了什麼事嗎?」 - require_relative:
接受相對路徑載入
所以 RubyGem 管理相當單純,把 Gem 都安裝在統一的安裝路徑下
Bundler
Bundler 負責幾項工作
- 讀取 Gemfile 並安裝對應適合的版本
- 產生 Gemfile.lock 確保在不同環境下還原時版本一致
- 因為不能載入多版本,所以 Bundler 會在所有版號中找到適合的,例如 module_a 需要 module_c > 1.0.0,而 module_b 需要 module_c <=1.1.0,則最後會安裝 module_c@1.1.0 符合兩者需求
實驗
實驗方式相同,但是發現 Ruby 不能載入多個版本,會出現
|
|
使用 Bundler 會因為找不到適合版本而拋錯
|
|
Golang mod
參考Using Different Versions of a Package in an Application via Go Modules,Golang 可以在 mod file 中指定 replace
,就可以在程式中使用多個版本,也可以指向自己的 fork,相當的方便,官方文件:Requiring external module code from your own repository fork
結語
比對不同的實作蠻有趣,目前看來 npm 會 install 在當下路徑,換來好處是可以載入多個版本的相同模組;而 gem 沒有這樣的彈性
至於載入多版本的相同模組
我覺得是蠻現代的需求,不確定為什麼 Ruby / Gem 沒有提供這樣的特性,背後的脈絡不知是如何考量