如何搭建自己的CLI工具

为什么要CLI

CLI 英文全称为Command Line Interface,是在图形界面普及之前人们与电脑的交互方式。例如我们经常在控制台敲的命令系统,或者linux的操作系统命令,或者自己定义的一些列电脑操作命名,凡是通过这种命令去与计算机交互的统称为CLI。与之对应的GUI(Graphical User Interface)则是具有图形界面的操作交互。有些时候图形界面操作较命令交互的操作简单直接,因为它更符合人的直觉,试想一下如果window的交互方式是在屏幕上打一些无聊的代码,那么绝大数人都会放弃使用它。然而在很多时候,命令交互方式会比图形界面要便捷,这种场景常见于程序猿的世界。实际上你用cd folder 命令去打开一个文件夹还是用鼠标双击打开并没有多大的区别。区别在于你更喜欢哪种方式。大多数人喜欢后者,因为双击的操作是不需要学习(或者学习成本是非常之少的),或者学习成本非常小。而对于另外一些人来说,习惯使用命令非快速提高自己的工作效率。(非常符合作为一个程序开发者的身份)。

CLI在前端

前端开发早已经不是刀耕火种的年代了,仅仅在几年以前,单纯地组织js、css和html文件就能随心所欲地游刃在前端界,如今随着业务的增加以及对新的特性的追求(不管在语言上还是特性上),前端的工作量不再局限在只会写些HTML CSS 和JS 的范畴了。为了适应快速稳定的产品输出,前端应用的输出模式也开始像传统的IT项目靠齐:一个完成的开发周期。这其中就包括了新建模板仓库、构建,开发、自动化测试,上线部署,持续交付/集成等多个环节。每一个环节都对应这不同的开发运用模式,以及与之相匹配的也有开发环境和开发工具。为了将分散的工具和命令统一,我们需要一套完整的构建命令,来帮助我们构建起来在整个过程当中的逻辑的关联性和连续性。因此开发出一套属于自己业务的CLI命令系统是很有必要的。
image.png
本篇文章将以一个实际项目中的实践为基础,使用node作为开发语言讲解如何设计一个CLI工具以及它的部分实践细节。实际上使用任何语言都是可以的,但是对于前端的生态系统来说,JavaScript是最合适和最方便的语言来处理我们的前端各种业务。该CLI工具的源码已经存放在github上,你可以直接下载,另外CLI工具也做成了npm的一个包,通过npm install 可以直接安装。

万事开头难

与控制台的交互的原理非常简单:即获取用户的输入,执行相应的逻辑。我们可以把这种交互模式类比js中的一等公民“函数”的作用,指定对应的参数,获取固定的输出。我们编写CLI也是一样,需要在代码中可以获取控制台输入的命令。用下面这条最简单的命令来讲,假如我们在控制台输入:
**puppy say woofwoof**
这段命令我们分三个部分,puppy 是我们CLI工具的总得入口,你可以把它命名成任何你自己的名称。执行任何一条命令的时候都需要输入puppy;然后是say则是执行的命令名称。wangwang 就是执行子命令的参数。我们根据这种规则,在代码中获取用户在终端的输入,并且根据这些命令执行对应的方法。这就是CLI 的基础原理。

首先我们要做的就是初始化一个npm的项目,无论是编写一个公共的模板库,还是要搭建一个node框架,你的第一步应该都是这个。

npm init -y

在自动生成的package.json文件配置修改部分配置,其中一些配置对于CLI命令系统来说是非常重要的。

{
  "name": "puppy-cli", // 项目的名称,发布到npm上用
  "version": "1.1.6",
  "description": "Command line tools for building web application",
  "main": "index.js", // 代码执行的入口文件
  "scripts": { // 相关的命令
    "test": "echo "Error: no test specified" && exit 1",
    "dev": "ts-node src/index.ts",
    "fix": "eslint --fix src/*.ts",
    "puppy": "puppy"
  },
  "bin": {
    "puppy": "/lib/index.js" // 入口文件映射的命令,是CLI工具的最基础配置。
  },
  ...

可以看到在bin配置项中加入了"puppy": "/lib/index.js",其中puppy就是CLI 的命令名称,我们在/lib/index.js中作为入口文件执行相关的业务逻辑。其实在npm规则中,只要是非全局安装即没有在install 后面添加-g的命令都会被放入到bin目录中,你如果要执行对应的命令,需要手动敲入./bin/路径去执行。我们编写CLI命令一定是要全局的,同时为了支持全局的配置模式,你需要在/lib/index.js中加入一段特殊的代码:

#!/usr/bin/env node

这段代码非常重要,它的意思是将指定的全局路径映射到该文件下。这样在执行全局的命令时,系统就可以正确的识别到需要运行的文件。接着我们编写在入口文件中就可以获取到对应的参数了。为了方便的与控制台交互,我推荐你使用commander等成熟的npm包,来管理控制台的输入。

// index.js
process.stdin.setEncoding('utf8');

// This function reads only one line on console synchronously. After pressing `enter` key the console will stop listening for data.
function readlineSync() {
    return new Promise((resolve, reject) => {
        process.stdin.resume();
        process.stdin.on('data', function (data) {
            process.stdin.pause(); // stops after one line reads
            resolve(data);
        });
    });
}

// entry point
async function main() {
    let inputLine1 = await readlineSync();
    console.log('inputLine = ', inputLine1);
}

main();

命令和功能类型的划分

制作一个工具首先是基础现实的需求的,也就是我们要解决什么问题。CLI 工具也是一样,我们首先要确定我们需要的功能,与此对应不同的命令。根据这些,我们再将整个CLI工具的命令进行分类。例如:
针对git仓库的建立,我们可以设置一个命令,在项目开发前期,通过puppy git init 命令来初始化本地的代码仓库。然后我们将模板文件插入到项目中,这时我们就用到了一些脚手架。我自己使用的是yeoman-scaffold作为整个项目的脚手架用以实现项目的拷贝,模板的自动迁移等功能,你也可以选择自己喜欢的。我们将脚手架的部分命令集成到我们自己的CLI中,这样,只需要面对一套命令,就可以完成脚手架的所有工作,实现了命令的统一性, puppy generator template 生成指定的初始化模板。然后我们需要配置开发环境,一般现在的流行构建工具包括webpack、rollup、vite等,你可以选择自己喜欢的,把他们的命令集成到自己的CLI中。puppy run project. 然后是测试环节,执行mocha等第三方单元的测试命令,系统会自动帮你过测试流程,你只需要编写对应的自动化测试程序和单元测试用例。最后是上线,这个需要根据自己不同的项目而定,如果你使用的git的管理系统,可以把命令同样集成到CLI中,如果是SVN集中管理系统,则需要编写对应的bat脚本。同样的说明适合持续集成/交付,git上有成熟的集成系统,你可以集成命令的方式来实现。
除了主要的业务命令,我们也需要一些关系到自身的相关命令,例如:CLI各个功能的同步更新update,安装新的包命令install,列出所有功能列表命令list,查看CLI的帮助命令 help。这些类型属于基础的命令,是与CLI整体相关性比较强的。一般来说也是比较通用的。
处理命令的逻辑并不难,获取到控制台的输出,然后执行相应的操作,这一切都能够用node在用户的操作系统中实现。你可以像编写函数一样来处理这些命令。多使用第三方的成熟包,可以解决不同系统知己去拿的兼容性,也能省去很多自己开发的时间。因此,选择合适的第三方插件在编写CLI中是一件值得鼓励的事情。

持久缓存的文件

因为nodejs是运行时,我们需要再本地有相关的存储,用以将部分信息本地持久化。为此需要在用户的机器上存一个缓存文件,以便记录必要的信息,以及安装过的插件。这个文件初次被写入用户的本地,在运行时我们经常需要去读取和更新。我们采用了yml文件格式来存储这些命令的详细,你也可以用json格式来存储(实际上json格式的更加便捷),它看起来是这样的:

source:
  native:
    help:
      path: /Users/chenyan/Office/Lab/Commander/lib/core/commanders/help.js
      type: native
      params: []
      description: get some help from native
    create:
      path: /Users/chenyan/Office/Lab/Commander/lib/core/commanders/create.js
      type: native
      params:
        - name: '--project'
          abbr: '-p'
          desc: project name
      description: create a project or plugins
    update:
      path: /Users/chenyan/Office/Lab/Commander/lib/core/commanders/update.js
      type: native
      params:
        - name: '--generator'
          abbr: '-g'
          desc: plugin or generator name
      description: upgrade to new version
    install:
      path: /Users/chenyan/Office/Lab/Commander/lib/core/commanders/install.js
      type: native
      params:
        - name: '--package'
          abbr: '-p'
          desc: npm package name
      description: 'install plugins, generator'
    list:
      path: /Users/chenyan/Office/Lab/Commander/lib/core/commanders/list.js
      type: native
      params: []
      description: list out all commanders
  custom: {}
version: 1.0.0

在执行命令时,我们会去文件中查看相关的命令,并找到该命令的对应执行文件,这样就可以执行这个模块,找到对应执行的文件。这就是我们把信息存在yml文件中的原因。
缓存文件另外一个存储的信息就是存储第三方插件的命令以及他们的地址。我们编写的插件注册到系统中的命令是会存到custom字段中去,并且记录对应的插件文件地址,这样下次我们获取到命令行的参数时,就能根据这张表的对应关系去查找到需要执行的js文件从而做到对插件的使用。

系统的自动更新

由于我们的系统中是存在很多的依赖,我们需要知道哪些依赖包有更新,以及我们自己的包的更新。因此建立一个及时的更细机制尤为重要。我们设想的是在用户每次执行一条命令的时候去npm社区检测是否有软件版本更新。然后把是否更新的提示推送给用户,让用户自己去选择是否需要更新。

核心功能

CLI是否需要自带很多功能,其实取决你对CLI的设计理解:是一个大而全的工具?还是精巧的工具内核?实际上这部分属于设计思想,和我们具体的业务不会有很强的关联性。最终我们在的CLI采用的是后者思路。在核心功能上我们只提供了简单的十个以内的命名,通过这些命令,你可以操作框架本身以及其延伸的诸多功能。下面列出我们的CLI工具的核心功能:

  1. checker: 检查各个包的更新状态,以及框架本身的系统时效性。
  2. create: 创建模板,由扩展yeoman脚手架命令而来,实际上就是集成了第三方脚手架功能。创建的多种类型:项目projects,插件plugins,组件comps。
  3. install: 安装我们为这个套CLI工具编写的插件。这个命令很重要,几乎是整个CLI框架的基础之一。它同时也可以安装npm包,只需要你根据参数设置即可。
  4. list: 列出本地的命令。
  5. help: 帮助说明列表,对这套框架的一些基础说明,源码地址等信息。
  6. update:强制更新升级的插件 。通过制定参数如 puppy update plugins-abc 你可以升级制定的插件。如果不跟参数,则强制更新所有插件。

可以看到我们的本地命名只有6个,实际上,如果我们需要,甚至可以在此基础上再缩减四到五个。因为只有createinstall是我们经常用到的。至于原因,我们会再下一节中说明。

开放插件系统

开放性,是我们考虑构建整个cli框架时仔细思考的最重要的问题。如果你用过koa等nodejs框架,你就会对它的核心思想:微内核感到啧啧称奇。这种思想指导了一个程序在保留核心功能的同时,提供了足够多的可扩展性给外部的开发者。既能保证自己的特性,又能写入第三方的功能。就像是一个核心的充满着多个插槽的乐高积木一样。同样的,通过编写插件再通过install命令安装到项目中去,我们就能够为这个框架添加形形色色的功能了。
要修改install功能我们需要为框架指定下载路径,你可以通过配置文件配置,可以是npm服务器也可以使你们自己搭起来的公司内部的服务器。在安装的时候需要本地路径需要对应写到配置文件中。
安装插件的同时,我们为组件注册了新的命令,这些命令被缓存在本地。通过执行这些命令,我们再去调用插件,实现了自定义编写,使用一套完整的模块开发周期。在你编写完一个插件时你需要再服务器对应的地方写好插件的使用方式,方便使用者使用。实际上使用插件的方式是把不同的模块集成到一个框架里面,这些模块逻辑上没有依赖性,只是为了解决单一的问题而设计的。
一套完成的插件开发流程包括创建,测试,安装,更新,卸载。而这些命令都能够通过我们框架的核心命令来促成,一气呵成。

puppy create-->link test-->install-->update-->unload

采用微内核的巨大好处之一就是你能够围绕着框架做起一套完整的生态系统。并不是每一件事情都需要你自己去收做,相信社区和他人的力量,能够写出非常优秀的程序。

输出文案的美化

最后,为了是你的枯燥的控制台显得骚气一点,你可以使用一些插件来或者字体来装饰他们。我推荐你使用第三方的包如Chalk 等美化输入的npm包。另外在使用图标系统是,我推荐是你使用Spawn等具有兼容性的插件来帮助你下载包到你的本地。

总结

总得来说开发一个CLI工具并不难,设计思路十分重要,这最终决定了你生成工具的样子。随着前端软件开发流日益复杂化,一个标准的流程的诞生也需要有一个标准的工具去支持。为了将琐碎的工程化细节处理基础处理的集成化,我们需要将所有的细节合并在一起。你当然也可以按照自己的方式去设计你的CLI,总之这一切都来自于你的业务的需求。在开发过程中,只需要注意以上提到的一些点,那么其他的就任你发挥。楼主自己编写的CLI 在已经开源在github上了,由于时间的原因,没有时间去维护,如果你感兴趣,可以自行去查看。

参考文档

原文地址:https://www.cnblogs.com/constantince/p/15429583.html