[Node] 命令行工具的设计原则

机缘巧合,最近开发了一个较为复杂的命令行工具。
我觉得值得总结一下,在开发过程中,有哪些让我慢慢想明白的点,
以及开发一个命令行工具,需要坚持哪些原则。

1. 命令行工具

我使用的编程语言是 Node.js,用它开发一个命令行工具非常的简单。

(1)初始化

首先我们要用 npm 初始化一个 Node.js 项目(名字就称为 debug-cli 吧),

$ mkdir debug-cli
$ cd debug-cli
$ npm init -f

(2)bin/ 文件夹

Node.js 的命令行代码入口文件,要放到项目根目录 bin/ 文件夹中。
名字默认为 index.js,使用其他名字要在 package.json 中进行配置。

入口文件的第一行,要添加 Shebang 序列,

#! /usr/bin/env node

在类 Unix 系统中,包含 Shebang 的文本,如果作为可执行文件执行,
#! 后面指定的解释器将会被调用,用来运行后面的代码。
因此,以上入口文件作为可执行文件来执行,会自动调用 node 解释器来运行。

注:/usr/bin/env 不是一个路径,而是一个命令,
后面跟 node 参数,就会找到 node 安装路径,然后调用它。

(3)package.json 配置

package.json 中,要设置 bin 字段,指向 bin/ 文件夹中的入口文件,
有两种写法:

  • 可以直接 "bin": "bin/index.js"
  • 也可以 "bin": { "myCli": "bin/index.js" }

包含 bin/ 目录的 Node.js 命令行工具模块,
安装后会在 node_modules/.bin/ 目录,添加一个或多个软连接,
指向该模块 package.json bin 字段配置的那些文件。

(4)本地运行

本地运行时,可以在项目根目录下这样调用,

$ cd debug-cli
$ node bin/index.js ...

2. 命令解析库

我使用了 commander.js 处理命令行参数。
使用这个库之后,bin/index.js 的文件结构如下,

#! /usr/bin/env node

const program = require('commander');
const { version } = require('../package.json');

// 引用构建之后的目标产物
const cliFunc = require('../out/src/cli/xxx');

...
program.version(version);

// ---- ---- ---- ---- ---- ---- ---- ---- ---- ----

program
  .command('xxx <param1> [param2]')
  .alias('x')
  .option('-l, --log', '输出日志')
  .description('功能介绍')
  .action((...args) => cliFunc(...args));  // 调用 cliFunc

// ---- ---- ---- ---- ---- ---- ---- ---- ---- ----

// 使用了未定义的命令
program.on('command:*', () => {
  console.error('命令未定义', program.args.join(' '));
  process.exit(1);
});

program.parse(process.argv);

在上文中介绍了,文件的第一行,用来指明这是一个 Node.js 脚本。
之后我们导入了项目的构建产物 ../out/src/cli/xxx,它暴露了一个方法出来。

关于 program 的更多使用方式,可参考 commander.js 的文档。

(1)version

其中,program.version 指定了该 Node.js 命令行工具用 --version 调用时展示的版本号,
这里我让它展示 package.jsonversion 字段。

(2)command

每一个 program.command().alias().option().description().action() 指定了一种命令行调用方式,例如,

program
  .command('xxx <param1> [param2]')
  .alias('x')
  .option('-l, --log', '输出日志')
  .description('功能介绍')
  .action((...args) => cliFunc(...args));  // 调用 cliFunc

<param1> 尖括号约定这是一个必选参数,[param2] 约定这是可选的。

调用方式如下,

$ node bin/index.js xxx -l param1 param2

cliFunc 除了能接收 param1param2 之外,
还会增加一个 command 参数,表示命令行参数,

const cliFunc = (param1, param2, command) => {
  // --log 或者 -l 调用时,command.log 为 true
};

(3)on

program.on 用于处理任何未被定义的命令,例如,

$ node bin/index.js yyy

(4)parse

program.parse(process.argv) 表示对传入 Node.js 的命令行参数进行解析。
其中 process.argv 是 Node.js 进程接受到的原始的参数。

3. 构建配置

构建是一个项目中比较重要的环节了,即使对于 Node.js 项目也是如此,
一般而言,我们会选择一个类型安全的,IDE 友好的语言进行开发,
然后将这个语言编译成 Node.js。

当前比较流行的语言是 TypeScript,所以我的命令行工具项目,
还需要对 TypeScript 进行一些配置。

(1)tsconfig.json

这里面提供了 TypeScript 相关的一些配置,最简配置如下,

{
  "compilerOptions": {
    "target": "ES2015",
    "module": "CommonJS",
    "sourceMap": true,
    "rootDir": "./",
    "outDir": "./out/",
    "resolveJsonModule": true,
  }
}

target 表示编译成 ES2015
module 表示构建产物的模块组织形式为 CommonJS
sourceMap 表示要产生 .map 文件用于源码映射,
rootDiroutDir 指明根目录和构建产物目录的位置,
resolveJsonModule 表示可以直接 import 一个 json 文件。

(2)package.json 中 配置 scripts

package.json 的 scripts 字段用来配置一些 npm scripts 命令,
可以使用 npm run xxx 的方式来调用。

常用的 scripts 有这么几个,

"scripts": {
  "clean-build": "rm -rf ./out",
  "build": "npm run clean-build && tsc -p ./",
  "watch": "npm run clean-build && tsc -w -p ./",
  "test": "mocha -timeout 100000",
  "log:test": "DEBUG=xxx* mocha -timeout 100000",
  "debug:test": "DEBUG=xxx* node --inspect-brk=5858 node_modules/.bin/mocha -timeout 100000",
  "clean-all": "rm -rf ./node_modules ./release ./out",
  "rebuild": "npm run clean-all && npm i && npm run build",
  "package": "npm run rebuild && pkg . -t node10-linux-x64 --options max_old_space_size=8192 -o ./release/main"
},

clean-build 表示清空构建产物,
build 用于调用 tsc 构建 TypeScript,
watch 用于监控文件的变更,并重新构建,
test 用于跑单测,
log:test 用于带日志的方式跑单测,
debug:test 对单测进行 debug,
clean-all rebuild 重新安装依赖并构建,
package 使用 pkg 打包成可执行文件。

其中有几个需要注意的点,

  • 测试框架选用了 mocha,也可以选用别的
  • DEBUG=xxx* 是使用了 shell 前置参数,这是 debug 这个库需要的
  • 打包成二进制可执行文件不是必须的,也可以选用 pkg 之外的工具打包

4. 日志 和 调试 配置

一个工程能否便捷的 debug,是一项非常重要的属性,
我们需要做两点准备,一个是考虑如何输出日志,另一个是如何配置 IDE。

(1)日志库

日志输出库,我使用了 debug

const debug = require('debug');
const log = debug('xxx');  // 通过 `xxx` 名字对日志进行分类

log(...);  // 代码中写日志

命令行方式调用是,默认是不输出日志的,除非增加 DEBUG=... 前置参数,

$ node bin/index.js ...  # 无日志
$ DEBUG=xxx* node bin/index.js ... # 展示分类名为 xxx 的日志

在开发过程中,我将 debugcommander.js 进行了结合,
通过 --log-l 参数,控制日志的输出。

$ node bin/index.js --log ... # 展示日志

这是通过调用 debug 模块的 enable 方法来实现的,

import * as debug from 'debug';

/**
 * 动态开启 log
 */
const enableLog = () => {
  debug.enable(debugName);
};

代码里检测到命令行参数传递了 --log-l 就强制开启 log,例如,

const cliFunc = (param1, param2, command) => {
  // --log 或者 -l 调用时,command.log 为 true
  if(command.log) { 
    enableLog();
  }
};

(2)调试配置

能够使用 IDE 进行调试,才能便于跟踪到正在执行的代码中去,
下面展示了如何配置 VSCode 用于调试该项目的单测。

需要有两方面的准备,

  • 一方面,我们要配置 package.json 的 scripts
"scripts": {
  "debug:test": "DEBUG=ast-utils* node --inspect-brk=5858 node_modules/.bin/mocha -timeout 100000"
}

这里要给 node 传入了 --inspect-brk=5858 参数,
所以就不能直接调用 mocha 了,而是转为 node 去调用 mocha。
即,不能使用以下方式调用了,

"scripts": {
  "log:test": "DEBUG=ast-utils* mocha -timeout 100000",
}

因为此时如果传入 --inspect-brk=5858,则是向 mocha 传参数了。

  • 另一方面,我们要配置 VSCode

新建或者修改根目录中已有的 .vscode/launch.json 文件,

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug",
      "skipFiles": [
        "<node_internals>/**"
      ],
      "runtimeExecutable": "npm",
      "runtimeArgs": [
        "run-script",
        "debug:test"
      ],
      "port": 5858,
      "stopOnEntry": false,
    }
  ]
}

有几个需要注意的点,
type 表示应用类型,
request 表示用直接启动的方式来打开 debug,而不是 attach 到进程的方式,
name 表示这个配置项的的名字,用来在 debug 面板中点选,
skipFiles 表示略过一些内置的 node 文件,断点不跳进去,

后面 4 个比较重要,
runtimeExecutablenpm,表示我们要调试 npm scripts
runtimeArgs 参数为一个数组,第二个参数表示 npm scripts 的名字
port 指定为刚才配置的--inspect-brk=5858 这个端口号,
stopOnEntry 表示是否停在 Node.js 第一行,否的话会直接执行,到断点才停。

5. 单元测试

一个中大型项目中的单元测试是不可少的,可以有效的避免一些低级错误。
这个项目中我选用了 mocha 这个库来写单测。

只需要在项目根目录,创建一个 test/ 文件夹,然后写到 test/index.js 中即可。
单测文件通常写为 index.test.js,这只是一种约定,并不影响功能。

单测文件的内容结构如下,

const assert = require('assert');  // Node.js 内置的断言库,也可以选其他的

// 构建产物中暴露出来的方法
// 跟 bin/index.js 中使用相同的引用
const cliFunc = require('../out/src/cli/xxx');

describe('这是一个单测', () => {
  describe('这是一个用例', () => {
    beforeEach(async () => {
      // ... 每次跑用例都会执行
    });

    it('不同的场景', async () => {
      const command  = { ... };  // 手动传入 command
      await cliFunc(param1,param2, command);
      debugger;
    });

    // 可以测试多个场景
    // ...
  });

  // 可以写多个用例
  // ...
});

这样配置好单测之后,结合 VSCode 的 debug 配置,
就可以对单测进行调试了。

6. 小结

有了以上配置之后,终于可以写功能性的代码了。

总结一下,上文介绍了 Node.js 如何编写命令行工具,

  • 使用 commander.js 解析命令行参数,
  • 配置了 TypeScript 的构建选项,
  • 使用了日志库 debug
  • 对 VSCode 进行了调试配置
  • 使用了 mocha 来编写单测

目录结构如下,

.
├── README.md
├── bin
│   └── index.js
├── out
│   └── src
│       ├── cli
│       │   └── xxx
│       │       ├── index.js
│       │       └── index.js.map
│       └── util
│           ├── log.js
│           └── log.js.map
├── package.json
├── src
│   ├── cli
│   │   └── xxx
│   │       └── index.ts
│   └── util
│       └── log.ts
├── test
│   └── index.js
└── tsconfig.json

完整代码在这里,github: debug-cli

7. 设计原则

除了使用的工具链之外,我觉得最重要是包含在其中的设计原则,
也就是之所以这样做的原因。

主要有这样以下几点,

  • 轻接口重能力:将接口层独立出来,命令行接口中尽量不包含业务逻辑,能复用的功能都下沉
  • 类型和单测:为了不影响迭代效率,够用就好,不追求特别的严谨
  • log 和 debug:log 和 debug 功能是第一位的,应坚持这两个功能总是好用的,降低一切问题的排查成本

除此之外,代码的演化过程中,还可能会形成一些编码方法论,

  • 代码组织方式:项目内部出现的重复功能,放到 common 中;可能演变为跨项目可用的能力,放到了 util 中
  • 辅助和废弃方法留存:开发过程中用到实验室方法和曾用方法,都留存在了项目中,这样有助于探索式开发
  • 脏逻辑隔离:一些明显硬编码的脏逻辑,进行隔离放到了 ugly 中

总共涉及了这样几个关注点,即,
如何开发起来效率更高,如何排查问题更便捷,
如何组织代码。

我想以上每个问题,不同的人都会有自己的考虑。
如此便形成了不同的设计风格。


参考

github: debug-cli
commander.js
debug
mocha
Debugging in VSCode
TypeScript tsconfig.json Ref

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容