机缘巧合,最近开发了一个较为复杂的命令行工具。
我觉得值得总结一下,在开发过程中,有哪些让我慢慢想明白的点,
以及开发一个命令行工具,需要坚持哪些原则。
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.json
的 version
字段。
(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
除了能接收 param1
和 param2
之外,
还会增加一个 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 文件用于源码映射,
rootDir
和 outDir
指明根目录和构建产物目录的位置,
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 的日志
在开发过程中,我将 debug 与 commander.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 个比较重要,
runtimeExecutable
为 npm
,表示我们要调试 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