使用 Node.js 开发命令行程序的最佳实践

在 Node.js 出现之前,我们所见的命令行程序大多是使用 shell、ruby、python 等脚本语言进行开发的。而现如今,Node.js 已经被广泛用来开发各种命令行程序,提升着工程师的开发效率。

只要有想法,使用 Node.js 就能很快的实现一个命令行程序。但是,在开发真实的“生产环境”命令行程序时,有很多方面需要我们关注。

这篇文章,尝试总结我在开发一个真实的命令行程序时的最佳实践。希望对你有帮助。

代码组织

一个良好的代码组织会更利于开发、维护、功能扩展。

本质上来说,使用 Node.js 开发的命令行程序就是满足一定约束的 npm 包,我们可以使用任何我们觉得恰当的方式组织代码。

我建议将命令行程序拆分成两部分,cli 和 core,类似于操作系统的 shell 和 kernel。

cli_and_core

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.jsyargs

这里推荐下 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);
}

参考资料

知识共享许可协议
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。