本文作者:许超英作为开发者亲密的好伙伴,996在我们日常的开发工作风雨无阻中陪伴着我们。作为一名前端开发者,你一定有自己开发过自己的CLI小工具吧!不会的话,本文就不教了~接下来的五分钟,我们来聊一聊NodeCLI工具的高级设计,探讨如何利用插件机制为这样的小工具带来更灵活、更丰富的功能体验。插件化的好处到现在为止,我们已经接触到了大量的插件化平台,比如koa、egg、webpack等,为什么这些框架或者工具都选择实现一套插件化机制?首先,如果没有插件,我们就把大大小小的功能都写在一起,这样会造成项目太大,代码结构会极其冗长复杂,这显然不是态度一个健康的项目应该有的。但是利用插件机制对附属功能进行剪枝,只保留了核心功能,甚至连核心功能都插件化了。在极大简化项目的同时,也主要为项目带来了以下特点:灵活性,由于插件本身是独立于核心代码的,所以插件可以在不影响核心代码的情况下自由更新和更改和其他插件功能。一定程度上提高了核心代码的稳定性。功能定制,用户可以自由组合插件功能,无需安装冗余功能可扩展性,这也是插件机制最大的特点之一,无论是项目维护者还是社区都可以轻松贡献插件,满足不同的需求核心功能之外的需求。可以说,如果你的项目功能结构比较复杂,或者未来有不断迭代需求的计划,可以考虑使用插件机制来简化开发和使用成本。先设定一个小目标,再谈回到我们的NodeCLI小工具。一般来说,CLIgadgets都是轻量级的,使用方便,比如我们可能经常用到的一些脚手架提供的工具命令:MyToolnewaaaMyTooldeletebbbandusually安装起来也很方便:npminstall-gMyTool但是,一旦我们有新的功能需求,比如增加一个命令或者增加一个参数,我们就得发布一个更新包,想办法提示用户更新我们的工具。很不方便,也不及时。结合题目我们知道可以使用插件机制来解决需求迭代的问题。那么我们先定一个小目标,理想的插件式CLI工具应该具备哪些特性呢?首先,最好声明插件,完全不用安装就可以使用,例如:MyToolstart--featureA--featureB#我们假设featureA和featureB是两个独立的插件,像这样,在在使用插件的过程中,不需要用户下载任何插件时,用户声明可以使用该插件。当然,这与一般插件平台使用插件的方式不同。比如我们在使用webpack插件时,需要先修改package.json文件,将这些插件下载到本地项目的node_modules中,然后在配置文件中声明插件,像这样:插件的使用像webpack这样的插件确实有点繁琐,所以在我们的插件方案中,首先要做的就是去掉插件安装过程。由于该插件无需安装,因此无需更新或卸载。一般来说,要实现插件,我们需要在我们的CLI工具中构建一套完整的插件包管理逻辑。让用户不再关心插件包相关的任何操作,无需下载安装,无需更新插件,更不用说卸载插件,一切由小工具搞定。那么要实现这样一套插件包管理逻辑,我们需要考虑哪些因素和解决方案呢?下面详细探讨一下免安装插件包的管理机制。插件注册考虑到NodeCLI工具插件的使用场景和插件功能的独立性,我们很容易想到使用npm来注册和发布我们的插件:每个插件都是一个单独的npm包,只要插件包的名字具有一定的特征,我们就可以很容易的根据插件的名字找到对应的包。例如插件名和包名的对应关系:插件名包名@{pluginName}myTool-plugin-@{pluginName}@${scopeName}/${pluginName}@${scopeName}/myTool-plugin-@{pluginName}除此之外,我们还需要考虑一些特殊的包,比如scoped包,也是通过名称来区分的,如上表所示。还有就是发布在私有npm上的插件包,需要我们的插件平台自己添加注册表参数来区分。当然,使用私有插件也会比普通插件多一个参数,比如:MyTool--registry=http://my.npm.com--my-plugin#使用一个名为my的私有插件包-plugin下载由于我们的插件都是npm包,所以我们只需要考虑如何下载一个npm包。最初,我们的想法可能是以库的形式引入npm,然后安装插件包:constnpm=require('npm');npm.install();但这有一个很大的性能问题,npm包的大小大约是25M,这对于一个命令行工具来说是不行的。所以我们想,既然我们要做的是一个NodeCLI工具,那么用户本地一定要有一个Node环境。可以使用本地的npm下载插件包吗?答案是肯定的:constnpm=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命令时对其进行剪枝。这是对npm的一种优化,即有些依赖如果没有提前声明,会在下一次安装时去掉。因此,一旦用户在某个时候再次运行npminstallxxx,比如添加了一个项目依赖,或者添加了我们的一个插件(这个插件其实就是前面说的用npminstall安装的),就会有之前安装的一些Dependencies插件被npm删除了!这导致我们在下次运行CLI工具和某个插件时收到缺少依赖项错误。也正是因为npm的这个特性,我们不得不放弃将插件包存放在用户项目node_modules目录下的方案,转而将插件包存放在全局,使用全局目录如~/.mytool/plugins作为插件包存放地址,里面的插件包会按照${插件名称}/${版本}的路径存放,如:#~/.mytool/plugins├──pluginA│└──1.0.1├──pluginB│└──1.0.0└──pluginC├──1.0.1└──1.0.2这样我们的插件包就躲过了npm的“误伤”。但是由于存储位置的变化,插件包的加载逻辑也必须做相应的调整。加载考虑到版本和存放位置等问题,插件的加载其实有点复杂。下面是一个本地开发服务器的插件加载过程。我们可以用这个简化的流程图来帮助理解:首先,如果我们有一个参数path来指定加载某个插件包的路径,显然,用户就是上帝,永远拥有最高的优先级(手动狗头),所以我们先判断路径参数。如果这个参数存在,我们直接从这个路径加载插件包。那么,如果我们的工作空间划分是基于项目的,那么我们也应该尊重项目的本地插件依赖:如果插件包存在于node_modules中(主要是用户手动安装的时候),那么我们就直接加载this项目中的插件包。最后,我们在上一节中提到,所有插件包都托管在一个全局文件夹中。可以说99%的情况下,我们的插件都是从这个文件夹加载的。内部逻辑很简单:查询文件夹中是否存在该插件,存在则加载,不存在则下载该插件的最佳(通常是最新)版本。不过这里其实还有一个细节需要考虑,就是如果用户指定了插件的版本号,我们还需要判断全局文件夹中是否存在对应版本的插件,如果不存在,我们需要下载版本。以上其实就是一个简化版的插件加载过程。复杂的部分——如果你同时在思考它,你可能会隐隐约约地意识到——比如你在查询文件夹中的插件时,实际上只是对文件存在的简单判断或不?是否始终默认加载最新版本的插件?这些问题,我们会分解到接下来的几小节中慢慢讲。插件包与核心包的版本匹配每个插件平台最头疼的问题之一就是用户使用的核心包(一般来说就是插件平台本身)与插件的版本匹配问题包裹。有时当核心包有重大更新(BREAKINGCHANGE)时,旧插件包的版本不一定匹配,反之亦然。所以我们肯定是希望在出现版本不匹配的问题时能够提示用户,并且像我们讨论的插件免安装管理模式一样,应该能够根据需要自动匹配并安装相应的插件到核心包版本。理论上用户根本不会感知到核心包或插件包版本控制的概念。要自动匹配核心包和插件包,首先我们需要想办法关联他们的版本。您可以使用插件开发者声明的方法。例如,插件开发者可以在插件package.json中的engines字段下声明插件正常运行所需的核心包环境,如:{"engines":{"svrx":"^1.0.0"}}这意味着该插件仅适用于^1.0.0范围内的svrx版本。(svrx是一个CLI工具的名字)由于package.json中的字段可以在下载包之前通过npmview命令直接读取,所以我们可以很容易的根据当前使用的svrx核心包版本来判断最匹配的用户。插件版,然后下载版。版本匹配这里我们可以选择semver来做判断:semver.satisfies('1.2.3','1.x||>=2.5.0||5.0.0-7.2.3')//trueso,on在第一节描述的插件加载过程中,当用户没有指定具体版本时,我们加载的目标插件包不一定是插件的latest(最新)版本,而是根据引擎字段版本匹配semver检查。自动更新完成,我们回到之前提出的问题,插件包更新了怎么办?其实这是任何插件机制的问题。一般的解决办法是,比如webpack插件,我们在安装插件的时候,会把版本信息写到package.json里面,比如html-webpack-plugin@^3.0.0,这样当v3.1.0的时候发布后,我们“下载”插件包,第一次重装时,可以自动更新到最新版本。但是请注意,“下次重装”指的是我们去掉本地依赖后,重新安装npm包然而在实际使用中,我们并不会经常更新这些项目中的依赖,所以在大多数情况下,我们并没有办法及时享受到最新版本的插件,这是用户在安装插件时会遇到的问题如果是插件免安装机制呢?难道我们每次加载都默认加载最新版本吗?当然可以,因为具体加载的版本可以由内部加载机制决定。但是,这个有个缺点:如果每次加载插件都需要判断(npmview)插件是否有最新版本,如果有最新版本就去下载(npminstall)新版本包,比较浪费时间!插件全部加载完毕,服务启动,黄花菜凉了!如何在不拖慢加载速度的情况下自动更新插件?我们可以采用一个折中的方案,就是每次服务启动后,我们都会检查版本更新,下载所有正在使用的插件的新版本,而这一切都在子进程中进行,永远不会阻塞服务的正常运行。这样下次启动Server-X的时候,这些插件就都是最新版本了。但是,这样的方案还是有一些细节需要注意的。比如包下载出错怎么办?用户在下载过程中突然中断程序怎么办?这些特殊情况会导致下载的插件包文件不完整,无法使用。为此,我们可以尝试使用临时文件夹作为插件包下载的暂存区,在确认插件包是下载成功,像这样:consttmp=require('tmp');consttmpPath=tmp.dirSync().name;//生成一个随机的临时文件夹目录constresult=awaitnpm.install({name:packageName,path:tmpPath,//npm下载到一个临时文件夹global:true,//...});consttmpFolder=libPath.join(tmpPath,'node_modules',packageName);constdestFolder=libPath.join(root,result.version);//复制到目标文件夹fs.copySync(tmpFolder,destFolder,{dereference:true,//确保链接文件夹也被复制});所以如果插件下载失败,插件包不会存在于我们的目标文件夹中,所以我们会尝试在下次启动时重新下载插件。至于一些下载失败的插件包,由于在临时文件夹中,会被我们的系统定期清理,不用担心垃圾残留,超级环保!过期包清理其实我们真正需要关心的残留文件是插件文件夹中已经存在的那些过期插件包。因为自动更新机制的存在,我们每次插件更新后都会下载新版本存放在plugin文件夹中,下次直接启动新版本的插件,这样旧版本的插件包就没用了.如果不能及时清理,可能会占用用户的存储空间。具体的清理逻辑也很简单,就是在做自动更新步骤的时候发现插件存放目录下的版本不是最新的,也不是当前用户指定的版本,然后删除里面的文件夹批次。另外考虑到工程师的洁癖,个人认为CLI工具本身应该有自洁逻辑。如果用户卸载该工具,该工具应自动清除本地存储的所有配置、核心包和插件包,实现零污染。综上所述,我们讨论了NodeCLI工具插件包管理的一些方案探索和设计细节。如果只看标题和加粗加黑的文字,那不看其他文字你会发现好像还可以吧?(嘿嘿~)本文的核心内容和素材均来自于网易云音乐前端组出品的一款插件式本地开发服务器——Server-X及其插件机制的设计开发过程。为了概括起见,文中特意隐藏了对Server-X的描述之后,如果大家还有兴趣,或者想了解具体的插件机制代码,可以打开下方链接进一步阅读。总的来说,从插件包的下载、存储、加载、版本管理等方面,其实都存在一些我们在开发之前可能没有考虑到的问题。如果你正好打算搭建一个插件平台,或者遇到类似的问题场景,希望这套server-X的免费插件包管理机制可以帮到你。链接可以star支持Server-X包管理GitHub主源码Server-X项目主页小注:文章开头的996形容词与网易云音乐前端开发团队无关!本文由网易云音乐前端团队发布,可自由转载。转载请在标题中注明,并在显着位置注明出处。我们一直在招聘,如果你恰好准备跳槽,又恰好喜欢云音乐,那就加入我们吧!
