開源低程式碼平臺開發實踐二:從 0 構建一個基於 ER 圖的低程式碼後端

悠閒的水發表於2021-07-31

前後端分離了!

第一次知道這個事情的時候,內心是困惑的。

前端都出去搞 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,
    }
  }
}

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

相關文章