如何构建一个快、轻量且高扩展的CLI?

echosoar 原创发表于 2020/03/19 11:32:06
在前一段时间,经过了双十一大规模流量的验证,我们开源了服务于淘宝、飞猪等导购业务的Serverless框架 -- Midway FaaS,其研发流程中很重要的一环就是本地CLI工具。
我们在CLI工具上提供了创建项目、本地调用函数、单步调试、打包发布等等功能,最初的一版是基于Serverless框架(MIT License)进行扩展的,但其内部默认集成了很多譬如向AWS发送数据埋点等等不需要的特定功能,且执行性能与我们的预期有不小的差距,于是,我们就取其精华,构建了一个基于命令生命周期+Hooks机制的轻量cli内核,通过插件机制对各种平台和能力进行高效的扩充,同时能够兼容Serverless框架的原有插件。

方便的插件机制

通常我们在创作一个CLI工具的时候,需要写很多的switch case语句去将用户输入的命令和参数映射到不同的处理逻辑上,新增一个命令的时候也需要对cli进行改造,我们希望让插件的开发者新增一个命令的时候不再需要修改CLI,所有的这些处理过程都有内核自动处理,这将极大的提高开发效率。
下面是一个简单的deploy插件的代码:
class DeployPlugin {
  commands = {
    deploy: {
      usage: 'Deploy to online',
      lifecycleEvents: ['copyFiles', 'installDep', 'package', 'deploy'],
      options: {
        platform: {
          usage: 'specify deploy platform',
          shortcut: 'p'
        }
      }
    }
  };
  
  hooks = {
  	'after:deploy:deploy': () => {
    	console.log('deploy success!');
    }
  }
}
插件中只需要通过commands属性去定义需要什么命令,在插件加载的时候,内核会将所有插件的commands属性进行分析,其每个属性都是作为一级命令,在用户输入 f deploy 的时候就会找到上面的这个插件,从里面的 lifecycleEvents 属性找到生命周期列表,然后去执行生命周期的处理逻辑。
另外options中定义了所有这个命令的参数,比如上述代码中的 platform ,用户可以在命令行中输入 f deploy --platform=fc 或者使用shortcut缩写 f deploy -p=fc ,在插件中就可以用过 this.options.p 获取到用户的输入,这就使得插件的开发者不再需要去关心怎么处理用户输入,怎么让用户输入的命令执行到自己写的插件。

生命周期

CLI工具的一个很重要的功能就是将用户在命令行中输入的命令找到对应的处理逻辑,与此同时为了支持某些平台性的功能需要对某一个命令进行一些拓展或者修改,比如说deploy(发布)命令,为兼容Aliyun FC需要在发布前构建出对应的template.yml文件,为兼容Tencent SCF也需要在发布前进行一些独特的处理,因此就要求CLI能很方便的提供生命周期钩子,在不同的插件中可以对某一个命令的某一个生命周期进行扩展。
之前的Deploy插件代码中,生命周期有 copyFile 、 install dep 、 package 、 deploy 等几个,在用户执行 f deploy 的时候,CLI内核就会自动找到 deploy 这个命令所在的插件,从而找到其生命周期列表,按照生命周期生成hooks列表,然后在已加载的所有插件中寻找有这些hooks实现的方法,依次去执行。
image.png
由于hooks是在所有插件中去寻找的,也就是说可以在很多个不同的插件里面去完成一个命令的逻辑,每个平台都可以提供一个插件,去提供各种命令中本平台需要独特订制的能力,这也是Midway FaaS实现支持Aliyun、Tencent等多平台能力的基石。

数据共享

由于整个CLI都是插件,那么在不同的插件中就会有数据共享的需求,比如:默认create插件(用来创建项目,初始化)中让用户输入一些项目名称、Author等等参数,这些参数在其他存在create hooks的插件中会使用,那么如何在插件之间进行数据共享呢?
由于在所有插件挂载的时候,CLI内核会将全局共享的一个Core Instance作为参数传递给所有插件,在Core Instance上面提供了getStore和setStore两个方法,所有的插件在初始化的时候会生成一个唯一且不变的插件名称,在调用setStore进行数据存储的时候会自动与插件进行绑定,这就避免了多个插件设置同样的key导致数据混乱。在调用getStore的时候,默认为读取当前插件的数据,通过可选择的参数可以定向找到其他插件进行数据读取,这就实现了不同插件之间的数据共享。

自动生成帮助信息

在插件加载的时候,内核会将所有插件的commands属性进行分析,其每个属性都是作为一级命令,其中的usage就是这个命令的帮助信息,另外options中定义了所有这个命令的参数,在每个参数中的usage属性作为这个参数的帮助信息,在用户输入的命令中如果存在help/h参数的时候,就会自动将这些usage数据进行分析和整理,然后显示给用户。
在用户没有输入命令的时候,会展示所有的插件中所有命令及其参数的帮助信息,当用户输入了命令,比如 deploy 的时候,就会展示关于deploy这个命令的帮助信息。

拓展

与serverless框架一样,我们的内核也支持用户在代码中通过yml文件配置自定义的插件:
plugins:
 - npm::faas-cli-plugin-dashboard
 - npm:fc:faas-cli-plugin-debug
 - local::./plugin-test
从上面的代码中可以看到插件的描述通过 : 进行分割,其格式是 type:provider?:path,我们支持两种类型的插件:npm包和local本地文件。npm的模式可以让用户添加他人公开的插件,local模式方便插件开发者在本地开发。
同时,通过可选的provider属性能够对不同的平台进行按需加载,比如定义了provider为fc,那么就只会在aliyun fc环境下加载此插件进行执行。

性能

单纯的内核运行与执行help命令(会加载所有的插件)性能都比较高,benchmark数据如下:
Benchmark:
  pure core            x 307,783 ops/sec ±4.36% (73 runs sampled)
  core help            x 146,613 ops/sec ±2.34% (79 runs sampled)

我们的这个高性能、轻量且方便扩展的插件内核已经开源,欢迎Contribute和Star。

https://github.com/midwayjs/midway-faas/