Node CLI 工具的插件方案探索

banner

本文做者: 徐超穎

CLI 工具做爲開發者們親密無間的好夥伴,996 風雨無阻地陪伴着咱們進行平常的開發工做。身爲前端開發,你必定也親自開發過一套屬於你本身的 CLI 小工具!若是沒有,本文也不會教~ 在接下來的五分鐘裏,咱們來聊聊 Node CLI 工具的進階設計,探索一下在 CLI 端需求複雜化的場景下,如何利用插件機制來爲這類小工具帶來更靈活、豐富的功能體驗。html

插件化帶來的好處

截至目前,咱們已經接觸過大量的插件化平臺了,好比 koa、egg、webpack 等等,爲何這些框架或者工具都不約而同地選擇實現一套插件機制?前端

首先,若是沒有插件,咱們把全部的大小功能所有集中寫在一塊兒,這會致使項目的體量過於龐大,代碼結構會異常冗長複雜,顯然不會是一個健康的項目應有的姿態。而使用插件機制,對旁系功能作剪枝操做,僅保留核心功能,甚至連核心功能也插件化,在大大簡化項目的同時,還主要給項目帶來了如下特性:node

  • 靈活性,因爲插件自己和核心代碼之間是相互獨立的,所以插件能夠自由更新變更,而不會影響到核心代碼及其它插件功能,從某種程度上是提高了核心代碼的穩定性
  • 功能定製化, 用戶能夠自由組合插件功能,無需安裝冗餘功能
  • 可擴展性,這也是插件機制最大的特徵之一,無論是項目維護者仍是社區均可以輕鬆貢獻插件,以知足核心功能外的不一樣需求

能夠說,若是你的項目功能結構複雜,或者將來有不斷迭代需求的計劃,均可以考慮使用插件機制來簡化開發、使用成本。webpack

先定一個小目標

說回到咱們的 Node CLI 小工具,通常來說,CLI 小工具都是輕量易用的,好比咱們可能常用的一些腳手架提供的工具命令:git

MyTool new aaa
MyTool delete bbb

並且一般它們安裝起來也很容易:github

npm install -g MyTool

可是,一旦咱們有了新的功能需求,好比添加一個命令、添加一個參數,就不得不發佈更新包,想辦法提示用戶去更新咱們的工具,這是很是不方便、不及時的。結合標題咱們知道,能夠利用插件機制來化解需求迭代這個問題。web

那麼先來定一個小目標,一個插件化的 CLI 工具理想狀況下應該具有什麼特徵呢? npm

首先,插件最好是聲明即便用,徹底不用安裝的,好比:json

MyTool start --featureA --featureB # 咱們假設featureA、featureB是兩個獨立的插件

像這樣,在使用插件的過程當中,並不要求用戶去下載任何插件,用戶聲明插件便可使用。固然,這和通常的插件平臺使用插件的方式是不一樣的,好比咱們使用 webpack 的插件時,須要先修改 package.json 文件,把這些插件下載到本地工程的node_modules 中,再去配置文件裏聲明這些插件,就像這樣:bash

webpack 插件

webpack 這樣的插件使用方式確實有點繁瑣,因此在咱們的插件方案裏,首先要作的就是免去插件的安裝過程。

插件既然不用安裝,也就更不提更新或者卸載了。總的來講,要作到插件化,咱們須要給咱們的 CLI 工具內置一整套插件包管理邏輯。讓用戶能夠再也不關心任何插件包相關的操做,不須要下載安裝,不須要更新插件,更不須要卸載插件,一切的一切,都交給小工具來處理。

那麼要實現這樣的一套插件包管理邏輯,咱們須要考慮的因素和方案有哪些呢?下面咱們就具體來探索下免安裝插件包管理機制。

插件的註冊

考慮到 Node CLI 工具插件的使用場景,以及插件功能的獨立性,咱們很容易想到利用 npm 來註冊發佈咱們的插件:每一個插件都是一個單獨的 npm 包,只要插件包的名字具備必定的特徵,咱們就能夠輕鬆根據插件名字查找到對應的包。好比這樣的插件名字與包名的對應關係:

插件名 包名
@{pluginName} myTool-plugin-@{pluginName}
@${scopeName}/${pluginName} @${scopeName}/myTool-plugin-@{pluginName}

另外,咱們也須要考慮到一些特殊的包,好比 scoped 包,這個也是如上面表格所示在名字上作好特徵區分。

還有一種是發佈在私有 npm 上的插件包,這就須要咱們的插件平臺自己添加 registry 參數來作區分了,固然,使用私有插件也會比普通插件多一個參數,好比:

MyTool --registry=http://my.npm.com --my-plugin # 使用一個名爲 my-plugin 的私有插件

包下載

因爲咱們的插件都是一個個 npm 包,因此咱們只須要考慮如何下載一個 npm 包。最初咱們的想法多是以庫的形式引入 npm ,而後安裝插件包:

const npm = require('npm');
npm.install();

可是這樣有個很大的性能問題,npm 包的大小有約 25 M,這對於一個命令行工具來講很不 OK。

因而咱們想,既然要作的是一個 Node CLI 工具,那麼用戶的本地確定有 Node 環境啊,咱們能不能利用本地的 npm 來下載插件包呢?答案是確定的:

const npm = require('global-npm');
npm.install();

這裏咱們可使用 global-npm 或者其它相似的包,他們的做用是根據環境變量信息找到並加載本地的 npm。這樣,咱們的核心包大小就獲得了完美的「大瘦身」。

存儲

插件包下載後,存儲位置也是一個問題。默認地,npm 會把下載的包存放在當前目錄的 node_modules 中。在通常腳手架工具的使用場景裏, 包管理器默認會把插件包文件存放在用戶工程項目的 node_modules,這樣的好處就是插件包作到了工程粒度的隔離。可是,因爲插件包是由咱們全局的 CLI 工具下載的,並且確定,咱們不該該把插件做爲一個 devDependency 添加進用戶工程目錄下的 package.json 文件,這會修改用戶的文件,不符合咱們的預期。由此就產生了一個矛盾,即 node_modules 中存在插件包,可是 package.json 中又沒有聲明插件包。

這麼乍一看實際上是沒有問題的,若是用戶安裝了全部工程依賴和咱們的插件,是能夠正常啓動 咱們的工具並運行的。可是這裏有一個 npm 冷知識:對於在 node_modules 中存在,但又沒有在 package.json 中聲明的依賴,npm 在執行 install 命令時,會對它們進行剪枝(prune)操做。這是 npm 的一種優化,即若是某些依賴沒有被事先聲明,那它們就會在下一次 install 操做中被移除。

npm remove packages

因此,一旦用戶在某個時候又運行了一次 npm install xxx, 好比新增一個工程依賴,或者新增一個咱們的插件(前面講過插件其實也是用 npm install 來安裝的),就會有以前某些已安裝插件的依賴被 npm 移除!這就致使咱們在下一次運行 CLI 工具和某個插件時會收到依賴丟失的報錯。

正是由於 npm 的這個特性,咱們必須得放棄將插件包存儲在用戶工程 node_modules 目錄中的方案,轉而全局存儲插件包,將某個全局目錄好比 ~/.mytool/plugins 做爲插件包的存放地址,裏面的插件包將按照 ${插件名}/${version} 的路徑存放,如:

# ~/.mytool/plugins
├── pluginA
│   └── 1.0.1
├── pluginB
│   └── 1.0.0
└── pluginC
    ├── 1.0.1
    └── 1.0.2

如此咱們的插件包便逃過了 npm 的「誤傷」,不過,由於存儲位置的改變,插件包的加載邏輯也要作相應的調整。

加載

考慮到版本、存儲位置等問題,插件的加載實際上是有點複雜的。如下是一個本地開發服務器的插件加載流程,咱們能夠用這個簡化版的流程圖來幫助理解:

簡化流程圖

首先,若是咱們有一個參數 path,用來指定某個插件包加載的路徑,顯然,用戶就是上帝,永遠優先級最高(手動狗頭),因此咱們先對 path 參數進行了判斷。若是存在該參數,咱們直接從這個路徑加載插件包。

而後,若是咱們的工做區劃分是以工程爲粒度的,那麼咱們也應尊重工程本地的插件依賴包:若是 node_modules 中存在該插件包(主要是用戶手動安裝的狀況),那咱們就直接加載這個工程中的插件包。

最後,上一節咱們提到,插件包是被所有託管在一個全局文件夾中的,能夠說 99% 的狀況下,咱們的插件都是從這個文件夾加載的。內部邏輯簡單來講就是:查詢文件夾中是否存在該插件,有則加載,無則下載最佳(通常是最新)的一個插件版本。不過這裏其實還有個細節要考慮,那就是用戶若是指定了插件的版本號,咱們還須要判斷全局文件夾中是否存在相應版本的插件,若是沒有,咱們須要下載該版本。

以上實際上是插件加載的一個簡化版流程,複雜的部分——若是你同時也在思考的話,可能隱隱約約也會覺察——比方說在文件夾中查詢插件時,真的只是簡單判斷文件存在與否嗎?默認老是加載最新版本的插件嗎?這些問題,咱們將拆成後續幾個小節慢慢說。

插件包與核心包的版本匹配問題

每一個插件平臺必定比較頭疼的一個問題,就是用戶所用的核心包(通常來說就是插件平臺自己)與插件包的版本匹配問題。有時候核心包有大更新(BREAKING CHANGE)時,舊的插件包的版本不必定能匹配上,反之亦然。因而咱們確定但願,在出現版本不匹配問題時,能對用戶做出提示,而且,像咱們正在討論的這種插件免安裝管理模式,應該能自動根據核心包版本匹配並安裝相應的插件,理論上用戶根本不會感知核心包或者插件包有版本這一律念。

要作到自動匹配核心包和插件包,首先咱們須要想辦法將它們的版本關聯起來。你能夠採用插件開發者聲明的方法,好比,插件開發者能夠在插件 package.json 中的 engines 字段下聲明插件正常運行所需的核心包環境,如:

{ "engines" : { "svrx" : "^1.0.0" } }

這表示該插件只能在 ^1.0.0 區間的 svrx 版本上運行。(svrx 是某個 CLI 工具的名字)

因爲 package.json 中的字段能夠在下載這個包以前直接由 npm view 命令讀取,

npm view engines

咱們就能夠結合當前用戶使用的 svrx 核心包版本輕鬆判斷出最佳匹配的插件版本,再對該版本進行下載。版本匹配這裏咱們能夠選擇 semver 來作判斷:

semver.satisfies('1.2.3',  '1.x || >=2.5.0 || 5.0.0 - 7.2.3')  // true

因此,在上一節講述的插件加載流程裏,當用戶沒有指定具體版本時,咱們加載的目標插件包並不必定是該插件的最新(latest)版本,而是根據 engines 字段作了 semver 檢查後最匹配的一個版本

自動更新

好了,咱們再回到以前提出的問題,插件包更新了怎麼辦?實際上,這是任何插件機制都會遇到的問題。通常的解決方案是,好比 webpack 的插件,咱們安裝插件時會把版本信息寫到 package.json 中,如 html-webpack-plugin@^3.0.0,這樣,當 v3.1.0 發佈後,咱們「下次從新安裝」這個插件包時,能夠自動更新成最新的版本。可是請注意,「下次從新安裝」指的是咱們移除本地依賴後從新安裝這個 npm 包,然而實際使用過程當中,咱們並不會頻繁去更新這些工程中的依賴,因此絕大多數狀況下,咱們沒有辦法及時享受到最新版本的插件。這是用戶自行安裝插件都會面臨的問題。

那若是是插件免安裝機制呢?咱們是否是能夠每次加載都默認加載最新版本?固然能夠,由於加載的具體版本能夠由內部的加載機制決定。可是,這樣作有一個弊端:若是每次加載插件都去判斷(npm view)一次該插件是否有最新版,有最新版還要下載(npm install)新的版本包,太浪費時間了!等全部插件都加載好,服務啓動,黃花菜都涼了!

怎麼才能作到自動更新插件的同時,又不拖慢加載速度呢?咱們能夠採用一個折中的方案,即在每次服務啓動後,纔對全部使用中的插件作版本更新檢查、新版本下載,而且這一切都在子進程中進行,毫不會阻塞服務正常運行。這樣,下一次啓動 Server-X 時,這些插件就都是最新版本了。

不過這樣的方案仍然有一些細節須要注意,好比,包下載出錯怎麼辦?在下載過程當中用戶忽然中斷程序怎麼辦?這些特殊狀況都會形成下載的插件包文件不完整、不可用。對此咱們能夠嘗試使用臨時文件夾做爲插件包下載的暫存區,等確認插件包下載成功後再將臨時文件夾內的文件移動到目標文件夾(~/.myTool/plugins)中,就像這樣:

const tmp = require('tmp');
const tmpPath = tmp.dirSync().name;   // 生成一個隨機臨時文件夾目錄
const result = await npm.install({
    name: packageName,
    path: tmpPath,  // npm 下載到臨時文件夾 
    global: true,
    // ...
});  
const tmpFolder = libPath.join(tmpPath, 'node_modules', packageName);  
const destFolder = libPath.join(root, result.version);  
  
// 複製到目標文件夾  
fs.copySync(tmpFolder, destFolder, {  
  dereference: true, // ensure linked folder is copied too  
});

因此若是插件下載失敗,咱們的目標文件夾中就不會存在這個插件包,因而下一次啓動時咱們會嘗試從新下載該插件。下載失敗的部分插件包呢,因爲是在臨時文件夾中,會按期被咱們的系統清理,也不用擔憂垃圾殘餘,超級環保!

過時包清理

其實,咱們真正須要關心的殘餘文件,是那些已經存在於插件文件夾中的、過時的插件包。由於自動更新機制的存在,咱們會在每次插件更新後下載新的版本存放到插件文件夾中,下一次也是直接啓動新的插件版本,這樣一來老版本的插件包就沒用了,若是不能及時清理,可能會佔用用戶的存儲空間。

具體的清理邏輯也很簡單,就是在作自動更新這一步的同時,找出插件存儲目錄中版本不是最新且不是當前用戶指定的版本,而後批量刪除文件夾。此外,考慮到工程師潔癖,我的以爲 CLI 工具自己也應該要有自清理邏輯,若是用戶卸載了工具,那麼工具應該自動清除全部存儲在本地的配置、核心包和插件包,作到零污染。

總結

以上呢,就是咱們對於 Node CLI 工具插件包管理的一些方案探索和設計細節討論了。若是你只看標題和粗體加黑文字的話,那麼你就會發現其實不看其它文字好像也還OK?(嘿嘿嘿~)

本文核心內容及素材均來源於網易雲音樂前端組出品的一款插件化的本地開發服務器—— Server-X及其插件機制的設計開發過程,爲了通用化,文中刻意隱去了對 Server-X 的描述,若是你仍然感興趣,或者想了解具體的插件機制代碼,能夠打開下方的連接進行進一步閱讀。

總的來講,從插件包的下載、存儲、加載,到版本管理,其實都存在了一些開發前咱們可能沒考慮周全的問題,若是你正好也打算作一個插件平臺,或是遇到了相似的場景,但願 Server-X 的這套免安裝插件包管理機制能對你有所幫助。

Links

能夠 star 支持一下嘛

小注:文章開頭的 996 系形容詞,與網易雲音樂前端開發組無關!

本文發佈自 網易雲音樂前端團隊,可自由轉載,轉載請在標題標明轉載並在顯著位置保留出處。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索