前后端分離了!
第一次知道這個事情的時候,內心是困惑的。
前端都出去搞 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 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 編譯器的類型識別??蛻舳说拇a導出功能導出的文件,直接復制過來的。客戶端也會復制一份同樣的代碼來用。
包管理 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上,代碼地址: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 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 說:“我還會改變,還會成長,未來的某一天,我們一定是最好的搭檔?!?/p>
下一篇文章
《從 0 構建一個可視化低代碼前端》,估計要等一段時間了,要先把前端重構完。