前後端分離了!
第一次知道這個事情的時候,内心是困惑的。
前端都出去搞 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, [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 的最終目标是,釋出一個代碼包,使用者通過圖形化界面安裝即可,不要接觸代碼。
兩頁向導,即可完成安裝,需要接口:
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,核心代碼是
,該子產品沒有 Controller。TypeOrmService
- magic, 項目最核心子產品,通用JSON接口實作子產品。
- directive, 指令定義子產品,定義指令功能用到的基礎類,熱加載指令,并提供指令檢索服務。
- directives, 所有指令實作類,系統從這個目錄熱加載所有指令。
- magic-meta, 解析JSON參數用到的資料格式,主要使用子產品是
,由于magic
子產品也會用到這些資料,為了避免子產品之間的循環依賴,把這部分資料抽出來,單獨作為一個子產品,那兩個子產品同時依賴這個子產品。directive
- entity-interface, 系統種子資料類型接口,主要用于 TypeScript 編譯器的類型識别。用戶端的代碼導出功能導出的檔案,直接複制過來的。用戶端也會複制一份同樣的代碼來用。
包管理 package-manage
提供一個接口
publishPackages
。把參數傳入的中繼資料,釋出到系統裡,同步到資料庫模式:
- 就是一個包一個檔案,放在根目錄的
目錄下,檔案名就是包的schemas
+ .json 字尾。uuid
- 通知 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 子產品的
擷取指令處理類。指令處理類可以:1、建構QueryDirectiveService
用到的條件語句,2、過濾查詢結果。QueryBuilder
-
拿到權限配置,根據權限配置修改AbilityService
, 根據權限配置過濾查詢結果中的字段。QueryBuilder
- QueryBuilder 用到的查詢語句分兩部分:1、影響查詢結果數量的語句,比如 take 指令、paginate指令。這些指令隻是要截取指令數量的結果;2、其他沒有這種影響的查詢語句。因為分頁時,需要傳回一個總的記錄條數,用第二類查詢語句先查一次資料庫,獲得總條數,然後加入第一類查詢語句獲得查詢結果。
post
/magic/post
/post
MagicPost
類是核心代碼,實作業務邏輯。它使用
MagicPostParser
把傳入的JSON參數,解析成一棵資料樹,并分離相關指令。資料結構定義在
/magic-meta/post
目錄。它可以:
- 遞歸儲存關聯對象,理論上可以無限嵌套。
- 根據
做權限檢查。AbilityService
-
擷取指令處理類, 在執行個體儲存前跟儲存後會調用指令處理程式,詳情請翻閱代碼。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方法,就不需要了展開了
*/
...
}
-
, 樹形完全跟上文定義的 PackageMeta 一緻,差別就是 meta 相關的全都換成了 store 相關的:PackageStore
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
-
, 建立 X6 的 Grpah對象useGraphCreate
-
, 處理拖入一個節點的動作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
這個子產品之主要是後端資料的組織跟接口定義,前端代碼很少,基于
庫完成。
權限定義支援表達式,表達式類似 SQL 語句,并内置了變量
$me
指代目前登入使用者。
前端輸入時,需要對 SQL 表達式進行校驗,是以也引入了開源庫
sql-where-parser
安裝、登入
安裝代碼在
src/components/install
登入頁面是
src/components/login.tsx
代碼一眼就能瞅明白。
後記
這篇文章挺長的,但是還不确定有沒有把需要說的說清楚,有問題的話留言或者聯系作者吧。
示範能跑起來以後,就已經冒着被踢的危險,在幾個 QQ 群發了一下。收到了很多回報,非常感謝熱心的朋友們。
rxModels,終于走出去了第一步...
與前端的第一次接觸
rxModels來了,熱情的走向前端們。
前端們皺起了眉頭,說:“離遠點兒,你不是我們理想中的樣子。”
rxModels 說:“我還會改變,還會成長,未來的某一天,我們一定是最好的搭檔。”
下一篇文章
《從 0 建構一個可視化低代碼前端》,估計要等一段時間了,要先把前端重構完。