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 系形容词,与网易云音乐前端开发组无关!

本文发布自 网易云音乐前端团队,可自由转载,转载请在标题标明转载并在显著位置保留出处。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们
相关文章
相关标签/搜索