TCB CLI源码分析--利用IoC和DI打造复杂命令行工具

好的代码总是令人读有所得。优秀的设计可以让代码在复杂应用场景下保持整洁。

背景

随着nodejs的流行,很多前端开发不光可以在浏览器中编写交互,还可以借助nodejs,在命令行中编写强大的CLI工具,方便其它开发者快速使用产品能力。

类似国外的Vercel,云开发也开发了一套自己的CLI工具。它支持命令行登录、托管前端网站、部署容器、部署云函数等等。文档在: https://docs.cloudbase.net/cli-v1/intro

在实现中,有几点值得关注

  • 如何设计设计多个命令的代码,让代码结构更清晰?
  • 如何解析命令行参数?
  • 如何实现命令行酷炫动态交互?比如列表选择、进度条加载。
  • 如何利用IoC(控制反转)和DI(依赖注入),避免参数的层层传递,实现高内聚与低耦合?

整体设计

对外的代码在GitHub上,已经很久没更新了,但不影响整体设计:Github Cloudbase CLI Source

主要关注 src 目录的结构,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.
├── auth
├── commands
├── completion
├── constant.ts
├── decorators
├── env
├── error.ts
├── function
├── gateway
├── hosting.ts
├── index.ts
├── logger.ts
├── ssl
├── storage.ts
├── third
├── types.ts
└── utils

按照目录,可以分为几个部分:

  • commands:存放具体的命令,包括命令的参数定义、参数解析。
  • decorators:存放装饰器,实现IoC的关键。
  • 其它文件:基本上都是单元函数。主要供commands调用。

复杂命令类的注册

在入口文件 index.ts 刚开始时,就调用了命令注册的函数–registerCommands()

e6c9d24egy1h18bcq7fedj20w40dajsy.jpg

通过这个函数的实现,可以看到它会依次初始化 registrableCommands 中的命令类:

e6c9d24egy1h18beyd3ygj20vy0n441n.jpg

命令类是怎么被添加到registrableCommands的?

可以看到上图中有个 ICommand 装饰器。这个就是注入命令类的关键。ICommand 装饰器是一个class decorator。任何使用此装饰器的命令类,都会被添加到 registrableCommands 中。

在 src/commands/env 中,可以看到类命令类,均被 @ICommand 装饰了:

e6c9d24egy1h18bk83aduj210q0qcdjy.jpg

这种写法类似 NestJS 中的 @Injectable 装饰器,语义更明显,也是IoC的一种体现。

  • 传统写法:依次引入命令类,并且在决定加载具体加载哪些命令
  • IoC:是否注册到全局,完全由类自己决定(反转了注册的决定权)

借助IoC,如果之后不再支持这个命令类,那么只需要去掉类定义中的@ICommand。而不需要改动外部的引用文件。逻辑高内聚,语义更清晰,改动文件更少。

基础命令类的设计

在commands文件夹下,有很多命令。这些命令都有一些通用的逻辑:

  • 执行前检查用户身份信息
  • 每个命令解析执行前后,都交互式打印相关信息

这些通用的逻辑,使用面向对象的继承来实现。

除了通用的逻辑,每个命令都有自己的解析执行逻辑,比如上传文件、调用云函数、参数定义。这些借助了TypeScript的抽象类和抽象方法来实现。核心思想是「设计模式–模版模式」。

e6c9d24egy1h18btzzpqfj210q0u078a.jpg

红色的就是通用逻辑,绿色的就是需要继承类自行实现的逻辑。

命令参数的解析和交互式命令行的实现

上图可以看出,每个具体命令类的参数是需要自己定义的。

e6c9d24egy1h18byof79ej212a0nmadv.jpg

然后当命令类被注册时,registerCommands() 会调用每个命令类的init()方法。而这个方法就是从命令基类上继承来的。

init()中实现借助了 commander.js 来实现参数的解析:

e6c9d24egy1h18c20as5cj21cm0ia0ve.jpg

commander.js以及交互式命令行第三方库的使用可以参考 交互式命令行编程和原理。这里不再粘贴了。

DI(依赖注入)的实现

这块虽然也是IoC的思想,通过DI的写法,来快速访问上下文参数。同时,DI是我们自己实现的,所以比较难理解。尤其对于没有接触过「元编程」的同学。我会配合代码以及具体的数据结构,让元编程更加可感。

清爽的使用方式

先看下每个命令类中的exectue()函数,是如何编写的:

e6c9d24egy1h18cxmzalxj21860rsq6b.jpg

可以看到,直接通过装饰器,就可以读取到envId、params以及Logger对象。这种方式就是在NestJS中经常用到的,可以通过装饰器快速拿到具体的body、query、method等信息。

在eggjs/koajs/expressjs中,这类上下文中的信息,是从context上读取。这种方法有什么不好的点?

  • 当中间件和service逻辑越来越复杂后,直接操作context不可控制。你永远不知道哪位同事在哪个中间件中操作了context的某个属性,从而导致了你的逻辑中读取不到context中的这个属性
  • 当不断调用更低一层的单元函数时,会不断往下丢context。就像前端代码中,不停往子组件中丢state。代码写起来比较搓。

参数装饰器实现

这里有必要提醒一下,参数装饰器是在方法装饰器之前执行的。具体可以看TS的装饰器文档。

点击去参数装饰器的实现,可以看到他们都依赖于一个 createParamDecorator() 方法。

e6c9d24egy1h18d77yricj214u0fyju4.jpg

再看这个createParamDecorator()实现。看到metadata的时候,就知道是元编程没跑了。

e6c9d24egy1h18d8aymf7j20yo0u0q6f.jpg

那么这里面具体做了什么?

  • 拿到当前类的当前方法上的元数据,元数据的标识是PARAM_METADATA。target是类实例,key是类的方法名。
  • 重新定义这个元数据,其实就是追加了paramtype类型的参数的信息。index是这个参数在函数参数列表中的位置。

比如对于@EnvId()/@ArgsParams()/@Log(),执行后,那么当前类的当前方法的 PARAM_METADATA 元数据,就会变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"__EnvId__": {
index: 0,
getter: () => {}
},
"__ArgsParams__": {
index: 1,
getter: () => {}
},
"__Log__": {
index: 2,
getter: () => {
// ...
}
}
}

可以看到,参数装饰器的作用就是为了在编译时,将参数的关键信息记录下来。具体替换参数的值,修改函数运行时的行为,还需要方法装饰器

方法装饰器实现

e6c9d24egy1h18dkfpb6jj20vs0u0423.jpg

在命令基类的init()方法中的instance.action(......) 内部,调用了命令子类自主实现的exectue() 函数。从截图中看到,这里将ctx上下文传入了子类的方法。

那么@InjectParams 方法装饰器的作用,可以大概猜出来了:从ctx中读取值,根据参数装饰器注入的信息,改写函数运行时的参数。

e6c9d24egy1h18doj8ixcj20x50u0td9.jpg

  1. 先读取当前方法的元信息(参数装饰器注入的)
  2. 如果存在,那么就改写函数运行时的行为
  3. 根据参数类型,从ctx中读取出来值,然后放到新的参数列表中
  4. 函数运行时,传入的是新的参数列表

为什么手造IoC和DI(依赖注入)?

对于新项目,完全没必要手造IoC和DI。Inversify.JS 是JavaScript最大的DI库,就能满足需求。
在微搭模版服务端的SDK中,尝试用它实现了 @cloudbase/lcap-business-sdk 库。相较于自己实现,成本低很多,而且有完备的社区规范。

对于老项目,可能需要自己手造下这一套东西。换用 Inversify.JS 的成本对代码的改造成本还是比较大的。

最后补充下,IoC是一种设计思想,DI是在IoC思想下的一种比较通用的写法。