好的代码总是令人读有所得。优秀的设计可以让代码在复杂应用场景下保持整洁。
背景
随着nodejs的流行,很多前端开发不光可以在浏览器中编写交互,还可以借助nodejs,在命令行中编写强大的CLI工具,方便其它开发者快速使用产品能力。
类似国外的Vercel,云开发也开发了一套自己的CLI工具。它支持命令行登录、托管前端网站、部署容器、部署云函数等等。文档在: https://docs.cloudbase.net/cli-v1/intro
在实现中,有几点值得关注
- 如何设计设计多个命令的代码,让代码结构更清晰?
- 如何解析命令行参数?
- 如何实现命令行酷炫动态交互?比如列表选择、进度条加载。
- 如何利用IoC(控制反转)和DI(依赖注入),避免参数的层层传递,实现高内聚与低耦合?
整体设计
对外的代码在GitHub上,已经很久没更新了,但不影响整体设计:Github Cloudbase CLI Source
主要关注 src 目录的结构,如下:
1 | . |
按照目录,可以分为几个部分:
- commands:存放具体的命令,包括命令的参数定义、参数解析。
- decorators:存放装饰器,实现IoC的关键。
- 其它文件:基本上都是单元函数。主要供commands调用。
复杂命令类的注册
在入口文件 index.ts 刚开始时,就调用了命令注册的函数–registerCommands()
通过这个函数的实现,可以看到它会依次初始化 registrableCommands
中的命令类:
命令类是怎么被添加到registrableCommands的?
可以看到上图中有个 ICommand
装饰器。这个就是注入命令类的关键。ICommand
装饰器是一个class decorator。任何使用此装饰器的命令类,都会被添加到 registrableCommands
中。
在 src/commands/env 中,可以看到类命令类,均被 @ICommand
装饰了:
这种写法类似 NestJS 中的 @Injectable
装饰器,语义更明显,也是IoC的一种体现。
- 传统写法:依次引入命令类,并且在决定加载具体加载哪些命令
- IoC:是否注册到全局,完全由类自己决定(反转了注册的决定权)
借助IoC,如果之后不再支持这个命令类,那么只需要去掉类定义中的@ICommand
。而不需要改动外部的引用文件。逻辑高内聚,语义更清晰,改动文件更少。
基础命令类的设计
在commands文件夹下,有很多命令。这些命令都有一些通用的逻辑:
- 执行前检查用户身份信息
- 每个命令解析执行前后,都交互式打印相关信息
这些通用的逻辑,使用面向对象的继承来实现。
除了通用的逻辑,每个命令都有自己的解析执行逻辑,比如上传文件、调用云函数、参数定义。这些借助了TypeScript的抽象类和抽象方法来实现。核心思想是「设计模式–模版模式」。
红色的就是通用逻辑,绿色的就是需要继承类自行实现的逻辑。
命令参数的解析和交互式命令行的实现
上图可以看出,每个具体命令类的参数是需要自己定义的。
然后当命令类被注册时,registerCommands()
会调用每个命令类的init()
方法。而这个方法就是从命令基类上继承来的。
init()
中实现借助了 commander.js 来实现参数的解析:
commander.js以及交互式命令行第三方库的使用可以参考 交互式命令行编程和原理。这里不再粘贴了。
DI(依赖注入)的实现
这块虽然也是IoC的思想,通过DI的写法,来快速访问上下文参数。同时,DI是我们自己实现的,所以比较难理解。尤其对于没有接触过「元编程」的同学。我会配合代码以及具体的数据结构,让元编程更加可感。
清爽的使用方式
先看下每个命令类中的exectue()
函数,是如何编写的:
可以看到,直接通过装饰器,就可以读取到envId、params以及Logger对象。这种方式就是在NestJS中经常用到的,可以通过装饰器快速拿到具体的body、query、method等信息。
在eggjs/koajs/expressjs中,这类上下文中的信息,是从context
上读取。这种方法有什么不好的点?
- 当中间件和service逻辑越来越复杂后,直接操作context不可控制。你永远不知道哪位同事在哪个中间件中操作了context的某个属性,从而导致了你的逻辑中读取不到context中的这个属性
- 当不断调用更低一层的单元函数时,会不断往下丢context。就像前端代码中,不停往子组件中丢state。代码写起来比较搓。
参数装饰器实现
这里有必要提醒一下,参数装饰器是在方法装饰器之前执行的。具体可以看TS的装饰器文档。
点击去参数装饰器的实现,可以看到他们都依赖于一个 createParamDecorator()
方法。
再看这个createParamDecorator()
实现。看到metadata的时候,就知道是元编程没跑了。
那么这里面具体做了什么?
- 拿到当前类的当前方法上的元数据,元数据的标识是
PARAM_METADATA
。target是类实例,key是类的方法名。 - 重新定义这个元数据,其实就是追加了
paramtype
类型的参数的信息。index是这个参数在函数参数列表中的位置。
比如对于@EnvId()
/@ArgsParams()
/@Log()
,执行后,那么当前类的当前方法的 PARAM_METADATA
元数据,就会变成:
1 | { |
可以看到,参数装饰器的作用就是为了在编译时,将参数的关键信息记录下来。具体替换参数的值,修改函数运行时的行为,还需要方法装饰器。
方法装饰器实现
在命令基类的init()
方法中的instance.action(......)
内部,调用了命令子类自主实现的exectue()
函数。从截图中看到,这里将ctx上下文传入了子类的方法。
那么@InjectParams
方法装饰器的作用,可以大概猜出来了:从ctx中读取值,根据参数装饰器注入的信息,改写函数运行时的参数。
- 先读取当前方法的元信息(参数装饰器注入的)
- 如果存在,那么就改写函数运行时的行为
- 根据参数类型,从ctx中读取出来值,然后放到新的参数列表中
- 函数运行时,传入的是新的参数列表
为什么手造IoC和DI(依赖注入)?
对于新项目,完全没必要手造IoC和DI。Inversify.JS 是JavaScript最大的DI库,就能满足需求。
在微搭模版服务端的SDK中,尝试用它实现了 @cloudbase/lcap-business-sdk 库。相较于自己实现,成本低很多,而且有完备的社区规范。
对于老项目,可能需要自己手造下这一套东西。换用 Inversify.JS 的成本对代码的改造成本还是比较大的。
最后补充下,IoC是一种设计思想,DI是在IoC思想下的一种比较通用的写法。