
前后端分离了!第一次知道这个事情的时候,内心是困惑的。前端都出去搞 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这只是关键安装,其他的库,不一一列举了。具体项目已经完成,代码地址: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, 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 tokenurl: /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 传递给指令,指令代码里可以放心使用它的接口操作数据库,不需要关心权限问题。MagicInstanceServiceMagicInstanceService 是接口 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上,代码地址: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> = []; /** * 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, } } }依此类推,可以做出 EntityStore、ColumnStore、RelationStore 和 DiagramStore。前面定义的 X6NodeMeta 和 X6EdgeMeta 不需要制作相应的 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 X6X6 支持在节点里嵌入 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 构建一个可视化低代码前端》,估计要等一段时间了,要先把前端重构完。
开源、全站、低代码项目 rxDrag 的前、后端演示终于全都上线了,停下来喘口气,把开发实践通过系列文章的方式分享出来,顺便整理一下思路。当决定要做这个低代码项目的时候,低代码还不像现在这样火。开发过程中,只是觉得前端后端合起来,有很多冗余信息,被代码一遍遍重复表达,是一件很枯燥、无聊的事情。这些枯燥的重复工作,完全可以由机器来做,以便解放出我们的时间,来做更有价值的工作。带着这些有点儿天真的想法,开始了低代码开发的探索之路。随着工作越来越深入,接触到的低代码领域的人也越来越多。慢慢意识到,低代码火了!当看到资本们疯狂的追逐、老板们天马行空的幻想、商家们无底线的吹捧、程序员们充满优越感的鄙视...难免会思考,自己做低代码的意义到底是什么?为什么要趟这趟浑水?当大潮退去,一地鸡毛,一个四十多岁业余程序员的时光,是否被毫无意义的消耗掉了?但是,有时候梦想的种子被种下,就很难将其湮灭。可能就是这份执念的驱动,让自己坚持了一年多,前端后端都尝试一遍。最后也想明白了,生命是以死亡为代价的,所有消失的事物,只要存在过,或多或少就实现了部分自身价值。所有的尝试,不管成功还是失败,都会成为社会进步的动力。区别是,有的变成了肥料,有的开出了花朵,有的还结出了果实。管那么多呢,只要觉得自己做的工作能够帮助某些人,这样的工作就是有意义的。是否过度被追捧,形形色色的评判又有什么关系,就算在闹市里,也可以完全寻一方静室,做自己喜欢的事情,然后坚持到底!把自己的开发经验、心得尽量多的分享出来,就算项目开不了花、结不出果,那么充当肥料,也要更有营养一些。在分享开发经验之前,先回答一些问题。低代码到底有没有用?低代码不是软件开发方面的银弹,它解决不了软件危机,它更像是一个工具,就像近视镜、助听器、汽车、轮船等一样。这些工具有一个共同的特点,就是对有些人有用,对有些人却没用。低代码也是这样的。作为一个外贸从业者,见证了这样一个过程:从用静态页面做企业网站,到 wordpress 的蓬勃发展,再到 Shopify 的一统独立站天下。这个过程里,看到软件技术应用完全普及开来,还有很大的市场空间。有很多对软件技术不是很熟悉,对软件有很强烈的使用需求的人,却不得门而入。Wordpress 跟 Shopify 只是满足了这部分人的一部分需求,就取得了巨大的成功。低代码的存在,可以更好地服务有类似需求的人群。在这个领域,什么凡科、美篇、易企秀只是初级的开始,相信会有更多更优秀的应用不断涌现的。这些应用本质上都是低代码。人天生就不愿意做一些重复性枯燥工作,程序员也是。经常见一些优秀程序员,炫耀自己代码结构多么优秀,优秀到这样的程度:自己完成主要架构,重复性代码交给低端程序员去做。问题是,谁是低端程序员,谁愿意做低端程序员?这些枯燥的重复性工作,交给机器来做,是更为明智的选择。有条件的公司,根据自己的业务领域,把一些通用的东西抽出来,打造一个专属自己的低代码框架,是不是可以提高自己公司的开发效率?是不是可以系统的扩展能力?是不是可以提高为客户定制的能力?是不是具备了快速原型化一个愿景的能力?具有这些能力的前提是,愿意预先花一定的成本,做一个低代码平台。所以低代码是每一个开发者都可以参与的事情,不是大资本的专利。也希望自己做的rxDrag系列低代码项目,能够提供一些有价值的借鉴和帮助。如果某些模块,能被真正应用起来,那么持续这么长时间的忙碌也算值了。低代码不能做什么?很多事情,都是低代码不能完成的,它不能做的事情太多了,不能送我上下班、不能替我接孩子、不能治疗疾病..., 不要去要求它什么都行,也不要把关注点放在这个方面。当聚焦在它能做什的么时候,我们关注的创造,看到的是客户。只关注正向东西,会带来美好人生体验。程序员会被淘汰吗?低代码完成的是一些枯燥的,重复性的工作。作为一个程序员,如果坚持要做这些工作,跟没有情感的机器抢饭吃,那么可能是要被淘汰的。如果是带有情感的工作,是不容易被机器取代的。在Wordpress以前,国内的建站公司远比现在多,大家收着客户不菲的价钱,套用着劣质模板,做着充满浓浓乡土气息的企业网站。直到 Wordpress 出现,国外大量的质优价廉的主题模板通过 Wordpress 生态圈子进入国内,有些做外贸培训的机构,凭借教客户用WordPress建站,年收入达上亿元。很多国内建站公司被淘汰。试问这些被淘汰的公司,输得心服口服吗?没有任何编程经验的人,经过短短培训,做出来的网站,秒杀你们收费高昂的乡土网站,凭什么不被淘汰?淘汰,是新事物取代旧事物的过程。一个工种消失,往往会产生更多新的工种。就像马车车夫消失了,却出现了各种驾驶员、宇航员。面对这样的变化,需要感叹吗?需要恐惧吗?需要谴责吗?这样的态度谁会在意?这些变化谁能阻止?历史车轮滚滚,时代要淘汰我们的时候,会跟我们打招呼吗?面对这些,我们除了全力奔跑,还能做些什么?技术日新月异,爱却永恒不变。爱、美、创意不仅从来没有被淘汰,反而越来越珍贵。愿意相信,真心为他人着想,用心服务客户的人,不会被淘汰,只是换个服务客户的形式而已。低代码不是毒瘤,也不是万能药,只是一个工具,这个工具既会被好人使用,也会被坏人使用。不要因为坏人在吹捧它,就对它充满敌意,它是无辜的。也不要因为大资本追捧,而神话它,它只是个工具。技术栈的选择历程技术栈太多了,不同的技术栈,适合不同的应用场景。就个人来讲,毕竟经验有限,很难说哪个更优。只是分享用过技术的使用体验,希望能对有些朋友多少能提供一点借鉴意义。最初重新进入开发领域,是要给公司做个CMS项目,因为看到了PHP在市场上的成功,就选择了PHP + Laravel,后来了解了VUE。在使用VUE的过程里,非常喜欢组件的概念。就萌生了用VUE+Laravel做一个低代码平台的想法。做低代码平台梦想的种子,或许就在这个时候已经种下了。页面表单输入的、请求接收到的、跟存到数据库的往往是同样的数据,却要在3个地方处理3遍,添加或者修改一个字段,就要3处代码全部改一遍。基于对这用冗余工作的厌恶,当时用PHP做了一个简易低代码框架:通过PHP函数构造前端页面的JSON描述,同时可以绑定字段数据。前端做了一个VUE渲染引擎,用于渲染后端传来的JSON。用这样的方式,虽然冗余代码问题解决了,结构却不合理。页面跟业务数据耦合太紧密。虽然作为业务程序员,技术水平一般,但是愿意折腾,愿意分享。疫情期间做了一个小的HMTL可视化编辑的小玩意,无意间竟然登上了知乎的热搜,由此认识了很多朋友。跟朋友交流多了,很多新的想法跟着进来了,知道可以把界面的描述不用PHP代码生成,直接把描述JSON写在数据库里。非常感谢当时提供这个思路的网友“冲动”。这时候的技术栈是:PHP + Laravel + Vue。设计思路是,通过可视化拖拽的方式构建前端JSON描述,把这些描述存在数据库里,做一个专门的渲染引擎,渲染这些界面,并绑定数据。目标是做一个不需要代码的前端,具体后端怎么实现,并没有考虑太多。一个人做开源,不可能所有东西都自己做,选一个成熟UI库是必要的。在还不了解什么事 material design 的情况下,误打误撞选中了 Vuetify。由于技术的不熟练,接下里在做 Vuetify 的可视化拖拽的过程里,经历了曲折的过程。有的坑是因为自己水平太菜,有的坑则是技术栈选择的问题。在处理拖拽事件的时候,使用 Vuetify 的方式总感觉不是特别自然,总觉得应该有更顺畅的方式。不是功能上实现不了,而是总觉得别扭。另外,对Vue的 slot 也有些使用不习惯。在这样的情况下,决定去了解React。看了一遍 React 文档,就被折服了。原来十几年前,只是书本上谈论的编程思想,已经被人实践出来变成了产品。作为没有任何约束的自由开发者,已经不可能再回到 Vue 了,注定要在 React 的路上走下去。既然选中了 React,那么 TypeScript 顺便一起学,也就顺理成章了。使用一个陌生的东西,不可能结构设计很合理,给自己定的目标就是先完成功能,然后再重构优化代码。边学习,边制作,跌跌撞撞完成了第一版可视化前端。技术栈是:TypeSctipt + React + Redux + Material ui。第一版完成,就迫不及待挂出演示,在几个论坛发了一下,反响还不错,虽然自己知道还差很多。接下来将近一年的时间里,都是不断重构折腾的过程。第一版跟后端通讯的接口是 Web api,用 mockjs 做的演示数据。这时间点,网友“灵活的胖子”给自己推荐了 mobx 跟 GraphQL,作为一个自由开发者,尝试几项新的技术,并不是困难的事情。使用 GraphQL 和 mobx 对前端重构,自然而然也就发生了。目前的演示版本,就是基于这两项技术重构后的版本。mobx 的优势不言而喻,虽然很多朋友不喜欢,觉得跟 React 的理念不搭,但对我来说不是障碍。mobx是从低代码界的扛把子项目mendix发展出来的,对于低代码项目是非常友好的。在使用的过程中,mobx 用起来还是非常舒服的。但是,说起 GraphQL,可就一言难尽了。后端的抉择:代码生成还是实时运行?前端完成,后端的实现面临两个方向:代码生成跟实时运行。代码生成技术已经发展多年,实现起来最为简单,却鲜有成功案例。大厂们开发出来的基于代码生成的IDE,大都化成了时代灰尘,被人遗忘在某些角落里。做一个精悍的、开箱即用的实时后端,无疑是自己最希望完成的作品。可是,现有的开源库,除了 hasura,跟 GraphQL 相关的,大都基于代码生成。它们可以成为开发者的优秀工具,却很难成为低代码平台的首选。作为团队只有一个人的业余爱好者,只能融入一个开源生态,是没有精力什么都自己做的。目前,几乎没有什么时间跟精力,开发一个跟 Hasure 类似的 GraphQL 服务端。只能暂时放弃 GraphQL,改用传统 Web API。到目前为止后端的实现为 JSON API + 指令的方式。演示已经可以运行,文档也已初步完成。自己心里很清楚,就这样放弃 GraphQL,很不甘心,说不定以后的某一天,还会再回来。后端技术栈的选择后端技术,一直是倾向于 PHP 生态的。使用 GraphQL 的时候,就计划好了,Laravel + Lighthouse。钟情于PHP的原因有三个:前 Web 时代 PHP 的成功;自己知识的匮乏,不了解太多新的技术,毕竟离开行业太久了;解释语言对热拔插友好,适合低代码项目。在使用 Lighthouse 过程里,感觉上总有些不顺畅,最后还是被朋友劝退了,放弃 PHP,在 Java 跟 TypeScript 两个里面选一个。选择Java,是不需要有任何顾虑的,毕竟成熟又成功。但是,还是想尝试一下 TypeScript,希它能够带来更多的可能。rxDrag的目标是中小型项目,相信 TypeScript 足以胜任。目标执行语言是js,是一种解释语言,热加载友好,可以使用JS生态圈的东西。使用一段时间之后,发现 TypeScript 的开发效率要比 PHP 高好多,一句话:TypeScript真香。到目前为止,后端技术栈:TypeScript,nestjs,TypeORM。前端技术栈:TypeScript,React,Mobx,Material ui。前后端都有演示可以运行。对技术栈选择的思考前一段时间,读高瓴资本创始人张磊的《价值》(应该是他说的,不是特别确定),他表达了这样一个观点,基于经济学的比较优势原理,接入全球统一的大市场,是一个国家发展的必要条件,中国近40年的快速发展,也是受益于改革开放,接入全球市场。同样的道理,可以拿到技术栈的选择上来。选择技术栈的时候,尽可能接入大的生态圈子,短期商业项目可能并看不出什么优势,长期来看,接入更大生态的项目会走的更远。低代码平台的重心在哪里开发 rxDrag 的前端项目DragIt,大约用了1年的时间,后端项目 rxModels 大约2个月。前后端完成以后,最深刻的感悟就是,低代码项目的重心应该放在后端。这想法与随处可见的拖拽低代码,显得有点格格不入。只需要静静坐下来,回顾一下这些年的发展,会发现后端的发展速度是要比前端慢的。Java全家桶发展了近20年,整体思路变化不大,前端却是飞速变化着。这意味着,同时开发出前端跟后端两个项目,后端生命周期大概率会长于前端。不管前端跟后端,围绕的核心都是数据,数据管好了,可以衍生很多前端应用。个人建议,把管理重点放在靠近数据的后端部分。rxDrag项目里,它的后端部分 rxModels 也是整个项目的核心。rxDrag低代码平台rxDrag力图构建一个全栈低代码生态圈,它的核心就是后端的对象管理模块 rxModels。目前包含这些内容:rxModels 是一个对象管理服务器,通过绘制ER图,就可以实时构建一个可以运行的后端。提供通用JSON接口,用于操作服务端数据,并且可以通过指令扩展接口。内置了基于角色的细粒度权限管理。项目地址:https://github.com/rxdrag/rx-modelsrxmodles-swr 一套React钩子,辅助跟服务端 rxModels 通信。项目地址:https://github.com/rxdrag/rxmodels-swrDragIt 可视化低代码前端。项目地址:https://github.com/rxdrag/dragit还有一个从 DragIt 分离出来的,不依赖具体 UI 库的拖拽框架,现在还么想好叫什么名字。下一篇文章内容分享 rxDrag 后端 rxModels 的开发实践
2021年09月