从 0 构建一个基于 ER 图的低代码后端,实现方式Laravel适用

前后端分离了!

第一次知道这个事情的时候,内心是困惑的。

前端都出去搞 SPA,SEO 们同意吗?

后来,SSR 来了。

他说:“SEO 们同意了!”

任何人的反对,都没用了,时代变了。

各种各样的 SPA 们都来了,还有穿着跟 SPA 们一样衣服的各种小程序们。

为他们做点什么吧?于是 rxModels 诞生了,作为一个不希望被抛弃的后端,它希望能以更便捷的方式服务前端。

顺便把如何设计制作也分享出来吧,说不定会有一些借鉴意义。即便有不合理的地方,也会有人友善的指出来。

保持开放,付出与接受会同时发生,是双向受益的一个过程。

rxModels 是什么?#

一个款开源、通用、低代码后端。

使用 rxModels,只需要绘制 ER 图就可以定制一个开箱即用的后端。提供粒度精确到字段的权限管理功能,并对实例级别的权限管理提供表达式支持。

主要模块有:图形化的实体、关系管理界面 (rx-models Client),通用 JSON 格式的数据操作接口服务 ( rx-models ),前端调用辅助 Hooks 库 ( rxmodels-swr ) 等。

rxModels 基于 TypeScript,NestJS,TypeORM 和 Antv x6 实现。

TypeScript 的强类型支持,可以把一些错误在编译时就解决掉了,IDE 有了强类型的支持,可以自动引入依赖,提高了开发效率,节省了时间。

TypeScript 编译以后的目标执行码时 JS,一种运行时解释语言,这个特性赋予了 rxModels 动态发布实体和热加载 指令 的能力。用户可以使用 指令 实现业务逻辑,扩展通用 JSON 数据接口。给 rxModels 增加了更多使用场景。

NestJS 有助于代码的组织,使其拥有一个良好的架构。

TypeORM 是一款轻量级 ORM 库,可以把对象模型映射到关系数据库。它能够 “分离实体定义”,传入 JSON 描述就可以构建数据库,并对数据库提供面向对象的查询支持。得益于这个特性,图形化的业务模型转换成数据库数据库模型,rxModels 仅需要少量代码就可以完成。

AntV X6 功能相对已经比较全面了,它支持在节点 (node) 里面嵌入 React 组件,利用这个个性,使用它来绘制 ER 图,效果非常不错。如果后面有时间,可以再写一篇文章,介绍如何使用 AntV x6 绘制 ER 图。

要想跟着本文,把这个项目一步步做出来,最好能够提前学习一下本节提到的技术栈。

rxModels 目标定位#

主要为中小项目服务。

为什么不敢服务大项目?

真不敢,作者是业余程序员,没有大项目相关的任何经验。

梳理数据及数据映射#

先看一下演示,从直观上知道项目的样子:rxModels 演示

元数据定义#

元数据 (Meta),用于描述业务实体模型的数据。一部分元数据转化成 TypeORM 实体定义,随之生成数据库;另一部分元数据业务模型是图形信息,比如实体的大小跟位置,关系的位置跟形状等。

需要转化成 TypeORM 实体定义的元数据有:


import { ColumnMeta } from "./column-meta";

/**

* 实体类型枚举,目前仅支持普通实体跟枚举实体,

* 枚举实体类似语法糖,不映射到数据库,

* 枚举类型的字段映射到数据库是string类型

*/

export enum EntityType{

  NORMAL = "Normal",

  ENUM = "Enum",

}

/**

* 实体元数据

*/

export interface EntityMeta{

  /** 唯一标识 */

  uuid: string;

  /** 实体名称 */

  name: string;

  /** 表名,如果tableName没有被设置,会把实体名转化成蛇形命名法,并以此当作表名 */

  tableName?: string;

  /** 实体类型 */

  entityType?: EntityType|"";

  /** 字段元数据列表 */

  columns: ColumnMeta[];

  /** 枚举值JSON,枚举类型实体使用,不参与数据库映射 */

  enumValues?: any;

}

/**

* 字段类型,枚举,目前版本仅支持这些类型,后续可以扩展

*/

export enum ColumnType{

  /** 数字类型 */

  Number = 'Number',

  /** 布尔类型 */

  Boolean = 'Boolean',

  /** 字符串类型 */  

  String = 'String',

  /** 日期类型 */  

  Date = 'Date',

  /** JSON类型 */

  SimpleJson = 'simple-json',

  /** 数组类型 */

  SimpleArray = 'simple-array',

  /** 枚举类型 */

  Enum = 'Enum'

}

/**

* 字段元数据,基本跟 TypeORM Column 对应

*/

export interface ColumnMeta{

  /** 唯一标识 */

  uuid: string;

  /** 字段名 */

  name: string;

  /** 字段类型 */

  type: ColumnType;

  /** 是否主键 */

  primary?: boolean;

  /** 是否自动生成 */

  generated?: boolean;

  /** 是否可空 */

  nullable?: boolean;

  /** 字段默认值 */

  default?: any;

  /** 是否唯一 */

  unique?: boolean;

  /** 是否是创建日期 */

  createDate?: boolean;

  /** 是否是更新日期 */

  updateDate?: boolean;

  /** 是否是删除日期,软删除功能使用 */

  deleteDate?: boolean;

  /**

   * 是否可以在查询时被选择,如果这是为false,则查询时隐藏。

   * 密码字段会使用它

   */

  select?: boolean;

  /** 长度 */

  length?: string | number;

  /** 当实体是枚举类型时使用 */

  enumEnityUuid?:string;

  /**

   * ============以下属性跟TypeORM对应,但是尚未启用

   */

  width?: number;

  version?: boolean;

  readonly?: boolean;  

  comment?: string;

  precision?: number;

  scale?: number;

}

/**

 * 关系类型

 */

export enum RelationType {

  ONE_TO_ONE = 'one-to-one',

  ONE_TO_MANY = 'one-to-many',

  MANY_TO_ONE = 'many-to-one',

  MANY_TO_MANY = 'many-to-many',

}

/**

 * 关系元数据

 */

export interface RelationMeta {

  /** 唯一标识 */

  uuid: string;

  /** 关系类型 */  

  relationType: RelationType;

  /** 关系的源实体标识 */  

  sourceId: string;

  /** 关系目标实体标识 */  

  targetId: string;

  /** 源实体上的关系属性 */  

  roleOnSource: string;

  /** 目标实体上的关系属性  */    

  roleOnTarget: string;

  /** 拥有关系的实体ID,对应 TypeORM 的 JoinTable 或 JoinColumn */

  ownerId?: string;

}

不需要转化成 TypeORM 实体定义的元数据有:


/**

 * 包的元数据

 */

export interface PackageMeta{

  /** ID,主键  */

  id?: number;

  /** 唯一标识 */

  uuid: string;

  /** 包名 */

  name: string;

  /**实体列表 */

  entities?: EntityMeta[];

  /**ER图列表 */

  diagrams?: DiagramMeta[];

  /**关系列表 */

  relations?: RelationMeta[];

}

import { X6EdgeMeta } from "./x6-edge-meta";

import { X6NodeMeta } from "./x6-node-meta";

/**

 * ER图元数据

 */

export interface DiagramMeta {

  /** 唯一标识 */

  uuid: string;

  /** ER图名称 */

  name: string;

  /** 节点 */

  nodes: X6NodeMeta[];

  /** 关系的连线 */

  edges: X6EdgeMeta[];

}

export interface X6NodeMeta{

  /** 对应实体标识uuid */

  id: string;

  /** 节点x坐标 */

  x?: number;

  /** 节点y坐标  */

  y?: number;

  /** 节点宽度 */

  width?: number;

  /** 节点高度 */

  height?: number;

}

import { Point } from "@antv/x6";

export type RolePosition = {

  distance: number,

  offset: number,

  angle: number,

}

export interface X6EdgeMeta{

  /** 对应关系 uuid */

  id: string;

  /** 折点数据 */

  vertices?: Point.PointLike[];

  /** 源关系属性位置标签位置 */

  roleOnSourcePosition?: RolePosition;

  /** 目标关系属性位置标签位置 */

  roleOnTargetPosition?: RolePosition;

}

rxModels 有一个后端服务,基于这些数据构建数据库。

rxModels 有一个前端管理界面,管理并生产这些数据。

服务端 rx-models#

整个项目的核心,基于 NestJS 构建。需要安装 TypeORM,只安装普通 TypeORM 核心项目,不需要安装 NestJS 封装版。


nest new rx-models

cd rx-models

npm install npm install typeorm

这只是关键安装,其他的库,不一一列举了。

具体项目已经完成,代码地址:github.com/rxdrag/rx-models

第一个版本承担技术探索的任务,仅支持 MySQL 足够了。

通用 JSON 接口#

设计一套接口,规定好接口语义,就像 GraphQL 那样。这样做的是优势,就是不需要接口文档,也不需要定义接口版本了。

接口以 JSON 为参数,返回也是 JSON 数据,可以叫 JSON 接口。

查询接口#

接口描述:


url: /get/jsonstring...

method: get

返回值:{

  data:any,

  pagination?:{

    pageSize: number,

    pageIndex: number,

    totalCount: number

  }

}

URL 长度是 2048 个字节,这个长度传递一个查询字符串足够用了,在查询接口中,可以把 JSON 查询参数放在 URL 里,使用 get 方法查数据。

把 JSON 查询参数放在 URL 里,有一个明显的优势,就是客户端可以基于 URL 缓存查询结果,比如使用 SWR 库

有个特别需要注意的点就是 URL 转码,要不然查询时,like 使用 % 会导致后端出错。所以,给客户端写一套查询 SDK,封装这些转码类操作是有必要的。

查询接口示例#

传入实体名字,就可以查询实体的实例,比如要查询所有的文章 (Post),可以这么写:


{

  "entity": "Post"

}

要查询 id = 1 的文章,则这样写:


{

  "entity": "Post",

  "id": 1

}

把文章按照标题和日期排序,这么写:


{

  "entity": "Post",

  "@orderBy": {

    "title": "ASC",

    "updatedAt": "DESC"

  }

}

只需要查询文章的 title 字段,这么写:


{

  "entity": "Post",

  "@select": ["title"]

}

这么写也可以:


{

  "entity @select(title)": "Post"

}

只取一条记录:


{

  "entity": "Post",

  "@getOne": true

}

或者:


{

  "entity @getOne": "Post"

}

只查标题中有 “水” 字的文章:


{

  "entity": "Post",

  "title @like": "%水%"

}

还需要更复杂的查询,内嵌类似 SQL 的表达式吧:


{

  "entity": "Post",

  "@where": "name %like '%风%' and ..."

}

数据太多了,分页,每页 25 条记录取第一页:


{

  "entity": "Post",

  "@paginate": [25, 0]

}

或者:


{

  "entity @paginate(25, 0)": "Post"

}

关系查询,附带文章的图片关系 medias :


{

  "entity": "Post",

  "medias": {}

}

关系嵌套:


{

  "entity": "Post",

  "medias": {

    "owner":{}

  }

}

给关系加个条件:


{

  "entity": "Post",

  "medias": {

    "name @like": "%风景%"

  }

}

只取关系的前 5 个


{

  "entity": "Post",

  "medias @count(5)": {}

}

聪明的您,可以按照这个方向,对接口做进一步的设计更改。

@ 符号后面的,称之为 指令

把业务逻辑放在指令里,可以对接口进行非常灵活的扩展。比如在文章内容 (content) 底部附加加一个版权声明,可以定义一个 @addCopyRight 指令:


{

  "entity": "Post",

  "@addCopyRight": "content"

}

或者:


{

  "entity @addCopyRight(content)": "Post"

}

指令看起来是不是像一个插件?

既然是个插件,那就赋予它热加载的能力!

通过管理界面,上传第三方指令代码,就可以把指令插入系统。

第一版不支持指令上传功能,但是架构设计已经预留了这个能力,只是配套的界面没做。

post 接口#

接口描述:


url: /post

method: post

参数: JSON

返回值: 操作成功的对象

通过 post 方法,传入 JSON 数据。

预期 post 接口具备这样的能力,传入一组对象组合(或者说附带关系约束的对象树),直接把这组对象同步到数据库。

如果给对象提供了 id 字段,则更新已有对象,没有提供 id 字段,则创建新对象。

post 接口示例#

上传一篇文章,带图片关联,可以这么写:


{

  "Post": {

    "title": "轻轻的,我走了",

    "content": "...",

    // 作者关联 id

    "author": 1,

    // 图片关联 id

    "medias":[3, 5, 6 ...]

  }

}

也可以一次传入多篇文章


{

  "Post": [

    {

      "id": 1,

      "title": "轻轻的,我走了",

      "content": "内容有所改变...",

      "author": 1,

      "medias":[3, 5, 6 ...]

    },

    {

      "title": "正如,我轻轻的来",

      "content": "...",

      "author": 1,

      "medias": [6, 7, 8 ...]

    }

  ]

}

第一篇文章有 id 字段,是更新数据库的操作,第二篇文章没有 id 字段,是创建新的。

也可以传入多个实体的实例,类似这样,同时传入文章(Post)跟媒体(Media)的实例:


{

  "Post": [

    {

      ...

    },

    {

      ...

    }

  ],

  "Media": [

    {

      ...

    }

  ]

}

可以把关联一并传入,如果一篇文章关联一个 SeoMeta 对象,创建文章时,一并创建 SeoMeta:


{

  "Post": {

    "title": "轻轻的,我走了",

    "content": "...",

    "author": 1,

    "medias":[3, 5, 6 ...],

    "seoMeta":{

      "title": "诗篇解读:轻轻的,我走了|诗篇解读网",

      "descript": "...",

      "keywords": "诗篇,解读,诗篇解读"

    }

  }

}

传入这个参数,会同时创建两个对象,并在它们之间建立关联。

正常情况下删除这个关联,可以这样写:


{

  "Post": {

    "title": "轻轻的,我走了",

    "content": "...",

    "author": 1,

    "medias":[3, 5, 6 ...],

    "seoMeta":null

  }

}

这样的方式保存文章,会删除跟 SeoMeta 的关联,但是 SeoMeta 的对象并没有被删除。别的文章也不需要这个 SeoMeta,不主动删除它,数据库里就会生成一条垃圾数据。

保存文章的时候,添加一个 @cascade 指令,能解决这个问题:


{

  "Post @cascade(medias)": {

    "title": "轻轻的,我走了",

    "content": "...",

    "author": 1,

    "medias":[3, 5, 6 ...],

    "seoMeta":null

  }

}

@cascade 指令会级联删除与之关联的 SeoMeta 对象。

这个指令能放在关联属性上,写成这样吗?


{

  "Post": {

    "title": "轻轻的,我走了",

    "content": "...",

    "author": 1,

    "medias @cascade":[3, 5, 6 ...],

    "seoMeta":null

  }

}

最好不要这样写,客户端用起来不会很方便。

自定义指令可以扩展 post 接口,比如,要加一个发送邮件的业务,可以开发一个 @sendEmail 指令:


{

  "Post @sendEmail(title, content, water@rxdrag.com)": {

    "title": "轻轻的,我走了",

    "content": "...",

    "author": 1,

    "medias @cascade":[3, 5, 6 ...],

  }

}

假设每次保存文章成功后,sendEmail 指令都会把标题跟内容,发送到指定邮箱。

update 接口#

接口描述:


url: /update

method: post

参数: JSON

返回值: 操作成功的对象

post 接口已经具备了 update 功能了,为什么还要再做一个 update 接口?

有时候,需要一个批量修改一个或者几个字段的能力,比如把指定的消息标记为已读。

为了应对这样的场景,设计了 update 接口。假如,要所有文章的状态更新为 “已发布”:


{

  "Post": {

    "status": "published",

    "@ids":[3, 5, 6 ...],

  }

}

基于安全方面的考虑,接口不提供条件指令,只提供 @ids 指令(遗留原因,演示版不需要 @符号,直接写 ids 就行,后面会修改)。

delete 接口#

接口描述:


url: /delete

method: post

参数: JSON

返回值: 被删除的对象

delete 接口跟 update 接口一样,不提供条件指令,只接受 id 或者 id 数组。

要删除文章,只需要这么写:


{

  "Post": [3, 5, ...]

}

这样的删除,跟 update 一样,也不会删除跟文章相关的对象,级联删除的话需要指令 @cascade

级联删除 SeoMeta,这么写:


{

  "Post @cascade(seoMeta)": [3, 5, ...]

}

upload 接口#


url: /upload

method: post

参数: FormData

headers: {"Content-Type": "multipart/form-data;boundary=..."}

返回值: 上传成功后生成RxMedia对象

rxModels 最好提供在线文件管理服务功能,跟第三方的对象管理服务,比如腾讯云、阿里云、七牛什么的,结合起来。

第一版先不实现跟第三方对象管理的整合,文件存在本地,文件类型仅支持图片。

用实体 RxMedia 管理这些上传的文件,客户端创建 FormData,设置如下参数:


{

   "entity": "RxMedia",

   "file": ...,

   "name": "文件名"

   }

全部 JSON 接口介绍完了,接下就是如何实现并使用这些接口。

继续之前,说一下为什么选用 JSON,而不用其他方式。

为什么不用 oData#

开始这个项目的时候,对 oData 并不了解。

简单查了点资料,说是,只有在需要 Open Data(开放数据给其他组织)时候,才有必要按照 OData 协议设计 RESTful API。

如果不是把数据开放给其他组织,引入 oData 增加了发杂度。需要开发解析 oData 参数解析引擎。

oData 出了很长时间,并没有多么流行,还不如后来的 GraphQL 知名度高。

为什么不用 GraphQL?#

尝试过,没用起来。

一个人,做开源项目,只能接入现有的开源生态。一个人什么都做,是不可能完成的任务。

要用 GraphQL,只能用现有的开源库。现有的主流 GraphQL 开源库,大部分都是基于代码生成的。前一篇文章说过,不想做一个基于代码生成的低代码项目。

还有一个原因,目标定位是中小项目。GraphQL 对这些中小项目来说,有两个问题:1、有些笨重;2、用户的学习成本高。

有的小项目就三五个页面,拉一个轻便的小后端,很短时间就搭起来了,没有必要用 GraphQL。

GraphQL 的学习成本并不低,有些中小项目的用户是不愿意付出这些学习成本的。

综合这些因素,第一个版本的接口,没有使用 GraphQL。

使用 GraphQL 的话,需要怎么做?#

跟一些朋友交流的时候,有些朋友对 GraphQL 还是情有独钟的。并且经过几年的发展,GraphQL 的热度慢慢开始上来了。

假如使用 GraphQL 做一个类似项目,需要怎么做呢?

需要自己开发一套 GraphQL 服务端,这个服务端类似 Hasura,不能用代码生成机制,使用动态运行机制。Hasura 把 GQL 编译成 SQL,你可以选择这样做,也可以不选择这样做,只要能不经过编译过程,就把对象按照 GQL 查询要求,拉出来就行。

需要在 GraphQL 的框架下,充分考虑权限管理,业务逻辑扩展和热加载等方面。这就需要对 GraphQL 有比较深入的理解。

如果要做低代码前端,那么还需要做一个特殊的前端框架,像 apollo 这样的 GraphQL 前端库库,并不适合做低代码前端。因为低代码前端需要动态类型绑定,这个需求跟这些前端库的契合,并不是特别理想。

每一项,都需要大量时间跟精力,不是一个人能完成的工作,需要一个团队。

或有一天,有机会,作者也想进行这样方面的尝试。

但也未必会成功,GraphQL 本身并不代表什么,假如它能够使用者带来实实在在的好处,才是被选择的理由。

登录验证接口#

使用 jwt 验证机制,实现两个登录相关的接口。


url: /auth/login

method: post

参数: {

  username: string,

  password: string

}

返回值:jwt token

url: /auth/me

method: get

返回值: 当前登录用户,RxUser类型

这两个接口实现起来,没有什么难的,跟着 NestJs 文档做一下就行了。

元数据存储#

客户端通过 ER 图的形式生产的元数据,存储在数据库,一个实体 RxPackage 就够了:


export interface RxPackage {

  /* id 数据库主键 */

  id: number;

  /** 唯一标识uuid,当不同的项目之间共享元数据时,这个字段很有用 */

  uuid: string;

  /** 包名 */

  name: string;

  /** 包的所有实体元数据,以JSON形式存于数据库 */

  entities: any;

  /** 包的所有 ER 图,以JSON形式存于数据库 */

  diagrams?: any;

  /** 包的所有关系,以JSON形式存于数据库 */

  relations?: any;

}

数据映射完成后,在界面中看到的一个包的所有内容,就对应 rx_package 表的一条数据记录。

这些数据怎么被使用呢?

我们给包增加一个发布功能,如果包被发布,就根据这条数据库记录,做一个 JSON 文件,放在 schemas 目录下,文件名就是 ${uuid}.json

服务端创建 TypeORM 连接时,热加载这些 JSON 文件,并把它们解析成 TypeORM 实体定义数据。

应用安装接口#

rxModels 的最终目标是,发布一个代码包,使用者通过图形化界面安装即可,不要接触代码。

两页向导,即可完成安装,需要接口:


url: install

method: post

参数: {

  /** 数据库类型 */

  type: string;

  /** 数据库所在主机 */

  host: string;

  /** 数据库端口 */

  port: string;

  /** 数据库schema名 */

  database: string;

  /** 数据登录用户 */

  username: string;

  /** 数据库登录密码 */

  password: string;

  /** 超级管理员登录名  */

  admin: string;

  /** 超级管理员密码 */

  adminPassword: string;

  /** 是否创建演示账号 */

  withDemo: boolean;

}

还需要一个查询是否已经安装的接口:


url: /is-installed

method: get

返回值: {

  installed: boolean

}

只要完成这些接口,后端的功能就实现了,加油!

架构设计#

得益于 NestJs 优雅的框架,可以把整个后端服务分为以下几个模块:

  • auth, 普通 NestJS module,实现登录验证接口。本模块很简单,后面不会单独介绍了。

  • package-manage, 元数据的管理发布模块。

  • install, 普通 NestJS module,实现安装功能。

  • schema, 普通 NestJS module,管理系统元数据,并把前面定义的格式的元数据,转化成 TypeORM 能接受的实体定义,核心代码是 SchemaService

  • typeorm, 对 TypeORM 的封装,提供带有元数据定义的 Connection,核心代码是 TypeOrmService,该模块没有 Controller。

  • magic, 项目最核心模块,通用 JSON 接口实现模块。

  • directive, 指令定义模块,定义指令功能用到的基础类,热加载指令,并提供指令检索服务。

  • directives, 所有指令实现类,系统从这个目录热加载所有指令。

  • magic-meta, 解析 JSON 参数用到的数据格式,主要使用模块是 magic,由于 directive 模块也会用到这些数据,为了避免模块之间的循环依赖,把这部分数据抽出来,单独作为一个模块,那两个模块同时依赖这个模块。

  • entity-interface, 系统种子数据类型接口,主要用于 TypeScript 编译器的类型识别。客户端的代码导出功能导出的文件,直接复制过来的。客户端也会复制一份同样的代码来用。

包管理 package-manage#

提供一个接口 publishPackages。把参数传入的元数据,发布到系统里,同步到数据库模式:

  • 就是一个包一个文件,放在根目录的 schemas 目录下,文件名就是包的 uuid + .json 后缀。

  • 通知 TypeORM 模块重新创建数据库连接,同时同步数据库。

安装模块 install#

模块内有一个种子文件 install.seed.json,里面是系统预置的一些实体,格式就是上文定义的元数据格式,这些数据统一组织在 System 包里。

客户端没有完成的时候,手写了一个 ts 文件用于调试,客户端完成以后,直接利用包的导出功能,导出了一个 JSON 文件,替换了手写的 ts 文件。相当于基础数据部分,可以自举了。

这个模块的核心代码在 InstallService 里,它分步完成:

  • 把客户端传来的数据库配置信息,写入根目录的 dbconfig.json 文件。

  • install.seed.json 文件里面的预定义包发布。直接调用上文说的 publishPackages 实现发布功能。

元数据管理模块 schema#

该模块提供一个 Controller,名叫 SchemaController。提供一个 get 接口 /published-schema,用于获取已经发布的元数据信息。

这些已经发布的元数据信息可以被客户端的权限设置模块使用,因为只有已经发布的模块,对它设置权限才有意义。低代码可视化编辑前端,也可以利用这些信息,进行下拉选择式的数据绑定。

核心类 SchemaService,还提供了更多的功能:

  • /schemas 目录下,加载已经发布的元数据。

  • 把这些元数据组织成列表 + 树的结构,提供按名字、按 UUID 等方式的查询服务。

  • 把元数据解析成 TypeORM 能接受的实体定义 JSON。

封装 TypeORM#

自己写一个 ORM 库工作量是很大的,不得不使用现成的,TypeORM 是个不错的选择,一来,她像个年轻的姑娘,漂亮又活力四射。二来,她不像 Prisma 那么臃肿。

为了迎合现有的 TyeORM,有些地方不得不做妥协。这种低代码项目后端,比较理想的实现方式自己做一个 ORM 库,完全根据自己的需求实现功能,那样或许就有青梅竹马的感觉了,但是需要团队,不是一个人能完成。

既然是一个人,那么就安心做一个人能做的事情好了。

TypeORM 只有一个入口能够传入实体定义,就是 createConnection。需要在这个函数调用前,解析完元数据,分离出实体定义。这个模块的 TypeOrmService 完成这些 connection 的管理工作,依赖的 schema 模块的 SchemaService

通过 TypeOrmService 可以重启当前连接(关闭并重新创建),以更新数据库定义。创建连接的时候,使用 install 模块创建的 dbconfig.json 文件获取数据库配置。注意,TypeORM 的 ormconfig.json 文件是没有被使用的。

magic 模块#

在 magic 模块,不管查询还是更新,每一个接口实现的操作,都在一个完整的事务里。

难道查询接口也要包含在一个事务里?

是的,因为有的时候查询可能会包含一些简单操作数据库的指令,比如查询一篇文章的时候,顺便把它的阅读次数 +1。

magic 模块的增删查改等操作,都受到权限的约束,把它的核心模块 MagicInstanceService 传递给指令,指令代码里可以放心使用它的接口操作数据库,不需要关心权限问题。

MagicInstanceService#

MagicInstanceService 是接口 MagicService 的实现。接口定义:


import { QueryResult } from 'src/magic-meta/query/query-result';

import { RxUser } from 'src/entity-interface/RxUser';

export interface MagicService {

  me: RxUser;

  query(json: any): Promise<QueryResult>;

  post(json: any): Promise<any>;

  delete(json: any): Promise<any>;

  update(json: any): Promise<any>;

}

magic 模块的 Controller 直接调用这个类,实现上文定义的接口。

AbilityService#

权限管理类,查询当前登录用户的实体跟字段的权限配置。

query#

/magic/query 目录,实现 /get/json... 接口的代码。

MagicQuery 是核心代码,实现查询业务逻辑。它使用 MagicQueryParser 把传入的 JSON 参数,解析成一棵数据树,并分离相关指令。数据结构定义在 /magic-meta/query 目录。代码量太大,没有精力一一解析。自己翻阅一下,有问题可以跟作者联系。

需要特别注意的是 parseWhereSql 函数。这个函数负责解析类似 SQL Where 格式的语句,使用了开源库 sql-where-parser

把它放在这个目录,是因为 magic 模块需要用到它,同时 directive 模块也需要用到它,为了避免模块的循环依赖,把它独立抽到这个目录。

/magic/query/traverser 目录存放一些遍历器,用于处理解析后的树形数据。

MagicQuery 使用 TypeORM 的 QueryBuilder 构建查询。关键点:

  • 使用 directive 模块的 QueryDirectiveService 获取指令处理类。指令处理类可以:1、构建 QueryBuilder 用到的条件语句,2、过滤查询结果。

  • AbilityService 拿到权限配置,根据权限配置修改 QueryBuilder, 根据权限配置过滤查询结果中的字段。

  • QueryBuilder 用到的查询语句分两部分:1、影响查询结果数量的语句,比如 take 指令、paginate 指令。这些指令只是要截取指令数量的结果;2、其他没有这种影响的查询语句。因为分页时,需要返回一个总的记录条数,用第二类查询语句先查一次数据库,获得总条数,然后加入第一类查询语句获得查询结果。

post#

/magic/post 目录,实现 /post 接口的代码。

MagicPost 类是核心代码,实现业务逻辑。它使用 MagicPostParser 把传入的 JSON 参数,解析成一棵数据树,并分离相关指令。数据结构定义在 /magic-meta/post 目录。它可以:

  • 递归保存关联对象,理论上可以无限嵌套。

  • 根据 AbilityService 做权限检查。

  • 使用 directive 模块的 PostDirectiveService 获取指令处理类, 在实例保存前跟保存后会调用指令处理程序,详情请翻阅代码。

update#

/magic/update 目录,实现 /update 接口的代码。

功能简单,代码也简单。

delete#

/magic/delete 目录,实现 /delete 接口的代码。

功能简单,代码也简单。

upload#

/magic/upload 目录,实现 /upload 接口的代码。

upload 目前功能比较简单,后面可以考添加一些裁剪指令等功能。

directive 模块#

指令服务模块。热加载指令,并对这些指令提供查询服务。

这个模块也比较简单,热加载使用的是 require 语句。

关于后端,其它模块就没什么好说的,都很简单,直接看一下代码就好。

客户端 rx-models-client#

需要一个客户端,管理生产并管理元数据,测试通用数据查询接口,设置实体权限,安装等。创建一个普通的 React 项目, 支持 TypeScript。


npx create-react-app rx-models-client--template typescript

这个项目已经完成了,在 GitHub 上,代码地址:github.com/rxdrag/rx-models-client

代码量有点多,全部在这里展开解释,有点放不下。只能挑关键点说一下,有问题需要交流的话,请跟作者联系。

ER 图 - 图形化的业务模型#

这个模块是客户端的核心,看起来比较唬人,其实一点都不难。目录 src/components/entity-board 下,是该模块全部代码。

得益于 Antv X6,使得这个模块的制作比预想简单了许多。

X6 充当的角色,只是一个视图层。它只负责渲染实体图形跟关系连线,并传回一些用户交互事件。它用于撤销、重做的操作历史功能,在这个项目里用不上,只能全部自己写。

Mobx 在这个模块也占非常重要的地位,它管理了所有的状态并承担了部分业务逻辑。低代码跟拖拽类项目,Mobx 确实非常好用,值得推荐。

定义 Mobx Observable 数据#

上文定义的元数据,每一个对应一个 Mobx Observable 类,再加一个根索引类,这数据相互包含,构成一个树形结构,在 src/components/entity-board/store 目录下。

  • EntityBoardStore, 处于树形结构的根节点,也是该模块的整体状态数据,它记录下面这些信息:

export class EntityBoardStore{

  /**

   * 是否有修改,用于未保存提示

   */

  changed = false;

  /**

   * 所有的包

   */

  packages: PackageStore[];

  /**

   * 当前正在打开的 ER 图

   */

  openedDiagram?: DiagramStore;

  /**

   * 当前使用的 X6 Graph对象

   */

  graph?: Graph;

  /**

   * 工具条上的关系被按下,记录具体类型

   */

  pressedLineType?: RelationType;

  /**

   * 处在鼠标拖动划线的状态

   */

  drawingLine: LineAction | undefined;

  /**

   * 被选中的节点

   */

  selectedElement: SelectedNode;

  /**

   * Command 模式,撤销列表

   */

  undoList: Array<Command> = [];

  /**

   * Command 模式,重做列表

   */

  redoList: Array<Command> = [];

  /**

   * 构造函数传入包元数据,会自动解析成一棵 Mobx Observable 树

   */

  constructor(packageMetas:PackageMeta[]) {

    this.packages = packageMetas.map(

      packageMeta=> new PackageStore(packageMeta,this)

    );

    makeAutoObservable(this);

  }

  /**

   * 后面大量的set方法,就不需要了展开了

   */

  ...

}
  • PackageStore, 树形完全跟上文定义的 PackageMeta 一致,区别就是 meta 相关的全都换成了 store 相关的:

export class PackageStore{

  id?: number;

  uuid: string;

  name: string;

  entities: EntityStore[] = [];

  diagrams: DiagramStore[] = [];

  relations: RelationStore[] = [];

  status: PackageStatus;

  constructor(meta:PackageMeta, public rootStore: EntityBoardStore){

    this.id = meta.id;

    this.uuid = meta?.uuid;

    this.name = meta?.name;

    this.entities = meta?.entities?.map(

      meta=>new EntityStore(meta, this.rootStore, this)

    )||[];

    this.diagrams = meta?.diagrams?.map(

      meta=>new DiagramStore(meta, this.rootStore, this)

    )||[];

    this.relations = meta?.relations?.map(

      meta=>new RelationStore(meta, this)

    )||[];

    this.status = meta.status;

    makeAutoObservable(this)

  }

  /**

   * 省略set方法

   */

  ...

  /**

   * 最后提供一个把 Store 逆向转成元数据的方法,用于往后端发送数据

   */

  toMeta(): PackageMeta {

    return {

      id: this.id,

      uuid: this.uuid,

      name: this.name,

      entities: this.entities.map(entity=>entity.toMeta()),

      diagrams: this.diagrams.map(diagram=>diagram.toMeta()),

      relations: this.relations.map(relation=>relation.toMeta()),

      status: this.status,

    }

  }

}

依此类推,可以做出 EntityStoreColumnStoreRelationStoreDiagramStore

前面定义的 X6NodeMetaX6EdgeMeta 不需要制作相应的 store 类,因为没法通过 Mobx 的机制更新 X6 的视图,要用其它方式完成这个工作。

DiagramStore 主要为展示 ER 图提供数据。给它添加两个方法:


export type NodeConfig = X6NodeMeta & {data: EntityNodeData};

export type EdgeConfig = X6EdgeMeta & RelationMeta;

export class DiagramStore {

  ...

  /**

   * 获取当前 ER 图所有的节点,利用 mobx 更新机制,

   * 只要数据有更改,调用该方法的视图会自动被更新,

   * 参数只是用了指示当前选中的节点,或者是否需要连线,

   * 这些状态会影响视图,可以在这里直接传递给每个节点

   */

  getNodes(

    selectedId:string|undefined, 

    isPressedRelation:boolean|undefined

  ): NodeConfig[]

  /**

   * 获取当前 ER 图所有的连线,利用 mobx 更新机制,

   * 只要数据有更改,调用该方法的视图会自动被更新

   */

  getAndMakeEdges(): EdgeConfig[]

}

如何使用 Mobx Observable 数据#

使用 React 的 Context,把上面定义的 store 数据传递给子组件。

定义 Context:


export const EnityContext = createContext<EntityBoardStore>({} as EntityBoardStore);

export const EntityStoreProvider = EnityContext.Provider;

export const useEntityBoardStore = (): EntityBoardStore => useContext(EnityContext);

创建 Context:


...

const [modelStore, setModelStore] = useState(new EntityBoardStore([]));

...

  return (

    <EntityStoreProvider value = {modelStore}>

      ...

    </EntityStoreProvider>

  )

使用的时候,直接在子组件里调用 const rootStore = useEntityBoardStore() 就可以拿到数据了。

树形编辑器#

利用 Mui 的树形控件 + Mobx 对象,代码并不复杂,感兴趣的话,翻翻看看,有疑问留言或者联系作者。

如何使用 AntV X6#

X6 支持在节点里嵌入 React 组件,定义一个组件 EntityView 嵌入进去就好。X6 相关代码都在这个目录下:


src/componets/entity-board/grahp-canvas

业务逻辑被拆分成很多 React Hooks:

  • useEdgeChange, 处理关系线被拖动

  • useEdgeLineDraw, 处理画线动过

  • useEdgeSelect, 处理关系线被选中

  • useEdgesShow, 渲染关系线,包括更新

  • useGraphCreate, 创建 X6 的 Grpah 对象

  • useNodeAdd, 处理拖入一个节点的动作

  • useNodeChange, 处理实体节点被拖动或者改变大小

  • useNodeSelect, 处理节点被选中

  • useNodesShow, 渲染实体节点,包括更新

撤销、重做#

撤销、重做不仅跟 ER 图相关,还跟整个 store 树相关。这就是说,X6 的撤销、重做机制用不了,只能自己重新做。

好在设计模式中的 Command 模式还算简单,定义一些 Command,并定义好正负操作,可以很容易完成。实现代码在:


src/componets/entity-board/command

全局状态 AppStore#

按照上问的方法,利用 Mobx 做一个全局的状态管理类 AppStore,用于管理整个应用的状态,比如弹出操作成功提示,弹出错误信息等。

代码在 src/store 目录下。

接口测试#

代码在 src/components/api-board 目录下。

很简单一个模块,代码应该很容易懂。使用了 rxmodels-swr 库,直接参考它的文档就好。

JSON 输入控件,使用了 monaco 的 react 封装:react-monaco-editor,使用起来很简单,安装稍微麻烦一点,需要安装 react-app-rewired

monaco 用的还不熟练,后面熟练了可以加入如下功能输入提示和代码校验等功能。

权限管理#

代码在 src/components/auth-board 目录下。

这个模块之主要是后端数据的组织跟接口定义,前端代码很少,基于 rxmodels-swr 库完成。

权限定义支持表达式,表达式类似 SQL 语句,并内置了变量 $me 指代当前登录用户。

前端输入时,需要对 SQL 表达式进行校验,所以也引入了开源库 sql-where-parser

安装、登录#

安装代码在 src/components/install 目录下。

登录页面是 src/components/login.tsx

代码一眼就能瞅明白。

后记#

这篇文章挺长的,但是还不确定有没有把需要说的说清楚,有问题的话留言或者联系作者吧。

演示能跑起来以后,就已经冒着被踢的危险,在几个 QQ 群发了一下。收到了很多反馈,非常感谢热心的朋友们。

rxModels,终于走出去了第一步…

与前端的第一次接触#

rxModels 来了,热情的走向前端们。

前端们皱起了眉头,说:“离远点儿,你不是我们理想中的样子。”

rxModels 说:“我还会改变,还会成长,未来的某一天,我们一定是最好的搭档。”

下一篇文章#

《从 0 构建一个可视化低代码前端》,估计要等一段时间了,要先把前端重构完。

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。