开源低代码平台开发实践二:从 0 构建一个基于 ER 图的低代码后端

前后端分离了!

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

前端都出去搞 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演示

开源低代码平台开发实践二:从 0 构建一个基于 ER 图的低代码后端_第1张图片

元数据定义

元数据(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

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

具体项目已经完成,代码地址:https://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, [email protected])": {
    "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 的最终目标是,发布一个代码包,使用者通过图形化界面安装即可,不要接触代码。

开源低代码平台开发实践二:从 0 构建一个基于 ER 图的低代码后端_第2张图片

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

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;

  post(json: any): Promise;

  delete(json: any): Promise;

  update(json: any): Promise;
}

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上,代码地址:https://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 模式,重做列表
     */
    redoList: Array = [];
    
    /**
     * 构造函数传入包元数据,会自动解析成一棵 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({} as EntityBoardStore);
export const EntityStoreProvider = EnityContext.Provider;
export const useEntityBoardStore = (): EntityBoardStore => useContext(EnityContext);

创建 Context:

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

...
  return (
    
      ...
    
  )

使用的时候,直接在子组件里调用 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 构建一个可视化低代码前端》,估计要等一段时间了,要先把前端重构完。

你可能感兴趣的:(开源低代码平台开发实践二:从 0 构建一个基于 ER 图的低代码后端)