NestJs的CLI工具 nest-code-generate

2,184 阅读6分钟

前言

在使用 NestJs + Mysql 项目开发中, 也许你会开启由实体类同步生成数据表的方式, 这种方式是可以的, 而且也是typeorm所擅长的, 但是也面临着数据会被误删的风险, 所以呢 我们几个小伙伴就想做一个工具来解决这个问题.

目标

我们不管要开发什么东西, 首先要明确的是, 我们想让这个东西来做什么, 有哪些功能, 这些功能大概的实现方式是什么? 能提前画好架构图或者流程图就更好了😁 这里我们总结了几点, 我们想让这个工具来帮我们做的事情:

通过数据库的数据表结构

  • 反向生成对应的实体类
  • 反向生成对应的控制器
  • 反向生成对应的服务层

大概思路

  1. 我想 像nest-cli 或者 vue-cli这一些优秀的cli一样, 通过在命令行输入一句简单的命令就能生成各种各样的文件(entity、controller、services), 因为这样是非常非常方便的.
  2. 我想获取到用户指定的数据表结构, 并且将它转换成自己需要的结构.
  3. 用户输入 v type src 工具就会根据 数据表type 生成实体类、控制器、服务层并将生成的文件夹挂载到src目录下.

开始

首先我们先来研究一下怎样将代码通过命令的方式来运行

  1. 我们先来执行以下命令来创建一个项目npm init -y && mkdir bin && touch ./bin/code-gen && mkdir src && touch ./src/index.ts
  2. 我们将package.json中加上一个属性bin, 他的值是一个对象, 对象的key"指令" 名字, 对象的value"指令执行的文件" 例如这个样子:

image.png

这里可以看到两个问题:

  1. 我指定了多个key(v、code-gen、nest-code-generate), 这里要明确 这些只是别名而已, 想取多少个名字就取多少个名字, 作者只是为了测试方便.
  2. code-gen似乎是一个没有后缀的文件, 既不是.js也不是.ts, 是的 这个文件确实是没有后缀的
  1. 我们在bin/code-gen里面写一点东西

如果电脑前的小伙伴用的是vscode的话, 编写code-gen一般是没有代码提示的, 我们这里通过下图来解决一下

image.png

#!/usr/bin/env node

console.log('code-gen');

我们来看一下上面这段代码:

  • 首行的#!/usr/bin/env node 用于指明这个脚本文件的解释程序, 我们这里指定的是node, 然后我们简单打印了一点东西.
  • 那么我们怎么去执行这个程序呢? 这个时候在命令行执行 v, 我们会不会看到命令行中打印出code-gen呢?
  • 很显然是不可以的, 因为我们还少了最后一步非常非常重要的操作, 就是执行npm link这个命令.
  • 这个命令的作用就是在本地创建一个快捷操作, 让你可以在不发布包的前提下 就能体验到该包的功能, 有关这一命令的信息, 小伙伴们可以看一看npm的官方文档, 或者网上查一下资料 还是非常简单的, 如果在执行这个命令时 产生了冲突, 我们可以加上一个 --force 后缀, 强制更新一下.
  • 这个时候我们在命令行执行v 就会看到命令行打印出code-gen这一消息.

image.png

怎样接收命令行中用户输入的参数呢?

  1. 这里我们要用的一个大部分cli都会用到的包, 它的名字叫做commander, 执行npm i commander 使用方法也非常的简单, 我们这里会简单的把用到的知识说一下, 因为我发现官网说的并不明确(反正俺是理解不了).
  2. 我们来看一下下面这段代码:
  • 第一段: program.version().usage() 其实非常简单, 就是当我们输入v -v的时候 会打印出版本号, usage()是执行v -h时候的提示.
  • 第二段: program.argument().argument().action() 这段代码的意思是 接受两个参数 一个是table-name 一个是dir 其中dir是可选的, 后面的action就是回调方法, 这个方法接受2个参数, 就是我们接受的两个argument.
#!/usr/bin/env node
const program = require('commander');

program
  .version(`nest-code-generate@${require("../package.json").version}`)
  .usage(`<table_name2,table_name2...> [dir]`);


program
  .argument('<table-name>', "数据表的名称")
  .argument('[dir]', '文件夹路径')
  .action((tableName, dir) => {
    console.log(tableName, dir);
   });

program.parse(process.argv);

获取数据表的结构

通过commander我们可以很轻松的通过命令行来和用户交互, 那么此时如果用户输入了这么一条指令v type src 我们应该怎么办呢? 我们要做的第一件事就是去读取用户的type表, 这里我们也要借用一个数据库连接工具, 它的名字叫做ali-rds-async, 可以通过执行npm i ali-rds-async去安装它.

ali-rds-async使用方法:

  • 我们先在src目录下创建一个client文件夹, 并创建一个index.ts
  • 连接数据库(是不是非常的简单)
    import AsyncAliRds from "ali-rds-async";
    
    export const db = new AsyncAliRds({
        host: 'localhost',
        port: 3306,
        user: 'root',
        password: 'password',
        database: 'nest-code-generate'
    });
    
  • 读取表结构: 这里我们可以通过 执行一条SQL来实现, 但是这里我们要改动一下文件 以方便我们的测试以及编码.
  1. 新增tsconfig.json;

  2. bin/code-gen: 这里的Parser是从lib(打包路径)里面引入的 image.png

  3. src/index.ts: 这里就是打包的入口文件, 暴露Parser image.png

  4. package.json: 这里要添加几个script image.png

  • 文件修改完成, 我们首先运行 npm run serve 或者 npm run build 将文件打包到lib文件夹下, 然后执行 v type 指令 看看会发生什么? 我们拿到了type表的结构. image.png

生成实体类

生成实体类的时候 我们还要涉及到一个命令行问答模式 这里我们先把这个功能实现, 用户可以通过这个功能来实现像vue-cli一样 通过选择式创建文件.

  • 借助工具inquirer来实现命令行问答模式, 该工具的使用方式也是比较简单, 后面用到 会简单说一下

  • 首先我们先来看一段代码

    import { prompt } from 'inquirer';
    import { findPath } from "./utils";
    
    export class Parser {
      tableName   : string; // 数据表名
      dir         : string; // 生成路径
      type       !: string; // 生成实体类 还是 控制层 还是服务层
      targetPath !: string; // 生成路径
    
      constructor(tableName: string, dir: string) {
        this.tableName = tableName;
        this.dir = dir;
        this.prompt();
      }
    
      // 发起询问
      async prompt() { 
        const { type } = await prompt([
          {
            name: 'type',
            type: 'list',
            message: 'What content is generated(要生成什么内容)?: ',
            choices: [
              { name: 'Entity (实体类)', value: 'entity' },
              { name: 'Tier (实体类 + 控制器和服务层方法)', value: 'tier' },
              { name: 'CURD (实体类 + 简单的增删改查)', value: 'curd' },
              { name: 'All (全部生成: 实体类 + 控制器和服务层方法 + 简单的增删改查)', value: 'all' }
            ]
          }
        ]);
    
        this.type = type;
    
        // 获取生成路径
        const targetPath = findPath(this.dir);
        this.targetPath = targetPath;
    
        this.parseOption();
      }
    
    }
    

    我们看一下代码中的prompt方法做了什么事情:

    1. 调用inquirerprompt方法, 在命令行中发起询问, 就像这个样子; image.png 我们来看一下传递给prompt的参数, 目前我们只用到了第一个参数, 也就是一个数组, 数组的每一项代表一个问题, 我们这里只传递了一个问题, 我们来看一下这个问题的key分别都代表什么意思.
    • name : 就是一会这个方法返回给你的字段名字;
    • type : 当前问题的类型, 目前用到的是 listinput, 一个是列表 一个是输入.
    • message : 这个就比较简单了, 就是你要提问的问题.
    • choices : 这个属性是一个数组, 只有typelist的时候才有这个属性, 数组中每一项中的name就是显示给用户看的内容, 而另一个value就是用户选中之后 返回给你的值.

    我们来看一下实际应用, 接收到的是什么? image.png

    1. type存起来, 因为这个就是用户要生成的内容;
    2. 获取生成路径, 该方法只有一个功能, 就是获取用户最终的生成路径, 默认为src;
    3. 调用parseOption方法;
  • parseOption方法 我们来看一下这个方法实现了什么功能:

    async parseOption() {
      const typeMap: { [k in Options]: () => any } = {
        'entity': () => this.generateEntity(),
        'tier': () => this.generateTier(),
        'curd': () => this.generateCURD(),
        'all': () => this.generateAll()
      };
    
      if (this.type && Reflect.has(typeMap, this.type)) {
        await typeMap[this.type]();
      } else {
        await typeMap.entity();
      }
      this.exit();
    }
    
    • 创建一个策略map 通过type来调用对应的方法, 如果没有type或者 type不在map中, 则直接调用生成实例的方法;
    • 调用exit()方法退出.
  • generateEntity方法 我们这里主要看generateEntity方法, 因为我们只会讲怎样生成实体类, 并不会讲怎样生成控制层或者服务层, 看代码:

// 单独生成实体类
async generateEntity() {
  // 获取全部的表格名字
  const tableNames: string[] = this.tableName.split(",");
  await hasTableName(tableNames, async () => {
    // 获取表结构(源)
    const structure = await getTableStructure(tableNames);
    
    // 判断实例是否有基类
    const { collect, base_name } = baseEntity();
    
    // 将源结构转换成期望结构
    const columnStructure = transformStructure(structure, collect);
    
    // 生成实体类
    generateEntity(columnStructure, this.targetPath, base_name);
  });
}
  1. 获取全部的表格名字, 因为表格名字很有可能是多个, 且用逗号分割的, 所以这里要拿到tableNames;
  2. 调用hasTableName方法, 判断是否传入了表名, 如果没有直接结束;
  3. 如果有表名 执行回调;

回调方法做了什么呢?

  1. 执行getTableStructure方法, 获取所有数据表的结构;
  2. 调用baseEntity方法, 判断实例是否有基类;
  3. 调用transformStructure方法, 将源数据转换成期望的结构;
  4. 调用genearteEntity方法, 生成实体类;

getTableStructure方法

// 获取表结构
export const getTableStructure = async (tableNames: string[]): Promise<RowMap> => {
  // @ts-ignore
  const structure: Promise<RowMap> = tableNames.reduce(async (map: Promise<RowMap>, name: string) => {
    const newMap = (await map);
    try {
      newMap[name] = await db.query(`SHOW FULL FIELDS FROM ${name}`);
    } catch (error) {
      throw error;
    }
    return map;
  }, {});

  return structure;
}

该方法非常的简单, 就是通过数组的reduce方法的去获取每一个表的结构, 其中都是对于reducepromise的基本使用, 这里不在赘述; 获取到的结构为:

{ 
  type: [
    {
      Field: 't_binary',
      Type: 'binary(10)',
      Collation: null,
      Null: 'NO',
      Key: '',
      Default: null,
      Extra: '',
      Privileges: 'select,insert,update,references',
      Comment: ''
    },
    {...}
  ],
  // 如果是多个表名的话, 例如 v type,sys_file, 那下面就会再多一个
  sys_file: [
   {...},
   {...}
  ]
}

baseEntity方法

export const baseEntity = (): { base_name: string, collect: string[] } => {
  let { base_name = '', collect = '' } = readYMLConfig('data_config') || {};
  if (collect !== '' && collect != null) {
    collect = collect.split(',').map((field: string) => field.trim()).filter((field: string) => field !== '');
  }

  return { base_name, collect: collect === '' ? [] : collect };
}

该方法用于获取code-gen.yml配置文件中的data_config字段, 也非常的简单, 不再赘述;

transformStructure方法(代码较多, 想看代码的小伙伴可以看源代码)

该方法的作用就是, 将getTableStructure方法获取到的源数据结构转换成@Column所需要的option数据, 举个例子:
源数据

{
  type: [
    {
      Field: 't_dec',
      Type: 'decimal(20,8)',
      Collation: null,
      Null: 'NO',
      Key: '',
      Default: null,
      Extra: '',
      Privileges: 'select,insert,update,references',
      Comment: ''
    }
  ]
}

转换过后的数据

{
  type: [
    {
      type: 'decimal',
      length: undefined,
      precision: 20,
      scale: 8,
      primaryGeneratedColumn: false,
      enum: undefined,
      name: 't_dec',
      collation: undefined,
      nullable: undefined,
      default: undefined,
      comment: undefined,
      update: undefined,
      jsType: 'number',
      isIndex: false
    }  
  ]
}

genearteEntity方法(代码较多, 想看代码的小伙伴可以看源代码)

该方法根据transformStructure方法生成的数据, 进行文件生成;

效果

我们来看一下最后实现的效果, 假设我们有一个空的src目录;

├── src

我们还有一个type数据表;

image.png

那么当我们执行v type src/demo 或者 v type demo, 就会自动创建一个demo目录, 里面包含entity文件;

image.png

├── src
│   ├── demo
│   │   └── entities
│   │       └── type.entity.ts

是不是so cool! 嘿嘿😁

结语

该工具本身的实现方式很简单, 但是可以帮助我们减轻很多开发负担, 解决很多开发问题. 其实很多东西表面看起来很华丽很复杂, 但万变不离其宗, 对于数据的处理 是非常重要的, 希望小伙伴们共同进步, 天天向上!

联系方式

github: github.com/Veloma-Time…
npm: www.npmjs.com/package/nes…
有不明白的地方 也可以添加我的微信: __veloma__