基于Node开发命令行程序的常用工程实践

介绍

node 开发命令行程序非常方便,我们常用的 webpack,babel,http-server,express-generator, yeoman 等等,都是 node.js 开发出来的命令行工具。

原理

node.js 的程序一般通过 node 指令进行运行。而在 Unix Like 的系统中,任何一种脚本也可以被系统当做可执行程序执行。只需要按照如下约定,设置脚本的运行时程序即可,例如对于 Node.js 脚本来说,只需要设置他的运行程序是 node:

1
2
#!/usr/bin/env node
console.log('hello world');

这样,该文件就可以直接在 Linux 系统中输入脚本名进行运行了。Linux 会自动使用 node 来运行该脚本。

假设该脚本文件名为 test, 此时便可在 shell 中直接执行 ./test, 而不需要使用 node ./test. (实际上他们是相等的)。

npm 可以自动生成这种 bash 文件

在 npm 包中,有一种通过 -g 全局安装的包,实际上就是将 npm 包内的 bin 入口 js 自动创建了 /usr/local/bin 下面一个软连接,连接到了 npm 安装的该全局包所在位置。如果你也希望在 /usr/local 下出现你的可执行文件的话,只需要在 npm 包中将我们预先写好的入口 js 文件设置为package.json 的 bin 字段即可:

1
2
3
4
"main": "./lib/echo.js",       # 入口模块位置
"bin" : {
"node-echo": "./bin/node-echo" # 命令行程序名和主模块位置
}

其中 node-echo 是一个 JavaScript 编写的 Node.js 代码文件,里面会按照 Unix 格式在顶部表明它需要被 node 来运行。也正因如此,node-echo 我们就不写扩展名 .js 了。

然后入口文件一般放置在 package 包目录根目录中的 bin 目录下,如 bin/node-echo 这样写:

1
2
#! /usr/bin/env node
require('../src/index.js'); // 也可以写任何你喜欢的逻辑

此时,使用 npm i -g 来安装这个包时, npm 则会自动在 /usr/local/bin 下面生成 node-echo 可执行文件,它是一个软连接,会链接到:

yourNpmPrefix/node_modules/yourPackageName/bin/node-echo

命令行程序常用操作

开发命令行程序,有一些基本的套路,比如一般:

  • 输入命令本身 xxxx -h 来展示程序的基本用法(包括 usage, options 列表)
  • 通过输入 -n sheldon--name sheldon 的方式获取用户输入的参数配置 option
  • 通过 -r--recusive 的方式来打开或关闭某个布尔类型的配置 option
  • 通过在最后加 value 参数来获取用户输入的操作对象,如 cp -r aFolder bFolder 中的 aFolder bFolder 就是 value 参数。
  • 通过 xx yy 的方式来支持子命令(yy 是 xx 的子命令)
  • 更高阶一点,遇到某些命令后可以交互式的获取用户输入的一些参数配置,如询问用户要初始化的模板名字是什么等等

对于 options 参数,要记得:如果用户输入 -abc,commander 会当做 -a -b -c 来解析。所以如果你希望的是 abc 参数的话,请在终端输入 --abc

commander

先来看强大的 commander

1
2
3
4
5
6
7
8
9
10
11
12
const program = require("commander");
program
.version("0.1.0")
.option("-p, --peppers", "Add peppers")
.option("-P, --pineapple", "Add pineapple")
.option("-b, --bbq-sauce", "Add bbq sauce")
.option(
"-c, --cheese [type]",
"Add the specified type of cheese [marble]",
"marble"
)
.parse(process.argv);

使用 commaner,首先要注册上你希望的 option,最后要调用一下 .parse(process.argv),他才会对进程的入参进行解析并转换为 commander 对象上的属性。

如果是布尔类型的 option(即不需要设置 value),则只需要:

1
2
.option('-p, --peppers', 'Add peppers') // 其中第一个参数格式是固定的,第二个参数是 --help 时候要显示的提示。
.option('--no-sauce', 'Remove sauce') // 这是定义了一个 否定用法。即用户如果输入 `--no-sauce`, 则commander拿到的sauce就是false

如果是 value 类型的 option,则

1
.option('-c, --cheese [type]', 'Add the specified type of cheese [marble]', 'marble') // 其中第一个引号里要固定加上中括号表示的参数(尖括号表示必填),第二个参数是帮助文案,第三个参数是cheese参数的默认值(如果不需要默认值可以不填)

如果希望能在 --help 的时候显示用法,只需:

1
program.usage("[options]");

这样执行命令时便会打印:

1
Usage: xxx[options]; // 其中 xxx 是你的命令行程序名字,会自动填在前面;后面就是你定义的usage字符串

如果希望拿到用户输入的 value 参数,则可以调用: program.args , commander 会把解析的 value 参数以数组形式放置在 args 属性上。

如果你的命令行程序有子命令,如 lime init [options] 则可以通过 command 方法添加:

1
2
3
4
5
6
program
.command("rm <dir>")
.option("-r, --recursive", "Remove recursively")
.action(function (dir, cmd) {
console.log("remove " + dir + (cmd.recursive ? " recursively" : ""));
}); // 其中 action回调函数收到的第一个形参为命令中的参数如dir,最后一个形参是命令的Command对象(该对象可以直接访问来获取相关option)

command 方法中可以声明命令所需的 value 参数,如果用尖括号包裹 dir, 则 dir 就是必填的;中括号包裹就是选填;也可以声明多个参数。

1
2
3
4
5
.command('rmdir <dir> [otherDirs]') // 表明rmdir子命令需要2个参数,第一个必填,第二个选填
.command('rmdir <dir> [otherDirs...]')
.action(function (dir, otherDirs) {

}) // 表明otherDirs是可变参数,会以数据形式传递给 action 的 otherDirs形参。

commander 中还有很多针对参数类型、参数格式的设定的方式,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
program
.version("0.1.0")
.usage("[options] <file ...>")
.option("-i, --integer <n>", "An integer argument", parseInt)
.option("-f, --float <n>", "A float argument", parseFloat)
.option("-r, --range <a>..<b>", "A range", range)
.option("-l, --list <items>", "A list", list) // 输入格式为 -l 1,2,3
.option("-o, --optional [value]", "An optional value")
.option("-c, --collect [value]", "A repeatable value", collect, [])
.option(
"-v, --verbose",
"A value that can be increased",
increaseVerbosity,
0
)
.parse(process.argv);

或针对选项设定 正则表达式 规则:

1
2
3
4
5
program
.version("0.1.0")
.option("-s --size <size>", "Pizza size", /^(large|medium|small)$/i, "medium")
.option("-d --drink [drink]", "Drink", /^(coke|pepsi|izze)$/i) // 输入的-d参数必须满足正则,否则会无法获取到-d的输入参数,变成布尔类型。
.parse(process.argv);

另外一种设置子命令的方式是通过子文件的方式。

大家可以去参考 commander 官方文档

智能帮助: 如果你希望在用户输入 -h 的时候能添加一点自己的东西,可以:

1
2
3
4
5
6
program.on("--help", function () {
console.log("");
console.log("Examples:");
console.log(" $ custom-help --help");
console.log(" $ custom-help -h");
});

如果你希望在某些场景(例如用户没有输入子命令时),主动打印 help 信息,则可以:

1
2
3
4
5
6
7
8
9
if (program.args.length < 1) {
// 没有输入子命令,则打印帮助
program.outputHelp((txt) => {
txt += "\n";
txt += " Examples: \n\n";
txt += " hello";
return txt;
});
}

yargs 获取命令行参数

获取命令行参数,在很多编程语言中都类似。node 中 process.argv 表示执行该脚本时传入的参数的数组。其中,前两个参数分别就是 node 可执行程序的位置和当前脚本的位置,如我的 mac 上的执行效果如下:

1
2
[ '/usr/local/Cellar/node/8.6.0/bin/node',
'/Users/cuiyongjian/Code/lime-cli/lime-cli/bin/lime' ]
  1. 然而,在命令行程序中,经常需要用户设置命令的 options, 如:
1
2
node hello --name=tom
node hello -n tom

这时我们需要获取 shell 中用户输入的 name 或 n 参数,除了自己解析 process.argv 数组外,还可通过第三方包 yargs 来更方便获取这些入参

1
2
var argv = require("yargs").argv; // argv拿到的就是
console.log(argv.n); // yargs.argv会采集所有输入转为 key-value 对的形式

若想让 n 是 name 的别名,则可以设置 yargs:

1
var argv = require("yargs").alias("n", "name").argv;

通过 argv._ 可以获取到所有非 options 的参数,如下面这条命令的 argv._ 的结果就是 [ 'A', 'B', 'C' ]:

1
hello A -n tom B C
  1. 使用命令行参数时,还需要经常接收 -h 参数展示帮助信息;对参数必填选填进行校验;布尔性质的 option 等操作。yargs 都具备了这些功能,例如可以设置一个参数的各种特性:
1
2
3
4
5
6
7
var argv = require("yargs").option("n", {
alias: "name",
demand: true,
default: "tom",
describe: "your name",
type: "string",
}).argv;

此时,就为 argv 设置了一个 n 参数的详细信息,表示其是必填的,且默认值是 “tom”, 描述信息是 “your name”。 在命令行中输入 node yourfile -h, 则 argv 会给显示出你配置这些参数使用方法:

1
2
3
4
选项:
--help 显示帮助信息 [布尔]
--version 显示版本号 [布尔]
-n, --name your name [字符串] [必需]
  1. 当然,这还不够,一个完整的命令行程序应该还要提示用法(Usage), 例子(Example),所以 yargs 也提供了一个简单的配置说明和示例的 API:
1
2
3
4
5
6
7
8
9
10
11
12
13
var argv = require("yargs")
.option("f", {
alias: "name",
demand: true,
default: "tom",
describe: "your name",
type: "string",
})
.usage("Usage: hello [options]")
.example("hello -n tom", "say hello to Tom")
.help("h")
.alias("h", "help")
.epilog("copyright 2015").argv;
1
2
3
4
5
6
7
8
9
10
11
Usage: hello [options]

选项:
--version 显示版本号 [布尔]
-f, --name your name [字符串] [必需] [默认值: "tom"]
-h, --help 显示帮助信息 [布尔]

示例:
hello -n tom say hello to Tom

copyright 2015

这就比较完整了。 如果 -n 只是作为一个 option 开关来用,则只需将其设置为 boolean 类型即可:

1
2
3
var argv = require("yargs").option("n", {
boolean: true,
}).argv;
  1. 子命令怎么办

我们使用 git 时,经常会用到类似 git remote ... 这样的命令。其 remote 就是 git 的一个子命令,子命令有自己的 option 和输入值,yargs 支持设置子命令。 要注意子命令的 option 配置,要在子命令捕获后的回调函数里进行设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
require("shelljs/global");
var argv = require("yargs").command(
"morning",
"good morning",
function (yargs) {
echo("Good Morning");
var argv = yargs
.reset()
.option("m", {
alias: "message",
description: "provide any sentence",
})
.help("h")
.alias("h", "help").argv;

echo(argv.m);
}
).argv;

使用 inquirer 与用户进行交互

inquirer 可以交互式的提示用户输入某些参数。

1
2
3
4
5
6
7
inquirer.prompt([
{
type: 'confirm',
name: 'destOk',
message: '确认使用目标文件夹:' + destFolder
}
]).then(function(answers){

子进程运行其他命令

有时我们在命令行程序里需要调用其他 bash 命令,这时可以通过 node 自带的子进程模块,可以执行任意的 shell 程序,并异步接收结果:

1
2
3
4
5
6
7
8
#!/usr/bin/env node
var name = process.argv[2];
var exec = require("child_process").exec;

var child = exec("echo hello " + name, function (err, stdout, stderr) {
if (err) throw err;
console.log(stdout);
});

有个第三方包 shelljs, 可以利用其 API 进行各种 Unix 命令操作, 例如:

1
shelljs.rm(@params, @destinationFile)

shelljs 还有全局 API 模式,可以将一些 linux 命令 API 设置在 node 全局空间内,如:

1
2
3
4
5
6
7
8
9
require("shelljs/global");

if (!which("git")) {
echo("Sorry, this script requires git");
exit(1);
}

mkdir("-p", "out/Release");
cp("-R", "stuff/*", "out/Release");

最好不要使用全局模式咯,毕竟覆盖掉全局空间内的 API 是不太好。

字符图片

为了让你的命令行程序 高大上,你可能需要一点 字符图片 作为 logo。可以安装这个 node 程序:

1
npm install -g figlet-cli

然后执行:

1
figlet "LIME TOOL"

生成的字符图片如下:

1
2
3
4
5
 _     ___ __  __ _____   _____ ___   ___  _
| | |_ _| \/ | ____| |_ _/ _ \ / _ \| |
| | | || |\/| | _| | || | | | | | | |
| |___ | || | | | |___ | || |_| | |_| | |___
|_____|___|_| |_|_____| |_| \___/ \___/|_____|

还可以吧老妹

其他常用工具

colors 一个修改 shell 中打印字符的颜色的工具。
chalk 也是一个颜色库
ora 一个能在 shell 内产生动态 loading 效果的库
rimraf 一个简单的 rm 删除库
conf 一个可以用来存储配置的库,有了它就不用自己写 json 来存储一些配置信息啦
cli-table 一个能在命令行打印表格的  模块
boxen 可以在终端命令行内生成字符框框,瞬间高大上咯

调试

自己在本地调试 node 命令行程序时,可以采用 npm link 的方式把当前包  软链 到全局。如果是调试一个本地包也可以把它 link 到局部。使用方式非常简单,可以参考这篇文章: https://github.com/atian25/blog/issues/17

我的示例

  • merge-file 可以基于二进制进行 2 个文件的无脑合并
  • lime-cli 青檬脚手架~ 可以用此工具生成各种常用的前端项目脚手架,例如 webapp 项目、前端库项目等。

实践

首先,你需要构造基本的命令行程序项目骨架。如果你不想自己操作,可以通过 lime-cli 来自动生成一个 nodebin-template 类型的项目骨架。此脚手架做了一件非常简单的事情,就是在你的项目中,定义好了一个带有 #! /usr/bin/env node 的文件,并配置好了 package.json.

接下来,只需在 index.js 中书写逻辑代码即可。如果业务比较复杂,可以将代码归并到 lib 目录中(lib 目录已经默认设置为了发布到 npm 的目录)。

Refer

教你从零开始搭建一款前端脚手架工具