前後端分離了!
第一次知道這個事情的時候,內心是困惑的。
前端都出去搞 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 編譯器的型別識別。客戶端的程式碼匯出功能匯出的檔案,直接複製過來的。客戶端也會複製一份同樣的程式碼來用。
包管理 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 說:“我還會改變,還會成長,未來的某一天,我們一定是最好的搭檔。”
下一篇文章
《從 0 構建一個視覺化低程式碼前端》,估計要等一段時間了,要先把前端重構完。