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

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

Tags
命令行编程
IoC
DI
TypeScript
元编程
装饰器模式
模版模式
CreatedTime
Aug 15, 2022 06:21 AM
Slug
2022-04-12-tcb-cli
UpdatedTime
Last updated August 15, 2022
好的代码总是令人读有所得。优秀的设计可以让代码在复杂应用场景下保持整洁。

背景

随着nodejs的流行,很多前端开发不光可以在浏览器中编写交互,还可以借助nodejs,在命令行中编写强大的CLI工具,方便其它开发者快速使用产品能力。
类似国外的Vercel,云开发也开发了一套自己的CLI工具。它支持命令行登录、托管前端网站、部署容器、部署云函数等等。文档在: https://docs.cloudbase.net/cli-v1/intro
在实现中,有几点值得关注
  • 如何设计设计多个命令的代码,让代码结构更清晰?
  • 如何解析命令行参数?
  • 如何实现命令行酷炫动态交互?比如列表选择、进度条加载。
  • 如何利用IoC(控制反转)和DI(依赖注入),避免参数的层层传递,实现高内聚与低耦合?

整体设计

对外的代码在GitHub上,已经很久没更新了,但不影响整体设计:Github Cloudbase CLI Source
主要关注 src 目录的结构,如下:
. ├── 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()
notion image
通过这个函数的实现,可以看到它会依次初始化 registrableCommands 中的命令类:
notion image
命令类是怎么被添加到registrableCommands的?
可以看到上图中有个 ICommand 装饰器。这个就是注入命令类的关键。ICommand 装饰器是一个class decorator。任何使用此装饰器的命令类,都会被添加到 registrableCommands 中。
在 src/commands/env 中,可以看到类命令类,均被 @ICommand 装饰了:
notion image
这种写法类似 NestJS 中的 @Injectable 装饰器,语义更明显,也是IoC的一种体现。
  • 传统写法:依次引入命令类,并且在决定加载具体加载哪些命令
  • IoC:是否注册到全局,完全由类自己决定(反转了注册的决定权)
借助IoC,如果之后不再支持这个命令类,那么只需要去掉类定义中的@ICommand。而不需要改动外部的引用文件。逻辑高内聚,语义更清晰,改动文件更少。

基础命令类的设计

在commands文件夹下,有很多命令。这些命令都有一些通用的逻辑:
  • 执行前检查用户身份信息
  • 每个命令解析执行前后,都交互式打印相关信息
这些通用的逻辑,使用面向对象的继承来实现。
除了通用的逻辑,每个命令都有自己的解析执行逻辑,比如上传文件、调用云函数、参数定义。这些借助了TypeScript的抽象类和抽象方法来实现。核心思想是「设计模式--模版模式」。
notion image
红色的就是通用逻辑,绿色的就是需要继承类自行实现的逻辑。

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

上图可以看出,每个具体命令类的参数是需要自己定义的。
notion image
然后当命令类被注册时,registerCommands() 会调用每个命令类的init()方法。而这个方法就是从命令基类上继承来的。
init()中实现借助了 commander.js 来实现参数的解析:
notion image
commander.js以及交互式命令行第三方库的使用可以参考 交互式命令行编程和原理。这里不再粘贴了。

DI(依赖注入)的实现

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

清爽的使用方式

先看下每个命令类中的exectue()函数,是如何编写的:
notion image
可以看到,直接通过装饰器,就可以读取到envId、params以及Logger对象。这种方式就是在NestJS中经常用到的,可以通过装饰器快速拿到具体的body、query、method等信息。
在eggjs/koajs/expressjs中,这类上下文中的信息,是从context上读取。这种方法有什么不好的点?
  • 当中间件和service逻辑越来越复杂后,直接操作context不可控制。你永远不知道哪位同事在哪个中间件中操作了context的某个属性,从而导致了你的逻辑中读取不到context中的这个属性
  • 当不断调用更低一层的单元函数时,会不断往下丢context。就像前端代码中,不停往子组件中丢state。代码写起来比较搓。

参数装饰器实现

这里有必要提醒一下,参数装饰器是在方法装饰器之前执行的。具体可以看TS的装饰器文档。
点击去参数装饰器的实现,可以看到他们都依赖于一个 createParamDecorator() 方法。
notion image
再看这个createParamDecorator()实现。看到metadata的时候,就知道是元编程没跑了。
notion image
那么这里面具体做了什么?
  • 拿到当前类的当前方法上的元数据,元数据的标识是PARAM_METADATA。target是类实例,key是类的方法名。
  • 重新定义这个元数据,其实就是追加了paramtype类型的参数的信息。index是这个参数在函数参数列表中的位置。
比如对于@EnvId()/@ArgsParams()/@Log(),执行后,那么当前类的当前方法的 PARAM_METADATA 元数据,就会变成:
{ "__EnvId__": { index: 0, getter: () => {} }, "__ArgsParams__": { index: 1, getter: () => {} }, "__Log__": { index: 2, getter: () => { // ... } } }
可以看到,参数装饰器的作用就是为了在编译时,将参数的关键信息记录下来。具体替换参数的值,修改函数运行时的行为,还需要方法装饰器

方法装饰器实现

notion image
在命令基类的init()方法中的instance.action(......) 内部,调用了命令子类自主实现的exectue() 函数。从截图中看到,这里将ctx上下文传入了子类的方法。
那么@InjectParams 方法装饰器的作用,可以大概猜出来了:从ctx中读取值,根据参数装饰器注入的信息,改写函数运行时的参数。
notion image
  1. 先读取当前方法的元信息(参数装饰器注入的)
  1. 如果存在,那么就改写函数运行时的行为
  1. 根据参数类型,从ctx中读取出来值,然后放到新的参数列表中
  1. 函数运行时,传入的是新的参数列表

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

对于新项目,完全没必要手造IoC和DI。Inversify.JS 是JavaScript最大的DI库,就能满足需求。 在微搭模版服务端的SDK中,尝试用它实现了 @cloudbase/lcap-business-sdk 库。相较于自己实现,成本低很多,而且有完备的社区规范。
对于老项目,可能需要自己手造下这一套东西。换用 Inversify.JS 的成本对代码的改造成本还是比较大的。
最后补充下,IoC是一种设计思想,DI是在IoC思想下的一种比较通用的写法。