背景
命令行工具,是 Node.js 最初也是最大的应用场景,我们日常工作中会经常用到 NPM、Babel、Webpack 等 CLI,我们的应用的工程化也往往需要依托于自有的命令行工具。
目前业界已经有大量 CLI 工具库,以下几个均是大家比较耳熟能详的:
- commander.js
- 经典老牌类库,小巧精简,适合实现一些小 CLI 工具。
- 函数式定义指令配置,指令少的时候比较简洁直观。
- 缺少插件、框架、中间件等通用的拓展方案。
- 学习成本较低。
- yargs
- 功能比 commander 多且强大,能满足大部分独立 CLI 工具的需求。
- 也是函数式定义指令配置,指令少的时候比较简单直观。
- 提供了配置继承的能力,但是也缺少插件、框架等通用的拓展方案。
- 学习成本中等,主要是功能太多且复杂。
- oclif
- 定位为 CLI 框架,功能强大且齐全。
- 通过传统类定义的方式定义指令配置,支持指令继承(但是似乎不支持继承配置),OOP 的编程风格让代码拆分较为简单。
- 具备较强的拓展能力和插件、生命周期钩子机制,但缺少框架方案,不易提供场景化的 CLI 框架封装,生命周期的钩子也没有中间件那么灵活。
- 学习成本中等偏上,比前两个多了不少概念。
为什么在上面的分析里面,我们都会考虑一个『扩展性』的需求呢?因为在我们过往的实践中,会涉及到社区开源和内部上层框架之间的协同问题:内部工具往往会继承于我们开源的社区工具,做一些私有逻辑,并会持续把企业的最佳实践下沉到社区。
这是 Egg 那边的一些真实实践:
其中的 common-bin 就是基于 yargs 封装的命令行框架,但是在多年实践中,我们也发现了 common-bin 的一些问题,比如年久失修、缺少插件机制、TS 不友好、缺少通用的切面逻辑 等等 ...
命令行框架
上文所说的场景,在 Artus 的体系里面,也有类似的情况,作为一个定位为框架的框架的开源项目,我们也不可避免的需要考虑对应的命令行场景。
在企业场景下,一个命令行除了常规的能力外,往往还有以下要求:
- 支持身份鉴权
- 支持灵活的定制化和扩展能力
- 具备一定的统一管控能力
- 自动化升级能力
- 现代化、健壮、便于测试
是不是觉得有点眼熟?这些要求其实跟一个 Web 框架很像,正好 Artus 的一个核心卖点就是协议无关性。那我们是不是可以基于 Artus 来封装一个 CLI 框架的框架呢?
试着对标下:
- 每一个 Command 是不是类似一个 Controller?
- Command 之间的公共逻辑是不是类似一个 Service?
- 支持身份鉴权,可以通过 Middleware 来统一拦截?
- 定制性和扩展能力,不正是插件机制么?
- 开源版本和企业版本的关系,不正是框架机制么?
Artus 天然具备了流水线、插件、框架、IoC 等能力,只需要额外再封装几个装饰器,实现下 argv 的 parser 能力,就很快能实现了。
不仅能让 CLI 的编程界面与我们应用的编程界面一致,减少开发者学习成本的同时,还能拓展 Artus 的使用场景,吃自己的狗粮。
甚至我们有时在命令行场景需要的一些能力,如 oss、redis 都可以直接复用 Artus 的生态了,也能直接引入 Artus 上层框架的一些 Loader 用于构建期的分析。
那还等什么? 干吧~
POC 验证
设计思路
- 基于 Artus 的 pipeline 流水线设计,将指令输入作为协议,将指令执行通过中间件模式串联,支持指令重定向。
- 用户编程风格方面,采用 IoC 的方式来定义指令、参数配置、中间件。
具体实现
该想法其实最早萌生于 8/9 月份
- 当时天猪抽空搞了个第一版基于 yargs 的大概思路( 初版代码 );
- 后续我基于该版本将其完善,并脱离了对 yargs 的依赖,便有了 第二版代码;
现在这个是第三版代码:https://github.com/artus-cli/artus-cli ,为了方便验证及体验已经发了 0.0.x 的 beta 版本。
Examples
体验用的相关 demo 例子都在:https://github.com/artus-cli/examples
编程界面
目录结构
跟 Artus 的应用一样,除了 config 目录的约定之外,其他原则上不约束目录结构,但是我们也会有一些推荐的规范( 比如入口文件统一放到 bin 目录下,指令文件统一放到 cmd 目录下 ),比如 simple-bin
如果不想分那么多目录或文件也可以参考 singlefile 例子的单文件结构
simple-bin
├── bin
│ └── cli.ts 入口文件
├── cmd
│ └── main.ts 主命令
└── package.json 描述文件
simple-bin 中只有一个指令,如果存在多个指令的情况,也可以参考 egg-bin 的目录结构:
egg-bin
├── bin
│ └── cli.ts
├── cmd
│ ├── cov.ts
│ ├── debug.ts
│ ├── dev.ts
│ ├── main.ts
│ └── test.ts
├── config
│ ├── framework.ts
│ └── plugin.ts
├── index.ts
├── meta.json
└── package.json
入口文件
CLI 的可执行文件,只要引入 @artus-cli/artus-cli
的 start
方法并且执行即可,一般定义在项目的 bin 目录中。
#!/usr/bin/env node
import { start } from '@artus-cli/artus-cli';
start();
定义指令
DefineCommand
通过 DefineCommand
这个装饰器可以定义一个指令。
// index.ts
import { DefineCommand, Command } from '@artus-cli/artus-cli';
@DefineCommand()
export class MyCommand extends Command {
async run() {
console.info('trigger me');
}
}
如果 DefineCommand
不传 command ,那么该指令会自动设置为主指令,比如上面的例子的 bin 名称是叫 my-bin,那么直接在命令行执行 my-bin 就会打印 trigger me
。
Option
可以通过 Option
装饰器定义参数:
// index.ts
import { DefineCommand, Option, Command } from '@artus-cli/artus-cli';
@DefineCommand()
export class MyCommand extends Command {
@Option({
alias: 'p',
default: 3000,
description: 'port'
})
port: number;
async run() {
console.info('Run with port', this.option.port);
}
}
此时再执行 my-bin
将会打印 Run with port 3000
,因为 port
默认是 3000 ,也可以指定执行 my-bin --port=7001
或者 my-bin -p 7001
综合使用
上面的例子中的 DefineCommand
也可以传入 command 指定执行参数以及描述:
// index.ts
import { DefineCommand, Option, Command } from '@artus-cli/artus-cli';
@DefineCommand({
command: 'my-bin [baseDir]',
description: 'My First Bin'
})
export class MyCommand extends Command {
@Option({
alias: 'p',
default: 3000,
description: 'port'
})
port: number;
@Option()
baseDir: string;
async run() {
console.info('Run with port %s in %s', this.port, this.baseDir);
}
}
当执行 my-bin ./src --port=7001
即可打印 Run with port 7001 in ./src
定义子指令
常规用法
定义子指令也是通过 DefineCommand
这个装饰器,比如上面的例子改成一个 dev 的子指令,只需要改一下 command 即可:
// dev.ts
import { DefineCommand, Option, Command } from '@artus-cli/artus-cli';
@DefineCommand({
command: 'dev [baseDir]',
description: 'Run Dev Server',
alias: 'd',
})
export class MyDevCommand extends Command {
@Option({
alias: 'p',
default: 3000,
description: 'port'
})
port: number;
@Option()
baseDir: string;
async run() {
console.info('Run with port %s in %s', this.port, this.baseDir);
}
}
然后执行 my-bin dev ./src --port=7001
或者 my-bin d ./src --port=7001
即可 ,如果需要定义更多子指令也可以使用同样的配置方式,比如
// test.ts
import { DefineCommand, Command } from '@artus-cli/artus-cli';
@DefineCommand({
command: 'test',
description: 'Run Unittest',
alias: 't',
})
export class MyTestCommand extends Command {
async run() {
console.info('Run Unittest');
}
}
当定义 command 的时候,上面例子中的 bin 名称( 即 my-bin )也可以省略,比如上面的例子可以精简成以下写法:
// test.ts
import { DefineCommand, DefineOption, Command } from '@artus-cli/artus-cli';
@DefineCommand({
command: 'test',
description: 'Run Unittest',
alias: 't',
})
export class MyTestCommand extends Command {
async run() {
console.info('Run Unittest');
}
}
定义好之后就可以执行 my-bin test
看到效果。
指定父指令
常规用法中的父子关系,是通过解析 command 字符串支持的,在定义 Command 的时候也可以通过配置 parent 主动指定父指令。比如
@DefineCommand({
command: 'my-bin module',
description: 'Module Commands',
})
export class ModuleMainCommand extends Command {
async run() {
console.info('module is run');
}
}
@DefineCommand({
command: 'dev',
description: 'Module Dev Commands',
parent: ModuleMainCommand,
})
export class ModuleDevCommand extends Command {
async run() {
console.info('module dev');
}
}
@DefineCommand({
command: 'debug',
description: 'Module Debug Commands',
parent: ModuleMainCommand,
})
export class ModuleDebugCommand extends Command {
async run() {
console.info('module debug');
}
}
然后就可以有了以下三个指令:
my-bin module
my-bin module dev
my-bin module debug
就不用挨个写 module dev
和 module debug
使用场景:比如已经有一个 DevCommand ,需要在 module 这个父指令下也有一个 dev 指令,就可以新增一个 ModuleDevCommand 继承 DevCommand ,只需要配置 parent 为 ModuleCommand 即可 。
Arguments
配置在 DefineCommand
的 command 参数中,两种配置方式
<options>
必传参数,比如 command: 'test <file>'
[options]
可选参数,比如 command: 'dev [baseDir]'
也可以配置动态参数
<options...>
必传的动态参数,比如 command: 'test <files...>'
,最终拿到的 files 将是个数组。
[options...]
可选动态参数,跟上面效果一样。
Option
Option 是 Arguments 与 Flags(--port
这种参数) 的统一配置,通过 Option
装饰器定义
export interface OptionProps {
alias?: string | string[]; // 别名
default?: any; // 默认值
required?: boolean; // 是否必须
description?: string; // 描述
}
当配置以下格式时
@DefineCommand()
export class DevCommand extends Command {
// 可以传入详细配置
@Option({
alias: 'p',
description: 'port'
})
port: number;
// 传入字符代表 description
@Option('daemon')
daemon: boolean;
@Option('node flags')
nodeFlags: string;
}
执行指令可传入 --port=7001 --node-flags=--inspect --daemon
转换成在 run 函数中获取的 options 为
{
"port": 7001,
"nodeFlags": "--inspect",
"daemon": true
}
- 如果是 boolean 类型,当传参为
--no-daemon
等同于 --daemon=false
。
- Arguments 的详细配置也可以在其中配置,但只支持配置
type
与 default
两个属性。
中间件
分成几种中间件:
- 触发器中间件( 由 artus/pipeline 提供的 pipeline middlewares )
- 跟指令绑定的中间件
- 跟指令类绑定的中间件( command middlewares )
- 跟 run 函数绑定的中间件( method middlewares )
执行流水大概如下
# 输入
input -> pipeline middlewares -> command middlewares -> method middlewares -> run
# 输出
run -> method middlewares -> command middlewares -> pipeline middlewares -> output
触发器中间件
在生命周期中注入 @artus-cli/artus-cli
的 Program ,然后调用 use
函数即可。这类型的中间件一般是用于全局或者针对性拦截一些指令输入。
Program
是框架提供的一些开放 API ,可以用于获取指令列表,注册中间件,注册全局 Option 等,后面高级功能有介绍。
// lifecycle.ts
import { Inject, ApplicationLifecycle, LifecycleHook, LifecycleHookUnit, CommandContext, Program, CommandContext } from '@artus-cli/artus-cli';
@LifecycleHookUnit()
export default class UsageLifecycle implements ApplicationLifecycle {
@Inject()
private readonly program: Program;
@LifecycleHook()
async didLoad() {
this.program.use(async (ctx: CommandContext, next) => {
// do something
await next();
});
}
}
使用场景:比如 plugin-help 中就通过中间件拦截了 --help
和 -h
的输入,然后重定向到 help 指令。
指令中间件
通过 Middleware 装饰器可以定义指令中间件,可以使用在指令类或者 run 函数中,比如下面的例子
import { DefineCommand, Command, Middleware } from '@artus-cli/artus-cli';
@DefineCommand({
command: 'dev',
description: 'Run the development server',
})
@Middleware(async (_ctx, next) => {
console.info('prerun 1');
await next();
console.info('postrun 1');
})
export class DevCommand extends Command {
@Middleware(async (_ctx, next) => {
console.info('prerun 2');
await next();
console.info('postrun 2');
})
async run() {
// nothing
}
}
输出结果为
prerun 1
prerun 2
postrun 2
postrun 1
中间件也可以传数组,再比如下面这个例子
import { DefineCommand, Command, Middleware } from '@artus-cli/artus-cli';
@DefineCommand({
command: 'dev',
description: 'Run the development server',
})
@Middleware([
async (_ctx, next) => {
console.info('prerun 1');
await next();
console.info('postrun 1');
},
async (_ctx, next) => {
console.info('prerun 2');
await next();
console.info('postrun 2');
},
])
export class DevCommand extends Command {
@Middleware([
async (_ctx, next) => {
console.info('prerun 3');
await next();
console.info('postrun 3');
},
async (_ctx, next) => {
console.info('prerun 4');
await next();
console.info('postrun 4');
},
])
async run() {
// nothing
}
}
输出内容为
prerun 1
prerun 2
prerun 3
prerun 4
postrun 4
postrun 3
postrun 2
postrun 1
指令继承
直接使用类的继承方式即可。
配置继承
当指令继承时,子指令类会继承父指令类定义的配置信息,比如下面的例子
// dev command
@DefineCommand({
command: 'dev',
})
export class DevCommand extends Command {
@Option({
alias: 'p',
default: 3000,
})
port: number;
async run() {
console.info('Run In', this.options.port);
}
}
// debug command
@DefineCommand({
command: 'debug',
})
export class DebugCommand extends DevCommand {
@Option({
default: 8080,
description: 'inspect port'
})
inspectPort: number;
async run() {
super.run();
console.info('Debug In', this.options.inspectPort);
}
}
执行 my-bin debug
的话,会输出
Run In 3000
Debug In 8080
中间件继承
除了上面说的指令配置会自动继承之外,在类上挂载的中间件也能够被继承,还是继续看例子:
// dev command
@DefineCommand({
command: 'dev',
})
@Middleware(async (_ctx, next) => {
console.info('dev prerun 1');
await next();
console.info('dev postrun 1');
})
export class DevCommand extends Command {
@Middleware(async (_ctx, next) => {
console.info('dev prerun 2');
await next();
console.info('dev postrun 2');
})
async run() {
// nothing
}
}
// debug command
@DefineCommand({
command: 'debug',
})
@Middleware(async (_ctx, next) => {
console.info('debug prerun 1');
await next();
console.info('debug postrun 1');
})
export class DebugCommand extends DevCommand {
@Middleware(async (_ctx, next) => {
console.info('debug prerun 2');
await next();
console.info('debug postrun 2');
})
async run() {
super.run();
}
}
执行 my-bin debug
后会输出
# ----- 指令执行开始 ----
dev prerun 1 # --> DevCommand>class_middleware
debug prerun 1 # --> DebugCommand>class_middleware
# ----- run() 执行开始 ----
debug prerun 2 # --> DebugCommand>run_middleware
# ----- super.run() 执行开始 ----
dev prerun 2 # --> DevCommand>run_middleware
dev postrun 2 # --> DevCommand>run_middleware
# ----- super.run() 执行结束 ----
debug postrun 2 # --> DebugCommand>run_middleware
# ----- run() 执行结束 ----
debug postrun 1 # --> DebugCommand>run_middleware
dev postrun 1 # --> DevCommand>run_middleware
# ----- 指令执行结束 ----
这里分两种情况:
- 一种是绑定在类上的中间件,会直接合并:
- 比如 Dev 定义的类中间件是 A ,Debug 定义的类中间件是 B ,那么在 Debug 中的类中间件列表会组合成
[ A, B ]
。
- 另一种是绑定在
run
函数的中间件,当在 DebugCommand 的 run
函数中调用 super.run
的时候,就会执行 Dev 的 run
函数中间件。
- 所以如果不想触发 Dev 的
run
函数中间件,不调用 super.run
即可 ...
高级功能
插件机制
插件的机制跟定义跟 artus 的插件一样的,当插件中通过 DefineCommand
定义了指令,也会自动被加载,所以插件可以做的很强大且方便,可以用来拓展指令,也可以用来全局拦截,甚至能够用来覆盖已有指令。
插件的实现,可以参考 examples 中的插件例子
配置及开启插件的方式
// config/plugin.ts
export default {
help: {
enable: true,
package: 'plugin-help',
},
};
框架继承
跟 artus 中的上层框架的继承一样,在 config/framework.ts
中定义需要继承的 CLI 框架即可。可以参考 examples 中的上层封装例子:
每个 CLI 都可以作为一个上层框架被更上层的 CLI 所继承,只需要配置
// config/framework.ts
export default {
package: 'your-cli-name'
}
多环境
该能力也是 artus 提供的,可以根据不同环境,让同一个指令产生不同的功能,只需要在 plugin.{env}.ts
配置不同插件即可,比如如下配置
// config/plugin.ts
export default {
codegen: {
enable: true,
package: 'plugin-codegen',
},
};
// config/plugin.prod.ts
export default {
codegen: false,
codegenExtra: {
enable: true,
package: 'plugin-codegen-extra',
},
};
当默认环境执行 CLI ,此时 codegen 插件起作用,当时当带上环境变量 ARTUS_CLI_ENV=prod
执行 CLI 时,codegen 会被关闭,codegenExtra 将会起作用。
该功能适合的场景:比如同个 dev 指令,在不同租户环境下执行不同的逻辑,或者同个 build 指令,本地跟在构建机器上跑不同逻辑。非常适合做这种同指令不同场景做差异化的功能。
除了通过环境变量传环境参数,在入口文件的 start 方法中也可以传,CLI 可以自己决定如何控制该参数( 比如读取文件配置等 )
#!/usr/bin/env node
import { start } from '@artus-cli/artus-cli';
start({
baseDir: __dirname,
artusEnv: 'prod', // 可以在这里传环境
});
指令注入
DefineCommand
内部对 Injectable
做了包装,所以定义的指令也可以直接注入到其他指令中执行,比如
// test command
@DefineCommand({
command: 'test',
})
export class TestCommand extends Command {
async run() {
console.info('test');
}
}
// coverage command
@DefineCommand({
command: 'cov',
})
export class CovCommand extends Command {
@Inject()
testCommand: TestCommand;
async run() {
console.info('coverage');
return this.testCommand.run();
}
}
指令重定向
存在一种场景需要对指令做重定向( 即更改执行指令 ),框架提供了 Utils
类( 下文有详细介绍 ),其中具备一些实用的工具函数。
比如上面的注入指令的例子,可以直接用重定向的方式
import { DefineCommand, Command, Helper } from '@artus-cli/artus-cli';
// test command
@DefineCommand({
command: 'test',
})
export class TestCommand extends Command {
async run() {
console.info('test');
}
}
// coverage command
@DefineCommand({
command: 'cov',
})
export class CovCommand extends Command {
@Inject()
helper: Helper;
async run() {
console.info('coverage');
// 参数格式跟 process.argv 一致,也可以写 flags
return this.helper.redirect([ 'test' ]);
}
}
指令冲突与覆盖
如果两个指令的 command 除了 arugments 之外是一样的,为了避免开发时不小心写了同样的 command 导致难以快速排查出原因,框架目前针对同样的 command 会报错提醒指令冲突。
如果开发者确认就是需要覆盖指令,可以在 DefineCommand
的参数中传入 overrideCommand
参数来强制覆盖。
import { DefineCommand, Command } from '@artus-cli/artus-cli';
// test command
@DefineCommand({
command: 'test',
})
export class TestCommand extends Command {
async run() {
console.info('test');
}
}
// new test command
@DefineCommand({
command: 'test',
}, { overrideCommand: true }) // 标识强制覆盖
export class NewTestCommand extends Command {
async run() {
console.info('new test');
}
}
Program
Program 是框架提供的 Singleton 原型,内置了一些便捷 API ,可以在生命周期中注入并使用,相关能力如下:
注册 Option
可以通过 Program 的 option
方法指定全局 Option 或者针对部分指令添加 Option 。使用方式如下
@LifecycleHookUnit()
export default class UsageLifecycle implements ApplicationLifecycle {
@Inject()
private readonly program: Program;
@LifecycleHook()
async configDidLoad() {
const { rootCommand } = this.program;
// 注册全局生效的 Option
this.program.option({
help: {
type: 'boolean',
alias: 'h',
description: 'Show Help',
},
});
// 注册只对根指令生效的 option
this.program.option({
version: {
type: 'boolean',
alias: 'v',
description: 'Show Version',
},
}, [ rootCommand ]);
}
}
注册中间件
除了前面通过装饰器注册中间件,也可以在 lifecycle 中通过 program 注册中间件( 三种中间件均支持 ),比如内置的 plugin-version
的实现:
// index.ts
@LifecycleHookUnit()
export default class VersionLifecycle implements ApplicationLifecycle {
@Inject()
private readonly program: Program;
@LifecycleHook()
async configDidLoad() {
const { rootCommand } = this.program;
this.program.option({
version: {
type: 'boolean',
alias: 'v',
description: 'Show Version',
},
}, [ rootCommand ]);
// intercept root command and show version
this.program.useInCommand(rootCommand, async (ctx: CommandContext, next) => {
const { args } = ctx;
if (args.version) {
return console.info(this.program.version || '1.0.0');
}
await next();
});
}
}
注册三种中间件的方法分别是:
use
注册 pipeline 中间件
useInCommand
注册指令中间件( 通过 program 注册到 command 的中间件不会被继承 )
useInExecution
注册 run 函数中间件
Utils
Utils 是框架提供的在 Execution 阶段使用的工具类,可以用于中间件或者在指令中注入并使用。提供了两个方法:
redirect(argv: string[])
重定向指令,会新建 pipeline ,上面有过介绍。
forward(clz: typeof Command, args?: T)
转发指令,入参是指令类,也可以传入参数( 如果传了会覆盖已有解析出来的参数 )。
- 跟 redirect 的差异:在当前 pipeline 触发( 即不会触发 pipeline middleware ),而 redirect 会新建 pipeline。
其他待实现功能
- 支持应用侧的配置,类似 .eslintrc
- 支持 Hooks( 其实中间件似乎已经够用? )
- Async hooks 引入( 是否可以优化编程界面? )