前言
在使用 NestJs + Mysql
项目开发中, 也许你会开启由实体类同步生成数据表的方式, 这种方式是可以的, 而且也是typeorm
所擅长的, 但是也面临着数据会被误删的风险, 所以呢 我们几个小伙伴就想做一个工具来解决这个问题.
目标
我们不管要开发什么东西, 首先要明确的是, 我们想让这个东西来做什么, 有哪些功能, 这些功能大概的实现方式是什么? 能提前画好架构图或者流程图就更好了? 这里我们总结了几点, 我们想让这个工具来帮我们做的事情:
通过数据库的数据表结构
- 反向生成对应的实体类
- 反向生成对应的控制器
- 反向生成对应的服务层
大概思路
- 我想 像
nest-cli
或者vue-cli
这一些优秀的cli
一样, 通过在命令行输入一句简单的命令就能生成各种各样的文件(entity、controller、services), 因为这样是非常非常方便的. - 我想获取到用户指定的数据表结构, 并且将它转换成自己需要的结构.
- 用户输入
v type src
工具就会根据 数据表type
生成实体类、控制器、服务层并将生成的文件夹挂载到src
目录下.
开始
首先我们先来研究一下怎样将代码通过命令的方式来运行
- 我们先来执行以下命令来创建一个项目
npm init -y && mkdir bin && touch ./bin/code-gen && mkdir src && touch ./src/index.ts
- 我们将
package.json
中加上一个属性bin
, 他的值是一个对象, 对象的key
是 “指令” 名字, 对象的value
是 “指令执行的文件” 例如这个样子:
这里可以看到两个问题:
- 我指定了多个key(v、code-gen、nest-code-generate), 这里要明确 这些只是别名而已, 想取多少个名字就取多少个名字, 作者只是为了测试方便.
- code-gen似乎是一个没有后缀的文件, 既不是.js也不是.ts, 是的 这个文件确实是没有后缀的
- 我们在
bin/code-gen
里面写一点东西
如果电脑前的小伙伴用的是vscode的话, 编写code-gen一般是没有代码提示的, 我们这里通过下图来解决一下
#!/usr/bin/env node
console.log('code-gen');
复制代码
我们来看一下上面这段代码:
- 首行的
#!/usr/bin/env node
用于指明这个脚本文件的解释程序, 我们这里指定的是node
, 然后我们简单打印了一点东西. - 那么我们怎么去执行这个程序呢? 这个时候在命令行执行
v
, 我们会不会看到命令行中打印出code-gen呢? - 很显然是不可以的, 因为我们还少了最后一步非常非常重要的操作, 就是执行
npm link
这个命令. - 这个命令的作用就是在本地创建一个快捷操作, 让你可以在不发布包的前提下 就能体验到该包的功能, 有关这一命令的信息, 小伙伴们可以看一看npm的官方文档, 或者网上查一下资料 还是非常简单的, 如果在执行这个命令时 产生了冲突, 我们可以加上一个
--force
后缀, 强制更新一下. - 这个时候我们在命令行执行
v
就会看到命令行打印出code-gen这一消息.
怎样接收命令行中用户输入的参数呢?
- 这里我们要用的一个大部分
cli
都会用到的包, 它的名字叫做commander
, 执行npm i commander
使用方法也非常的简单, 我们这里会简单的把用到的知识说一下, 因为我发现官网说的并不明确(反正俺是理解不了). - 我们来看一下下面这段代码:
- 第一段:
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来实现, 但是这里我们要改动一下文件 以方便我们的测试以及编码.
- 新增
tsconfig.json
; bin/code-gen
: 这里的Parser是从lib(打包路径)
里面引入的
src/index.ts
: 这里就是打包的入口文件, 暴露Parser
package.json
: 这里要添加几个script
- 文件修改完成, 我们首先运行
npm run serve
或者npm run build
将文件打包到lib
文件夹下, 然后执行v type
指令 看看会发生什么? 我们拿到了type
表的结构.
生成实体类
生成实体类的时候 我们还要涉及到一个命令行问答模式 这里我们先把这个功能实现, 用户可以通过这个功能来实现像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
方法做了什么事情:- 调用
inquirer
的prompt
方法, 在命令行中发起询问, 就像这个样子;
我们来看一下传递给prompt
的参数, 目前我们只用到了第一个参数, 也就是一个数组, 数组的每一项代表一个问题, 我们这里只传递了一个问题, 我们来看一下这个问题的key
分别都代表什么意思.name
: 就是一会这个方法返回给你的字段名字;type
: 当前问题的类型, 目前用到的是list
和input
, 一个是列表 一个是输入.message
: 这个就比较简单了, 就是你要提问的问题.choices
: 这个属性是一个数组, 只有type
为list
的时候才有这个属性, 数组中每一项中的name
就是显示给用户看的内容, 而另一个value
就是用户选中之后 返回给你的值.
我们来看一下实际应用, 接收到的是什么?
- 将
type
存起来, 因为这个就是用户要生成的内容; - 获取生成路径, 该方法只有一个功能, 就是获取用户最终的生成路径, 默认为
src
; - 调用
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);
});
}
复制代码
- 获取全部的表格名字, 因为表格名字很有可能是多个, 且用逗号分割的, 所以这里要拿到
tableNames
; - 调用
hasTableName
方法, 判断是否传入了表名, 如果没有直接结束; - 如果有表名 执行回调;
回调方法做了什么呢?
- 执行
getTableStructure
方法, 获取所有数据表的结构;- 调用
baseEntity
方法, 判断实例是否有基类;- 调用
transformStructure
方法, 将源数据转换成期望的结构;- 调用
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
方法的去获取每一个表的结构, 其中都是对于reduce
、promise
的基本使用, 这里不在赘述; 获取到的结构为:
{
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
数据表;
那么当我们执行v type src/demo
或者 v type demo
, 就会自动创建一个demo
目录, 里面包含entity
文件;
├── src
│ ├── demo
│ │ └── entities
│ │ └── type.entity.ts
复制代码
是不是so cool! 嘿嘿?
结语
该工具本身的实现方式很简单, 但是可以帮助我们减轻很多开发负担, 解决很多开发问题.
其实很多东西表面看起来很华丽很复杂, 但万变不离其宗, 对于数据的处理 是非常重要的, 希望小伙伴们共同进步, 天天向上!
联系方式
github: github.com/Veloma-Time…
npm: www.npmjs.com/package/nes…
有不明白的地方 也可以添加我的微信: __veloma__