Node.js

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

2020-11-30  本文已影响0人  何幻

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

1. 命令行工具

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

(1)初始化

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

(4)本地调试

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

$ 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/cli/xxx,它暴露了一个方法出来。

program 的使用方式,可以参考 commander.js 的文档。

(1)version

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

(2)command

program.command().alias().option().description().action()
分别指定了一种命令行调用,

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

<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 打包成可执行文件。

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

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 用于调试该项目的单测。

需要有两方面的准备,

"scripts": {
  "debug:test": "DEBUG=ast-utils* node --inspect-brk=5858 node_modules/.bin/mocha -timeout 100000"
}

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

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

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

新建或者修改根目录中已有的 .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 如何编写命令行工具,

目录结构如下,

.
├── 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. 设计原则

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

主要有这样以下几点,

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

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

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


参考

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

上一篇下一篇

猜你喜欢

热点阅读