開源低代碼平臺開發實踐二:從 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演示

元數據定義

元數據(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,核心代碼是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,
    }
  }
}

依此類推,可以做出 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<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 構建一個可視化低代碼前端》,估計要等一段時間了,要先把前端重構完。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章