【node/工具】初识node CLI

3,964 阅读10分钟

开发环境搭建

第一步

创建一个文件夹,比如my-cli,将它初始化为一个npm包:

$ mkdir my-cli
$ cd my-cli && npm init -y

第二步

创建入口文件。在根目录下新建一个bin文件夹,并在bin文件夹中创建一个叫my-cli.js的文件:

$ mkdir bin && cd bin
$ touch my-cli.js

第三步

在my-cli.js中,声明使用nodejs作为脚本的解释器,并且在脚本里面随便写点什么。

// my-cli.js
#!/usr/bin/env node

console.log(process.argv);

第四步

告诉npm,这个包所对应的主命令是my-cli。具体来说,就是在package.json文件中,添加一个bin对象:

{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
+ "bin": {
+ "my-cli": "./bin/my-cli.js"
+ },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

第五步

在根目录下执行“npm link”命令,目的是将这个npm包软链接到操作系统中去。这样子,我们就能不需要发包的同时能在任何目录下面使用my-cli这个命令,方便在开发环境下去调试。

$ npm link

正式开发

前置知识

面对命令行界面,开发者有三种诉求:

  • 理解命令串(某个命令串是想要干嘛)
  • 使用命令串(使用别人提供的命令行界面)
  • 开发命令行界面(开发命令行界面给别人用)

无论是以上的哪种,我们都需要掌握一定的命令行界面的一些前置知识-CLI命令语法(CLI Command Syntax )。 首先要声明的是,CLI命令语法算是某种约定俗成的惯例,并不存在对应的标准规范。现在我们讨论的Unix-like和POSIX兼容操作系统所共同遵循的一套语法。

在这套语法里面,包含了五个术语:

  • command
  • parameter
  • argument
  • long flag
  • short flag

command和parameter

===========================
|  command   |  parameter |
===========================

一个命令行字符串包含两部分,一个是command,一个是parameter。一个命令字符串中,除了开头的command,其余部分就是parameter。举个简单的例子,我们常用的解压命令如下:

$ tar -x -f archive.tar

在这个命令串中,tar就是command,而-x -f archive.tar就是parameter。 command是可以独立执行的,也即是说没有parameter也是没有关系的。而parameter又是包括三种类型的字符串:

  • argument
  • long flag
  • short flag

argument

简单来说,argument就是命令串中不包含双连字符--或者单连字符-前缀的那些字符串。argument又叫option。

比如上面所举例的tar -x -f archive.tar命令串中,archive.tar就是一个argument。argument是一个值类型,它既可以单独存在,又可以依附与具体的flag。何为“依附”呢?当一个argument跟随在flag(不管short flag还是long short)后面的时候,我们就说这个argument“依附”于前面的flag。当一个argument依附与某个flag的时候,术语上称其为 "flag argument"

$ ls .

比如说,以上命令串中,.就是一个独立存在的argument。 而在tar -x -f archive.tar命令串中,archive.tar是一个依附于-f这个flag的argument。flag跟argument结合起来,代表着一个key-value对。即,f这个key的值为argument。flag跟argument的结合是遵循一定的规则的,下面在讲long flag和short flag的时候会讲到。

狭义上的argument是指不包含flag argument的argument。

long flag

$ ls --all

以上命令串中,--all就是long flag。long flag是以双连字符(--)开头的字符串。在long flag中,双连字符后面可以跟随单个或者多个字符(也就是说单词)。 不过一般而言,主流的规范是双连字符只能跟随多个字符,以此把它跟short flag区分开来。

不同于short flag,long flag是不能无缝连接到一块的,它们之间必须以空格分隔。

long flag与argument的结合需要用“=”或者空格隔开,比如:

$ git log --pretty=oneline

// 或者
$ git log --pretty oneline

short flag

$ ls -a

以上命令串中,-a就是short flag。short flag是以单连字符(-)开头的字符串。在short flag中,单连字符后面只能可以跟随单个字符。 当单连字符后面跟随多个字符的时候,它代表的是多个short flag的组合。比如:

$ ls -la

这个命令等同于下面这个命令:

$ ls -l -a

当所有的short flag都不使用flag argument的时候,short flag组合的顺序无关紧要。但是当组合中的某个short flag需要使用flag argument的时候,那么这个short flag就必须放置到组合的最后。比如,tar -x -f archive.tar命令串中,-x不接受flag argument,而-f是接受的,那么当两者组成起来的时候,f必须放置到最后。

$ tar -xf archive.tar

跟long flag一样,short flag跟argument的结合也是用“=”或者空格隔开,比如:

tar -f archive.tar

// 或者
tar -f=archive.tar

跟long flag不同的是,short flag跟argument可以无缝连接到一块,比如-farchive.tar无缝连接到一块:

tar -farchive.tar

个人觉得,这种命令串写法是非常反人类的,不建议使用。

读取用户数据

对于命令行界面的实现代码而言,用户数据可以来自于三个地方:

  • 命令行参数(process.argv)
  • 标准输入流 (process.stdin)
  • 操作系统的环境变量(process.env)

在编写自己的命令行界面的时候,我们要想通过以上的三个途径来获取用户输入数据的话,我们完成可以从零开始,基于上面给出的核心API来封装和实现。但是,做软件开发工作,“善假于物”是一个良好意识,更何况我们是身处于繁荣的nodejs生态中呢?

途径1:解析和获取命令行参数

  • minimist。这是一个简单的,直接的获取并解析命令行参数的npm包;

  • YargsCommander。这两个包的功能是相似的。他们不单单提供命令行参数解析的功能,同时还能辅助开发者进行命令串的设计。

途径2:从标准输入流获取用户数据

标准输入流在开发命令行界面的背景下就是指console。

  • 原生readline模块。nodejs在v10之后,它提供了这个原生模块来帮助开发者在console读取用户输入。
  • readline-sync。这个npm包支持同步地从console读取用户输入。

如果我们想要开发命令行界面,我们还得基于上面两个包做进一步的封装。幸运的是,nodejs社区已经为我们准备了这样的包了:

以上三个包大部分的功能都是相似的。它们主要是通过问-答的交互方式从console获取用户输入。同时,它们所支持的问答的方式(比如,inquirer支持的问答方式有:input,confirm,list等等)也有很多种类,这些我们在做命令行界面开发所经常需要用到的。

途径3:操作系统的环境变量

从操作系统的环境变量中获取用户数据,这个就不用什么包了,直接读取process.env.someEnvName就行。

小结

目前,业界实现命令行界面比较主流的组合方案是:Commander + inquirer。

有个遗憾是,inquirer支持的是异步读取,想要进行同步的读取,那么我们还得使用readline-sync这个npm包,然后加载chalk在这个包进行美化,基本上也能实现inquirer这个包所提供的功能。

编写功能代码

获取到用户输入的数据后,我们能够知晓用户想让我们帮他干啥了。接下来,我们就可以编写具体的功能代码来满足用户的需求。一般而言,CLI实现的功能可以按照规模分为以下几种:

  • 小型功能。主要是消费nodejs的fs模块,path模块来完成相应的文件操作。比如,我自己实现的deep-touch的CLI。
  • 中型功能。基于门槛相对高一点的技术来是实现的功能。比如基于AST操作来对源码去做重构(增删改查)的CLI就算这种类型的。
  • 大型功能。整合【文件操作,提供开发服务器,打包等】一系列功能的CLI。比如说,create-react-app和vue-cli等。

不管是编写哪种类型的CLI,本质都是围绕nodejs原生API和生态中的npm包来进行的。下面以我编写的【deep-touch】的命令行工具为例,简单地讲述一下编写CLI功能代码的过程。

首先,我要说明编写这个CLI的目的。在一些目录层次结构比较深的项目中,如果我们想要在最底层去创建文件的话,那么我们必须要一层一层打开现有的文件夹,或者一层层创建文件夹,最后来到最底层的文件件,然后才手动地创建文件。这种类型的操作无疑是低效的。我们可以开发一个CLI来帮助我们避免这种低效操作。有了这个deep-touch的CLI,我们可以通过一行命令就实现在当前文件夹快速地创建深层次的文件。比如,我们项目名叫“deep-touch”,那我可以通过deep-touch index.js -p test.test.test命令创建如下的目录结构:

deep-touch---
            |
            |---test---
                      |
                      |---test---
                                |
                      			|---test---
										  |
                                          |--- index.js

好了,在清楚了我们所要实现的CLI功能后,我们第一步是设计我们的命令串结构。有了前面的前置知识,结合我们的需求,我们很容易设计出对应的命令串。 比如我们可以设计成“主命令+子命令+argument+option”(在这里,我要申明一下,这个option就是flag+flag argument)的形式:

$ deep-touch create index.js -p test.test.test

又或者说,为了简洁,我们可以设计成这样:

$ dt  index.js -p test.test.test

个人比较偏向于简洁的设计,所以,我采用上面的“主命令+argument+option”的设计。

首先,我们要按照开发环境搭建一节所说的步骤,搭建好deep-touch项目,当前的目录结构是这样的:

deep-touch---
            |
            |---bin---
            |---node_modules
            |---src
            |---package.json
            |---package-lock.json
            |

然后,我们修改一下package.json文件:

{
  "name": "deep-touch",
  "version": "1.0.0",
  "description": "A CLI for batch creation of files with long paths",
  "main": "index.js",
  "bin": {
    "deep-touch": "./bin/deepTouch.js",
    "dt": "./bin/deepTouch.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "littlepookshark",
  "license": "ISC",
  "dependencies": {
    "boxen": "^4.2.0",
    "chalk": "^4.1.0",
    "commander": "^6.0.0",
    "inquirer": "^7.3.3",
    "readline-sync": "^1.4.10"
  }
}

好了,到这里,我们的主命令已经算是实现了。为了方便,我们同时支持“deep-touch”和“dt”。当用户在命令行输入这两个主命令的时候,都是被解析到同一个nodejs文件上。

接下来,我们就是要实现deepTouch.js。这个实现过程其实就是利用commander包所提供的API来设计命令串的过程。使用commander包过程中,消费最多的API有以下几个:

  • version(): 创建命令行的版本号
  • command(): 创建子命令
  • arguments(): 创建某个主/子命令之下的argument
  • option(): 创建某个主/子命令之下的option,也就是flag+flag argument
  • action(): 解析到命令串上面的argument和flag argument,执行我们注册的回调
  • parse(): 开始解析命令行,是整个commander CLI的main函数。

通过使用以上的几个API,我们就可以完成deep-touch命令串的设计和架构:

// deepTouch.js

#!/usr/bin/env node

const deepTouch= require("../src/index");
const commander = require('commander');
const json = require('../package.json');

commander.version(json.version);
commander.arguments('<fileName...>')
         .option('-p, --path  <path>','the path of directory which the new file belong to ')
         .action((fileNames,cmdObj)=> {
            deepTouch(fileNames,cmdObj.path);
         });

commander.parse(process.argv);

从上面的代码可以看出,我们把命令行设计为主命令后面argument(通过arguments方法配置),它的值就是我们要创建的文件。而命令行可以加入多个option(通过option方法),我们此处只有一个option。从配置参数,我们可以看出,“-p”这个标志位是代表着需要创建文件所在的路径。

以上我们只设计了一条命令串,我们可以通过command()方法来添加别的命令串,这里就不展开阐述了,大家有兴趣的话,自行去官方文档去了解。

通过action方法,我们可以拿到commander.js帮我们解析出的fileNames和path,然后传给我们的deepTouch方法。然后,剩下的工作就是去实现了deepTouch方法了。实现deepTouch方法的过程中,我们不妨使用chalk和boxen来给输出的文字上颜色和加边框,从而达到美化的效果:

// src/index.js
const path = require('path');
const fs = require('fs');
const child_process = require('child_process')
const chalk = require('chalk');
const boxen = require('boxen');
const readlineSync = require('readline-sync');


function createFileSync(path) {
    fs.writeFileSync(path, '/* This is a file created by deep-touch*/');
    console.log(boxen(chalk.green(`create file success, the path is '${path}'`), { padding: 0 }));
    child_process.exec(`code --goto ${path}`,(error,stdout,stderr)=> {
        if(error || stderr){
            console.error(`execute command "code --goto ${path}" failed!`);
            console.log(error || stderr);
        }
    })
}


function checkFileName(fileName) {
    const isFile = (name)=> /.+\..+$/.test(name);
    if(!isFile(fileName)){
        throw new Error(chalk.red(`${fileName}是非法文件名!`));
        process.exit(1);
    }
}


function deepTouch(fileNames, pathName) {
    // 当不传入pathName参数的时候,默认在当前文件夹下创建文件
    let cwd = process.cwd();

    if(pathName && pathName.indexOf('.') > -1){
        const dirArr = pathName.split('.');

        // 创建文件的路径上,没有相应的文件夹就创建文件夹
        dirArr.forEach(name => {
            cwd = path.join(cwd, name);

            if (!fs.existsSync(cwd)) {
                fs.mkdirSync(cwd);
            }
        });
    }
    
    
    if (fileNames.length) {
        fileNames.forEach((filename)=> {

            checkFileName(filename);

            const filePath = path.join(cwd,filename);
            if(fs.existsSync(filePath)){
                if (readlineSync.keyInYN(chalk.blueBright(`> file '${filename}' had been existed, do you want to create a new file?`))) {
                    const newFileName = readlineSync.question(chalk.blueBright('> please input a new file name: '));

                    checkFileName(newFileName);
                    const newFilePath = path.join(cwd, newFileName);
                    createFileSync(newFilePath);
                  } else {
                    console.log(boxen(chalk.green(`file '${filename}' had been skipped!`), { padding: 0 }));
                  }
            }else {
                createFileSync(filePath);
            }
        });
    }
}

module.exports = deepTouch;

上面的代码简单来说实现了下面的步骤:

  1. 对path字符串进行解析。依次遍历该路径上的文件夹。如果不存在的话,则新建文件夹。如果用户没有传入path这个option的话,那么我就默认是在当前目录下创建文件。

  2. 遍历用户传进来的文件名数组,依次去同步创建。如果需要创建的文件在当前文件夹下已经存在的话,那么就进入问询环节。问询环节主要是使用“readline-sync”这个npm包来实现。因为这里需要同步地从命令行界面问询,上面介绍到的inquirer包就不太满足我们的需求了。

  3. 成功创建文件后,输出相应的提示告知你所创建文件的绝对路径。最后,默认使用VSC所提供的全局命令code帮你打开创建的文件。

到这里,我们已经实现了全部功能代码的编写了。下一步,我们就可以把它发布到npm仓库中。

发布npm包

如果没有从命令行界面登录的,先登录一下:

$ npm login

输入用户名和密码,成功登录后,可以用npm whoami来确定自己是否真的登录成功。然后,就可以直接发布了:

$ npm publish

测试

最后,我们全局安装一下:

$ npm i deep-touch -g

在随便哪个地方(比如,就在deep-touch这个项目里面)测试一下:

$ dt test.js -p src.test

从截图可以看出,我们成功了。

总结

  1. 开发自己的CLI工具,最重要的是有一定的关于命令行语法方面的前置知识。
  2. 其次是,需要掌握业内开发CLI所有主流的的npm包。
  3. 最后,就是围绕nodejs原生API或者npm包生态所提供的能力来实现自己想要的功能。

参考资料