在 Node.js 出现之前,我们所见的命令行程序大多是使用 shell、ruby、python 等脚本语言进行开发的。而现如今,Node.js 已经被广泛用来开发各种命令行程序,提升着工程师的开发效率。
只要有想法,使用 Node.js 就能很快的实现一个命令行程序。但是,在开发真实的“生产环境”命令行程序时,有很多方面需要我们关注。
这篇文章,尝试总结我在开发一个真实的命令行程序时的最佳实践。希望对你有帮助。
代码组织
一个良好的代码组织会更利于开发、维护、功能扩展。
本质上来说,使用 Node.js 开发的命令行程序就是满足一定约束的 npm 包,我们可以使用任何我们觉得恰当的方式组织代码。
我建议将命令行程序拆分成两部分,cli 和 core,类似于操作系统的 shell 和 kernel。
cli 仅仅是命令的入口,会调用 core 来完成真正的功能。这样的架构方式有如下的好处:
- 更容易扩展。比如我们哪天想开发 GUI 程序,那 core 部分的代码是可以直接重用的。
- cli 变得很轻量,core 可以独立进行更新(甚至可以做后台的静默更新)。
- 更容易测试。因为 core 是可编程的,我们将更容易针对它编写单元测试。
解析命令行
想让 Node.js 编写的命令行程序能正常工作,我们需要对命令行参数进行解析。一般我们会在入口文件进行解析操作。如:
#!/usr/bin/env node
require('../lib/cli').parse(process.argv);
入口文件的第一行是必须提供的,实际上是一个 shellbang,操作系统会使用 shellbang 指定的程序来执行脚本,这里就是 node。如果我们想要传递额外参数给 node 也是可以的,比如,设置更多的 old space 内存空间,避免内存不够用:#!/usr/bin/env node --max-old-space-size=10240
。
执行命令时传递的命令行参数可以通过 process.argv
获取到。它是一个数组,process.argv[0]
总是等于执行脚本的程序,process.argv[1]
总是等于所执行脚本的路径。我们一般会从 process.argv.slice(2)
开始解析。
社区中有很多命令行解析的模块,比如:commander.js、yargs。
这里推荐下 Caporal.js,它的使用方法类似 commander.js,但是提供了更丰富的定制性,内置拥有日志级别的终端 log 模块,可以实现 autocomplete,生成的帮助信息也更加“漂亮”。大家可以尝试下看看。
恰当的交互
既然是开发命令行程序,总免不了需要和用户进行一些人机交互。比如:确认用户动作、提供用户可选项、耗时操作的加载提示等。
对于交互形式,社区中这方面做得最完善的应该就是 Inquirer.js 了,它提供了确认框、列表选择(单选或者多选)、输入框等等非常实用的交互组件,我们可以按需进行实用。
对于加载提示,推荐使用 ora,颜值高、使用方便。而且还支持加载后显示成功还是错误的图形标志。
我们在实现具体的交互策略时,让命令行选项都可交互会是一个很好的功能。
比如,命令行支持如下命令:
- cli cmd -a
- cli cmd -b
那我们可以考虑当用户输入 cli cmd
时,弹出列表选择让用户选择是使用 -a
选项还是 -b
选项。
更新策略
更新策略应该是发布一个命令行程序时首先要考虑的功能。
更新并不仅仅是 npm publish
将新版本发布到 npm registry,还需要考虑我们怎样告知用户我们的命令行程序更新了,以什么样的策略来执行更新检查。
关于更新检查功能,推荐使用 pkg-updater。它拥有如下特性:
- 直接从 npm registry 拉取版本信息
- 使用后台 daemon 进程检查更新,不会阻塞命令执行
- 支持自定义更新文案、检查间隔、检查 tag 等
- 支持强制升级策略(必须更新才可使用)
这基本已经是一个比较完善的更新策略了,更多信息可以参考:Node.js 命令行工具检查更新的正确姿势。
错误上报
命令行程序难免会有发生错误的情况,怎样对待这些错误才是我们的重点。
最佳实践应该是将详细的错误日志写到一个日志文件,然后最好能上报到服务端以供我们分析。
可以使用类似下面的代码来进行错误收集和上报:
process.on('uncaughtException', onFatal);
process.on('unhandledRejection', onFatal);
cli
.exec()
.catch(onFatal);
function onFatal(e) {
// 收集数据
const data = {};
data.code = e.code;
data.message = e.message;
data.stack = e.stack;
data.os = process.platform;
data.node_version = process.version;
data.cli_version = pkg.version;
// 写文件
try {
fs.writeFileSync('cli-error.log', JSON.stringify(data), 'utf8');
} catch (e) {}
// 使用后台进程上报错误
require('child_process').spawn(
process.execPath,
[
'_report.js',
'http://api.example.com/error/report',
JSON.stringify(data)
],
{'stdio': ['ignore', 'ignore', 'ignore'], 'detached': true}
).unref();
// 退出
process.exit(1);
}