開源低代碼平臺開發(fā)實踐二:從 0 構建一個基于 ER 圖的低代碼后端

前后端分離了诉探!

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

前端都出去搞 SPA肾胯,SEO 們同意嗎竖席?

后來,SSR 來了敬肚。

他說:“SEO 們同意了毕荐!”

任何人的反對,都沒用了艳馒,時代變了憎亚。

各種各樣的 SPA 們都來了,還有穿著跟 SPA 們一樣衣服的各種小程序們弄慰。

為他們做點什么吧第美?于是 rxModels 誕生了,作為一個不希望被拋棄的后端陆爽,它希望能以更便捷的方式服務前端什往。

順便把如何設計制作也分享出來吧,說不定會有一些借鑒意義慌闭。即便有不合理的地方别威,也會有人友善的指出來。

保持開放驴剔,付出與接受會同時發(fā)生兔港,是雙向受益的一個過程。

rxModels 是什么衫樊?

一個款開源、通用利花、低代碼后端科侈。

使用 rxModels,只需要繪制 ER 圖就可以定制一個開箱即用的后端炒事。提供粒度精確到字段的權限管理功能臀栈,并對實例級別的權限管理提供表達式支持。

主要模塊有:圖形化的實體挠乳、關系管理界面( rx-models Client)权薯,通用JSON格式的數(shù)據操作接口服務( rx-models ),前端調用輔助 Hooks 庫( rxmodels-swr )等睡扬。

rxModels 基于 TypeScript盟蚣,NestJS,TypeORM 和 Antv x6 實現(xiàn)卖怜。

TypeScript 的強類型支持屎开,可以把一些錯誤在編譯時就解決掉了,IDE有了強類型的支持马靠,可以自動引入依賴奄抽,提高了開發(fā)效率蔼两,節(jié)省了時間。

TypeScript 編譯以后的目標執(zhí)行碼時JS逞度,一種運行時解釋語言额划,這個特性賦予了 rxModels 動態(tài)發(fā)布實體和熱加載 指令 的能力。用戶可以使用 指令 實現(xiàn)業(yè)務邏輯档泽,擴展通用 JSON 數(shù)據接口锁孟。給 rxModels 增加了更多使用場景。

NestJS 有助于代碼的組織茁瘦,使其擁有一個良好的架構品抽。

TypeORM 是一款輕量級 ORM 庫,可以把對象模型映射到關系數(shù)據庫甜熔。它能夠 “分離實體定義”圆恤,傳入 JSON 描述就可以構建數(shù)據庫,并對數(shù)據庫提供面向對象的查詢支持腔稀。得益于這個特性盆昙,圖形化的業(yè)務模型轉換成數(shù)據庫數(shù)據庫模型,rxModels 僅需要少量代碼就可以完成焊虏。

AntV X6 功能相對已經比較全面了淡喜,它支持在節(jié)點(node)里面嵌入 React組件,利用這個個性诵闭,使用它來繪制 ER 圖炼团,效果非常不錯。如果后面有時間疏尿,可以再寫一篇文章瘟芝,介紹如何使用 AntV x6繪制 ER 圖。

要想跟著本文褥琐,把這個項目一步步做出來锌俱,最好能夠提前學習一下本節(jié)提到的技術棧。

rxModels 目標定位

主要為中小項目服務敌呈。

為什么不敢服務大項目贸宏?

真不敢,作者是業(yè)余程序員磕洪,沒有大項目相關的任何經驗吭练。

梳理數(shù)據及數(shù)據映射

先看一下演示,從直觀上知道項目的樣子:rxModels演示 褐鸥。

blog2-rxmodels-client.jpg

元數(shù)據定義

元數(shù)據(Meta)线脚,用于描述業(yè)務實體模型的數(shù)據赐稽。一部分元數(shù)據轉化成 TypeORM 實體定義叫榕,隨之生成數(shù)據庫浑侥;另一部分元數(shù)據業(yè)務模型是圖形信息,比如實體的大小跟位置晰绎,關系的位置跟形狀等寓落。

需要轉化成 TypeORM 實體定義的元數(shù)據有:

import { ColumnMeta } from "./column-meta";

/**
* 實體類型枚舉,目前僅支持普通實體跟枚舉實體荞下,
* 枚舉實體類似語法糖伶选,不映射到數(shù)據庫,
* 枚舉類型的字段映射到數(shù)據庫是string類型
*/
export enum EntityType{
  NORMAL = "Normal",
  ENUM = "Enum",
}

/**
* 實體元數(shù)據
*/
export interface EntityMeta{
  /** 唯一標識 */
  uuid: string;

  /** 實體名稱 */
  name: string;

  /** 表名尖昏,如果tableName沒有被設置仰税,會把實體名轉化成蛇形命名法,并以此當作表名 */
  tableName?: string;

  /** 實體類型 */
  entityType?: EntityType|"";

  /** 字段元數(shù)據列表 */
  columns: ColumnMeta[];

  /** 枚舉值JSON抽诉,枚舉類型實體使用陨簇,不參與數(shù)據庫映射 */
  enumValues?: any;
}

/**
* 字段類型,枚舉迹淌,目前版本僅支持這些類型河绽,后續(xù)可以擴展
*/
export enum ColumnType{

  /** 數(shù)字類型 */
  Number = 'Number',

  /** 布爾類型 */
  Boolean = 'Boolean',

  /** 字符串類型 */  
  String = 'String',

  /** 日期類型 */  
  Date = 'Date',

  /** JSON類型 */
  SimpleJson = 'simple-json',

  /** 數(shù)組類型 */
  SimpleArray = 'simple-array',

  /** 枚舉類型 */
  Enum = 'Enum'
}

/**
* 字段元數(shù)據,基本跟 TypeORM Column 對應
*/
export interface ColumnMeta{

  /** 唯一標識 */
  uuid: string;

  /** 字段名 */
  name: string;

  /** 字段類型 */
  type: ColumnType;

  /** 是否主鍵 */
  primary?: boolean;

  /** 是否自動生成 */
  generated?: boolean;

  /** 是否可空 */
  nullable?: boolean;

  /** 字段默認值 */
  default?: any;

  /** 是否唯一 */
  unique?: boolean;

  /** 是否是創(chuàng)建日期 */
  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',
}

/**
 * 關系元數(shù)據
 */
export interface RelationMeta {
  /** 唯一標識 */
  uuid: string;

  /** 關系類型 */  
  relationType: RelationType;

  /** 關系的源實體標識 */  
  sourceId: string;

  /** 關系目標實體標識 */  
  targetId: string;

  /** 源實體上的關系屬性 */  
  roleOnSource: string;

  /** 目標實體上的關系屬性  */    
  roleOnTarget: string;

  /** 擁有關系的實體ID,對應 TypeORM 的 JoinTable 或 JoinColumn */
  ownerId?: string;
}

不需要轉化成 TypeORM 實體定義的元數(shù)據有:

/**
 * 包的元數(shù)據
 */
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圖元數(shù)據
 */
export interface DiagramMeta {
  /** 唯一標識 */
  uuid: string;

  /** ER圖名稱 */
  name: string;

  /** 節(jié)點 */
  nodes: X6NodeMeta[];

  /** 關系的連線 */
  edges: X6EdgeMeta[];
}

export interface X6NodeMeta{
  /** 對應實體標識uuid */
  id: string;
  /** 節(jié)點x坐標 */
  x?: number;
  /** 節(jié)點y坐標  */
  y?: number;
  /** 節(jié)點寬度 */
  width?: number;
  /** 節(jié)點高度 */
  height?: number;
}
import { Point } from "@antv/x6";

export type RolePosition = {
  distance: number,
  offset: number,
  angle: number,
}
export interface X6EdgeMeta{
  /** 對應關系 uuid */
  id: string;

  /** 折點數(shù)據 */
  vertices?: Point.PointLike[];

  /** 源關系屬性位置標簽位置 */
  roleOnSourcePosition?: RolePosition;

  /** 目標關系屬性位置標簽位置 */
  roleOnTargetPosition?: RolePosition;
}

rxModels有一個后端服務削咆,基于這些數(shù)據構建數(shù)據庫。

rxModels有一個前端管理界面蠢笋,管理并生產這些數(shù)據拨齐。

服務端 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接口

設計一套接口值纱,規(guī)定好接口語義,就像 GraphQL 那樣坯汤。這樣做的是優(yōu)勢虐唠,就是不需要接口文檔,也不需要定義接口版本了惰聂。

接口以 JSON 為參數(shù)疆偿,返回也是 JSON 數(shù)據,可以叫 JSON 接口搓幌。

查詢接口

接口描述:

url: /get/jsonstring...
method: get
返回值:{
  data:any,
  pagination?:{
    pageSize: number,
    pageIndex: number,
    totalCount: number
  }
}

URL 長度是 2048 個字節(jié)杆故,這個長度傳遞一個查詢字符串足夠用了,在查詢接口中溉愁,可以把 JSON 查詢參數(shù)放在 URL 里反番,使用 get 方法查數(shù)據。

把 JSON 查詢參數(shù)放在 URL 里叉钥,有一個明顯的優(yōu)勢罢缸,就是客戶端可以基于 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 ..."
}

數(shù)據太多了,分頁鳖目,每頁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)": {}
}

聰明的您,可以按照這個方向领迈,對接口做進一步的設計更改彻磁。

@ 符號后面的碍沐,稱之為 指令

把業(yè)務邏輯放在指令里衷蜓,可以對接口進行非常靈活的擴展累提。比如在文章內容(content)底部附加加一個版權聲明,可以定義一個 @addCopyRight 指令:

{
  "entity": "Post",
  "@addCopyRight": "content"
}

或者:

{
  "entity @addCopyRight(content)": "Post"
}

指令看起來是不是像一個插件恍箭?

既然是個插件刻恭,那就賦予它熱加載的能力瞧省!

通過管理界面扯夭,上傳第三方指令代碼,就可以把指令插入系統(tǒng)鞍匾。

第一版不支持指令上傳功能交洗,但是架構設計已經預留了這個能力,只是配套的界面沒做橡淑。

post 接口

接口描述:

url: /post
method: post
參數(shù): JSON
返回值: 操作成功的對象

通過post方法构拳,傳入JSON數(shù)據。

預期post接口具備這樣的能力梁棠,傳入一組對象組合(或者說附帶關系約束的對象樹)置森,直接把這組對象同步到數(shù)據庫。

如果給對象提供了id字段符糊,則更新已有對象凫海,沒有提供id字段,則創(chuàng)建新對象男娄。

post接口示例

上傳一篇文章行贪,帶圖片關聯(lián),可以這么寫:

{
  "Post": {
    "title": "輕輕的模闲,我走了",
    "content": "...",
    // 作者關聯(lián) id
    "author": 1,
    // 圖片關聯(lián) 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字段尸折,是更新數(shù)據庫的操作啰脚,第二篇文章沒有id字段,是創(chuàng)建新的实夹。

也可以傳入多個實體的實例拣播,類似這樣,同時傳入文章(Post)跟媒體(Media)的實例:

{
  "Post": [
    {
      ...
    },
    {
      ...
    }
  ],
  "Media": [
    {
      ...
    }
  ]
}

可以把關聯(lián)一并傳入收擦,如果一篇文章關聯(lián)一個 SeoMeta 對象贮配,創(chuàng)建文章時,一并創(chuàng)建 SeoMeta:

{
  "Post": {
    "title": "輕輕的塞赂,我走了",
    "content": "...",
    "author": 1,
    "medias":[3, 5, 6 ...],
    "seoMeta":{
      "title": "詩篇解讀:輕輕的泪勒,我走了|詩篇解讀網",
      "descript": "...",
      "keywords": "詩篇,解讀,詩篇解讀"
    }
  }
}

傳入這個參數(shù)圆存,會同時創(chuàng)建兩個對象叼旋,并在它們之間建立關聯(lián)。

正常情況下刪除這個關聯(lián)沦辙,可以這樣寫:

{
  "Post": {
    "title": "輕輕的夫植,我走了",
    "content": "...",
    "author": 1,
    "medias":[3, 5, 6 ...],
    "seoMeta":null
  }
}

這樣的方式保存文章,會刪除跟 SeoMeta 的關聯(lián)油讯,但是 SeoMeta 的對象并沒有被刪除详民。別的文章也不需要這個 SeoMeta,不主動刪除它陌兑,數(shù)據庫里就會生成一條垃圾數(shù)據沈跨。

保存文章的時候,添加一個 @cascade 指令兔综,能解決這個問題:

{
  "Post @cascade(medias)": {
    "title": "輕輕的饿凛,我走了",
    "content": "...",
    "author": 1,
    "medias":[3, 5, 6 ...],
    "seoMeta":null
  }
}

@cascade 指令會級聯(lián)刪除與之關聯(lián)的 SeoMeta 對象。

這個指令能放在關聯(lián)屬性上软驰,寫成這樣嗎涧窒?

{
  "Post": {
    "title": "輕輕的锭亏,我走了",
    "content": "...",
    "author": 1,
    "medias @cascade":[3, 5, 6 ...],
    "seoMeta":null
  }
}

最好不要這樣寫纠吴,客戶端用起來不會很方便。

自定義指令可以擴展post接口贰镣,比如呜象,要加一個發(fā)送郵件的業(yè)務,可以開發(fā)一個 @sendEmail 指令:

{
  "Post @sendEmail(title, content, water@rxdrag.com)": {
    "title": "輕輕的碑隆,我走了",
    "content": "...",
    "author": 1,
    "medias @cascade":[3, 5, 6 ...],
  }
}

假設每次保存文章成功后恭陡,sendEmail 指令都會把標題跟內容,發(fā)送到指定郵箱上煤。

update 接口

接口描述:

url: /update
method: post
參數(shù): JSON
返回值: 操作成功的對象

post 接口已經具備了 update 功能了休玩,為什么還要再做一個 update 接口?

有時候劫狠,需要一個批量修改一個或者幾個字段的能力拴疤,比如把指定的消息標記為已讀。

為了應對這樣的場景独泞,設計了 update 接口呐矾。假如,要所有文章的狀態(tài)更新為“已發(fā)布”:

{
  "Post": {
    "status": "published",
    "@ids":[3, 5, 6 ...],
  }
}

基于安全方面的考慮懦砂,接口不提供條件指令蜒犯,只提供 @ids 指令(遺留原因组橄,演示版不需要@符號,直接寫 ids 就行罚随,后面會修改)玉工。

delete 接口

接口描述:

url: /delete
method: post
參數(shù): JSON
返回值: 被刪除的對象

delete 接口跟 update 接口一樣,不提供條件指令淘菩,只接受 id 或者 id 數(shù)組骨望。

要刪除文章涤伐,只需要這么寫:

{
  "Post": [3, 5, ...]
}

這樣的刪除蜻牢,跟 update 一樣罪既,也不會刪除跟文章相關的對象,級聯(lián)刪除的話需要指令 @cascade进陡。

級聯(lián)刪除 SeoMeta愿阐,這么寫:

{
  "Post @cascade(seoMeta)": [3, 5, ...]
}

upload 接口

url: /upload
method: post
參數(shù): FormData
headers: {"Content-Type": "multipart/form-data;boundary=..."}
返回值: 上傳成功后生成RxMedia對象

rxModels 最好提供在線文件管理服務功能微服,跟第三方的對象管理服務趾疚,比如騰訊云、阿里云以蕴、七牛什么的糙麦,結合起來。

第一版先不實現(xiàn)跟第三方對象管理的整合丛肮,文件存在本地赡磅,文件類型僅支持圖片。

用實體 RxMedia 管理這些上傳的文件宝与,客戶端創(chuàng)建FormData焚廊,設置如下參數(shù):

{
   "entity": "RxMedia",
   "file": ...,
   "name": "文件名"
   }

全部JSON接口介紹完了,接下就是如何實現(xiàn)并使用這些接口习劫。

繼續(xù)之前咆瘟,說一下為什么選用JSON,而不用其他方式诽里。

為什么不用 oData

開始這個項目的時候袒餐,對 oData 并不了解。

簡單查了點資料谤狡,說是灸眼,只有在需要Open Data(開放數(shù)據給其他組織)時候,才有必要按照OData協(xié)議設計RESTful API墓懂。

如果不是把數(shù)據開放給其他組織焰宣,引入 oData 增加了發(fā)雜度。需要開發(fā)解析oData參數(shù)解析引擎捕仔。

oData 出了很長時間匕积,并沒有多么流行佛嬉,還不如后來的 GraphQL 知名度高。

為什么不用 GraphQL闸天?

嘗試過暖呕,沒用起來。

一個人苞氮,做開源項目湾揽,只能接入現(xiàn)有的開源生態(tài)。一個人什么都做笼吟,是不可能完成的任務库物。

要用GraphQL,只能用現(xiàn)有的開源庫〈铮現(xiàn)有的主流 GraphQL 開源庫戚揭,大部分都是基于代碼生成的。前一篇文章說過撵枢,不想做一個基于代碼生成的低代碼項目民晒。

還有一個原因,目標定位是中小項目锄禽。GraphQL對這些中小項目來說潜必,有兩個問題:1、有些笨重沃但;2磁滚、用戶的學習成本高。

有的小項目就三五個頁面宵晚,拉一個輕便的小后端垂攘,很短時間就搭起來了,沒有必要用 GraphQL淤刃。

GraphQL的學習成本并不低晒他,有些中小項目的用戶是不愿意付出這些學習成本的。

綜合這些因素钝凶,第一個版本的接口仪芒,沒有使用 GraphQL。

使用 GraphQL 的話耕陷,需要怎么做掂名?

跟一些朋友交流的時候,有些朋友對 GraphQL 還是情有獨鐘的哟沫。并且經過幾年的發(fā)展饺蔑,GraphQL 的熱度慢慢開始上來了。

假如使用 GraphQL 做一個類似項目嗜诀,需要怎么做呢猾警?

需要自己開發(fā)一套 GraphQL 服務端孔祸,這個服務端類似 Hasura,不能用代碼生成機制发皿,使用動態(tài)運行機制崔慧。Hasura 把 GQL 編譯成 SQL,你可以選擇這樣做穴墅,也可以不選擇這樣做惶室,只要能不經過編譯過程,就把對象按照 GQL 查詢要求玄货,拉出來就行皇钞。

需要在 GraphQL 的框架下,充分考慮權限管理松捉,業(yè)務邏輯擴展和熱加載等方面夹界。這就需要對 GraphQL 有比較深入的理解。

如果要做低代碼前端隘世,那么還需要做一個特殊的前端框架可柿,像 apollo 這樣的 GraphQL 前端庫庫,并不適合做低代碼前端以舒。因為低代碼前端需要動態(tài)類型綁定趾痘,這個需求跟這些前端庫的契合慢哈,并不是特別理想蔓钟。

每一項,都需要大量時間跟精力卵贱,不是一個人能完成的工作滥沫,需要一個團隊。

或有一天键俱,有機會兰绣,作者也想進行這樣方面的嘗試。

但也未必會成功编振,GraphQL 本身并不代表什么缀辩,假如它能夠使用者帶來實實在在的好處,才是被選擇的理由踪央。

登錄驗證接口

使用 jwt 驗證機制臀玄,實現(xiàn)兩個登錄相關的接口。

url: /auth/login
method: post
參數(shù): {
  username: string,
  password: string
}
返回值:jwt token
url: /auth/me
method: get
返回值: 當前登錄用戶畅蹂,RxUser類型

這兩個接口實現(xiàn)起來健无,沒有什么難的,跟著NestJs文檔做一下就行了液斜。

元數(shù)據存儲

客戶端通過 ER 圖的形式生產的元數(shù)據累贤,存儲在數(shù)據庫叠穆,一個實體 RxPackage就夠了:

export interface RxPackage {
  /* id 數(shù)據庫主鍵 */
  id: number;

  /** 唯一標識uuid,當不同的項目之間共享元數(shù)據時臼膏,這個字段很有用 */
  uuid: string;

  /** 包名 */
  name: string;

  /** 包的所有實體元數(shù)據硼被,以JSON形式存于數(shù)據庫 */
  entities: any;

  /** 包的所有 ER 圖,以JSON形式存于數(shù)據庫 */
  diagrams?: any;

  /** 包的所有關系渗磅,以JSON形式存于數(shù)據庫 */
  relations?: any;
}

數(shù)據映射完成后祷嘶,在界面中看到的一個包的所有內容,就對應 rx_package 表的一條數(shù)據記錄夺溢。

這些數(shù)據怎么被使用呢论巍?

我們給包增加一個發(fā)布功能,如果包被發(fā)布风响,就根據這條數(shù)據庫記錄嘉汰,做一個JSON文件,放在 schemas 目錄下状勤,文件名就是 ${uuid}.json鞋怀。

服務端創(chuàng)建 TypeORM 連接時,熱加載這些JSON文件持搜,并把它們解析成 TypeORM 實體定義數(shù)據密似。

應用安裝接口

rxModels 的最終目標是,發(fā)布一個代碼包葫盼,使用者通過圖形化界面安裝即可残腌,不要接觸代碼。

blog2-install.jpg

兩頁向導贫导,即可完成安裝抛猫,需要接口:

url: install
method: post
參數(shù): {
  /** 數(shù)據庫類型 */
  type: string;

  /** 數(shù)據庫所在主機 */
  host: string;

  /** 數(shù)據庫端口 */
  port: string;

  /** 數(shù)據庫schema名 */
  database: string;

  /** 數(shù)據登錄用戶 */
  username: string;

  /** 數(shù)據庫登錄密碼 */
  password: string;

  /** 超級管理員登錄名  */
  admin: string;

  /** 超級管理員密碼 */
  adminPassword: string;

  /** 是否創(chuàng)建演示賬號 */
  withDemo: boolean;
}

還需要一個查詢是否已經安裝的接口:

url: /is-installed
method: get
返回值: {
  installed: boolean
}

只要完成這些接口,后端的功能就實現(xiàn)了孩灯,加油闺金!

架構設計

得益于 NestJs 優(yōu)雅的框架,可以把整個后端服務分為以下幾個模塊:

  • auth, 普通 NestJS module峰档,實現(xiàn)登錄驗證接口败匹。本模塊很簡單,后面不會單獨介紹了讥巡。

  • package-manage, 元數(shù)據的管理發(fā)布模塊掀亩。

  • install, 普通 NestJS module,實現(xiàn)安裝功能尚卫。

  • schema, 普通 NestJS module归榕,管理系統(tǒng)元數(shù)據,并把前面定義的格式的元數(shù)據吱涉,轉化成 TypeORM 能接受的實體定義刹泄,核心代碼是 SchemaService外里。

  • typeorm, 對 TypeORM 的封裝,提供帶有元數(shù)據定義的 Connection特石,核心代碼是TypeOrmService盅蝗,該模塊沒有 Controller。

  • magic, 項目最核心模塊姆蘸,通用JSON接口實現(xiàn)模塊墩莫。

  • directive, 指令定義模塊,定義指令功能用到的基礎類逞敷,熱加載指令狂秦,并提供指令檢索服務。

  • directives, 所有指令實現(xiàn)類推捐,系統(tǒng)從這個目錄熱加載所有指令裂问。

  • magic-meta, 解析JSON參數(shù)用到的數(shù)據格式,主要使用模塊是 magic牛柒,由于 directive 模塊也會用到這些數(shù)據堪簿,為了避免模塊之間的循環(huán)依賴,把這部分數(shù)據抽出來皮壁,單獨作為一個模塊椭更,那兩個模塊同時依賴這個模塊蛾魄。

  • entity-interface, 系統(tǒng)種子數(shù)據類型接口虑瀑,主要用于 TypeScript 編譯器的類型識別∥吠螅客戶端的代碼導出功能導出的文件缴川,直接復制過來的∶柘冢客戶端也會復制一份同樣的代碼來用。

包管理 package-manage

提供一個接口 publishPackages而线。把參數(shù)傳入的元數(shù)據铭污,發(fā)布到系統(tǒng)里,同步到數(shù)據庫模式:

  • 就是一個包一個文件膀篮,放在根目錄的 schemas 目錄下嘹狞,文件名就是包的 uuid + .json 后綴。

  • 通知 TypeORM 模塊重新創(chuàng)建數(shù)據庫連接誓竿,同時同步數(shù)據庫磅网。

安裝模塊 install

模塊內有一個種子文件 install.seed.json,里面是系統(tǒng)預置的一些實體筷屡,格式就是上文定義的元數(shù)據格式涧偷,這些數(shù)據統(tǒng)一組織在 System 包里簸喂。

客戶端沒有完成的時候,手寫了一個 ts 文件用于調試燎潮,客戶端完成以后喻鳄,直接利用包的導出功能,導出了一個 JSON 文件确封,替換了手寫的 ts 文件除呵。相當于基礎數(shù)據部分,可以自舉了爪喘。

這個模塊的核心代碼在 InstallService 里颜曾,它分步完成:

  • 把客戶端傳來的數(shù)據庫配置信息,寫入根目錄的dbconfig.json 文件秉剑。

  • install.seed.json文件里面的預定義包發(fā)布泛啸。直接調用上文說的 publishPackages 實現(xiàn)發(fā)布功能。

元數(shù)據管理模塊 schema

該模塊提供一個 Controller秃症,名叫 SchemaController候址。提供一個 get 接口 /published-schema,用于獲取已經發(fā)布的元數(shù)據信息种柑。

這些已經發(fā)布的元數(shù)據信息可以被客戶端的權限設置模塊使用岗仑,因為只有已經發(fā)布的模塊,對它設置權限才有意義聚请。低代碼可視化編輯前端荠雕,也可以利用這些信息,進行下拉選擇式的數(shù)據綁定驶赏。

核心類 SchemaService炸卑,還提供了更多的功能:

  • /schemas 目錄下,加載已經發(fā)布的元數(shù)據煤傍。

  • 把這些元數(shù)據組織成列表+樹的結構盖文,提供按名字、按UUID等方式的查詢服務蚯姆。

  • 把元數(shù)據解析成 TypeORM 能接受的實體定義 JSON五续。

封裝 TypeORM

自己寫一個 ORM 庫工作量是很大的,不得不使用現(xiàn)成的龄恋,TypeORM 是個不錯的選擇疙驾,一來,她像個年輕的姑娘郭毕,漂亮又活力四射它碎。二來,她不像 Prisma 那么臃腫。

為了迎合現(xiàn)有的 TyeORM扳肛,有些地方不得不做妥協(xié)傻挂。這種低代碼項目后端,比較理想的實現(xiàn)方式自己做一個 ORM 庫敞峭,完全根據自己的需求實現(xiàn)功能踊谋,那樣或許就有青梅竹馬的感覺了,但是需要團隊旋讹,不是一個人能完成殖蚕。

既然是一個人,那么就安心做一個人能做的事情好了沉迹。

TypeORM 只有一個入口能夠傳入實體定義睦疫,就是 createConnection。需要在這個函數(shù)調用前鞭呕,解析完元數(shù)據蛤育,分離出實體定義。這個模塊的 TypeOrmService 完成這些 connection 的管理工作葫松,依賴的 schema 模塊的 SchemaService瓦糕。

通過 TypeOrmService 可以重啟當前連接(關閉并重新創(chuàng)建),以更新數(shù)據庫定義腋么。創(chuàng)建連接的時候咕娄,使用 install 模塊創(chuàng)建的 dbconfig.json 文件獲取數(shù)據庫配置。注意珊擂,TypeORM 的 ormconfig.json 文件是沒有被使用的圣勒。

magic 模塊

在 magic 模塊,不管查詢還是更新摧扇,每一個接口實現(xiàn)的操作圣贸,都在一個完整的事務里。

難道查詢接口也要包含在一個事務里扛稽?

是的吁峻,因為有的時候查詢可能會包含一些簡單操作數(shù)據庫的指令,比如查詢一篇文章的時候庇绽,順便把它的閱讀次數(shù) +1锡搜。

magic 模塊的增刪查改等操作,都受到權限的約束瞧掺,把它的核心模塊 MagicInstanceService 傳遞給指令,指令代碼里可以放心使用它的接口操作數(shù)據庫凡傅,不需要關心權限問題辟狈。

MagicInstanceService

MagicInstanceService 是接口 MagicService 的實現(xiàn)。接口定義:

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 直接調用這個類,實現(xiàn)上文定義的接口哼转。

AbilityService

權限管理類明未,查詢當前登錄用戶的實體跟字段的權限配置。

query

/magic/query 目錄壹蔓,實現(xiàn) /get/json... 接口的代碼趟妥。

MagicQuery 是核心代碼,實現(xiàn)查詢業(yè)務邏輯佣蓉。它使用 MagicQueryParser 把傳入的 JSON 參數(shù)披摄,解析成一棵數(shù)據樹,并分離相關指令勇凭。數(shù)據結構定義在 /magic-meta/query 目錄疚膊。代碼量太大,沒有精力一一解析虾标。自己翻閱一下寓盗,有問題可以跟作者聯(lián)系。

需要特別注意的是 parseWhereSql 函數(shù)璧函。這個函數(shù)負責解析類似 SQL Where 格式的語句傀蚌,使用了開源庫 sql-where-parser

把它放在這個目錄蘸吓,是因為 magic 模塊需要用到它善炫,同時 directive 模塊也需要用到它,為了避免模塊的循環(huán)依賴美澳,把它獨立抽到這個目錄销部。

/magic/query/traverser 目錄存放一些遍歷器,用于處理解析后的樹形數(shù)據制跟。

MagicQuery 使用 TypeORM 的 QueryBuilder 構建查詢舅桩。關鍵點:

  • 使用 directive 模塊的 QueryDirectiveService 獲取指令處理類。指令處理類可以:1雨膨、構建 QueryBuilder 用到的條件語句擂涛,2、過濾查詢結果聊记。

  • AbilityService 拿到權限配置撒妈,根據權限配置修改 QueryBuilder, 根據權限配置過濾查詢結果中的字段排监。

  • QueryBuilder 用到的查詢語句分兩部分:1狰右、影響查詢結果數(shù)量的語句,比如 take 指令舆床、paginate指令棋蚌。這些指令只是要截取指令數(shù)量的結果嫁佳;2、其他沒有這種影響的查詢語句谷暮。因為分頁時蒿往,需要返回一個總的記錄條數(shù),用第二類查詢語句先查一次數(shù)據庫湿弦,獲得總條數(shù)瓤漏,然后加入第一類查詢語句獲得查詢結果。

post

/magic/post 目錄颊埃,實現(xiàn) /post 接口的代碼蔬充。

MagicPost 類是核心代碼,實現(xiàn)業(yè)務邏輯竟秫。它使用 MagicPostParser 把傳入的JSON參數(shù)娃惯,解析成一棵數(shù)據樹,并分離相關指令肥败。數(shù)據結構定義在 /magic-meta/post 目錄趾浅。它可以:

  • 遞歸保存關聯(lián)對象,理論上可以無限嵌套馒稍。

  • 根據 AbilityService 做權限檢查皿哨。

  • 使用 directive 模塊的 PostDirectiveService 獲取指令處理類, 在實例保存前跟保存后會調用指令處理程序纽谒,詳情請翻閱代碼证膨。

update

/magic/update 目錄,實現(xiàn) /update 接口的代碼鼓黔。

功能簡單央勒,代碼也簡單。

delete

/magic/delete 目錄澳化,實現(xiàn) /delete 接口的代碼崔步。

功能簡單,代碼也簡單缎谷。

upload

/magic/upload 目錄井濒,實現(xiàn) /upload 接口的代碼。

upload 目前功能比較簡單列林,后面可以考添加一些裁剪指令等功能瑞你。

directive 模塊

指令服務模塊。熱加載指令希痴,并對這些指令提供查詢服務者甲。

這個模塊也比較簡單,熱加載使用的是 require 語句砌创。

關于后端过牙,其它模塊就沒什么好說的甥厦,都很簡單纺铭,直接看一下代碼就好寇钉。

客戶端 rx-models-client

需要一個客戶端,管理生產并管理元數(shù)據舶赔,測試通用數(shù)據查詢接口扫倡,設置實體權限,安裝等竟纳。創(chuàng)建一個普通的 React 項目撵溃, 支持 TypeScript。

npx create-react-app rx-models-client--template typescript

這個項目已經完成了锥累,在GitHub上缘挑,代碼地址:https://github.com/rxdrag/rx-models-client

代碼量有點多桶略,全部在這里展開解釋语淘,有點放不下。只能挑關鍵點說一下际歼,有問題需要交流的話惶翻,請跟作者聯(lián)系。

ER圖 - 圖形化的業(yè)務模型

這個模塊是客戶端的核心鹅心,看起來比較唬人吕粗,其實一點都不難。目錄 src/components/entity-board下旭愧,是該模塊全部代碼颅筋。

得益于 Antv X6,使得這個模塊的制作比預想簡單了許多输枯。

X6 充當?shù)慕巧楸茫皇且粋€視圖層。它只負責渲染實體圖形跟關系連線用押,并傳回一些用戶交互事件肢簿。它用于撤銷、重做的操作歷史功能蜻拨,在這個項目里用不上池充,只能全部自己寫。

Mobx 在這個模塊也占非常重要的地位缎讼,它管理了所有的狀態(tài)并承擔了部分業(yè)務邏輯收夸。低代碼跟拖拽類項目,Mobx 確實非常好用血崭,值得推薦卧惜。

定義 Mobx Observable 數(shù)據

上文定義的元數(shù)據厘灼,每一個對應一個 Mobx Observable 類座掘,再加一個根索引類绰寞,這數(shù)據相互包含池凄,構成一個樹形結構蛔垢,在 src/components/entity-board/store 目錄下邓馒。

  • EntityBoardStore, 處于樹形結構的根節(jié)點棘街,也是該模塊的整體狀態(tài)數(shù)據怕篷,它記錄下面這些信息:
export class EntityBoardStore{
  /**
   * 是否有修改捐川,用于未保存提示
   */
  changed = false;

  /**
   * 所有的包
   */
  packages: PackageStore[];

  /**
   * 當前正在打開的 ER 圖
   */
  openedDiagram?: DiagramStore;

  /**
   * 當前使用的 X6 Graph對象
   */
  graph?: Graph;

  /**
   * 工具條上的關系被按下钻洒,記錄具體類型
   */
  pressedLineType?: RelationType;

  /**
   * 處在鼠標拖動劃線的狀態(tài)
   */
  drawingLine: LineAction | undefined;

  /**
   * 被選中的節(jié)點
   */
  selectedElement: SelectedNode;

  /**
   * Command 模式奋姿,撤銷列表
   */
  undoList: Array<Command> = [];

  /**
   * Command 模式,重做列表
   */
  redoList: Array<Command> = [];

  /**
   * 構造函數(shù)傳入包元數(shù)據素标,會自動解析成一棵 Mobx Observable 樹
   */
  constructor(packageMetas:PackageMeta[]) {
    this.packages = packageMetas.map(
      packageMeta=> new PackageStore(packageMeta,this)
    );
    makeAutoObservable(this);
  }
  
  /**
   * 后面大量的set方法称诗,就不需要了展開了
   */
  ...

}
  • PackageStore, 樹形完全跟上文定義的 PackageMeta 一致,區(qū)別就是 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 逆向轉成元數(shù)據的方法头遭,用于往后端發(fā)送數(shù)據
   */
  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,
    }
  }
}

依此類推寓免,可以做出 EntityStoreColumnStore任岸、RelationStoreDiagramStore再榄。

前面定義的 X6NodeMetaX6EdgeMeta 不需要制作相應的 store 類,因為沒法通過 Mobx 的機制更新 X6 的視圖享潜,要用其它方式完成這個工作困鸥。

DiagramStore 主要為展示 ER 圖提供數(shù)據。給它添加兩個方法:

export type NodeConfig = X6NodeMeta & {data: EntityNodeData};
export type EdgeConfig = X6EdgeMeta & RelationMeta;

export class DiagramStore {
  ...

  /**
   * 獲取當前 ER 圖所有的節(jié)點剑按,利用 mobx 更新機制疾就,
   * 只要數(shù)據有更改,調用該方法的視圖會自動被更新艺蝴,
   * 參數(shù)只是用了指示當前選中的節(jié)點猬腰,或者是否需要連線,
   * 這些狀態(tài)會影響視圖猜敢,可以在這里直接傳遞給每個節(jié)點
   */
  getNodes(
    selectedId:string|undefined, 
    isPressedRelation:boolean|undefined
  ): NodeConfig[]

  /**
   * 獲取當前 ER 圖所有的連線姑荷,利用 mobx 更新機制,
   * 只要數(shù)據有更改缩擂,調用該方法的視圖會自動被更新
   */
  getAndMakeEdges(): EdgeConfig[]

}

如何使用 Mobx Observable 數(shù)據

使用 React 的 Context鼠冕,把上面定義的 store 數(shù)據傳遞給子組件。

定義 Context:

export const EnityContext = createContext<EntityBoardStore>({} as EntityBoardStore);
export const EntityStoreProvider = EnityContext.Provider;
export const useEntityBoardStore = (): EntityBoardStore => useContext(EnityContext);

創(chuàng)建 Context:

...
const [modelStore, setModelStore] = useState(new EntityBoardStore([]));

...
  return (
    <EntityStoreProvider value = {modelStore}>
      ...
    </EntityStoreProvider>
  )

使用的時候胯盯,直接在子組件里調用 const rootStore = useEntityBoardStore() 就可以拿到數(shù)據了懈费。

樹形編輯器

利用 Mui的樹形控件 + Mobx 對象,代碼并不復雜博脑,感興趣的話憎乙,翻翻看看票罐,有疑問留言或者聯(lián)系作者。

如何使用 AntV X6

X6 支持在節(jié)點里嵌入 React 組件泞边,定義一個組件 EntityView 嵌入進去就好该押。X6 相關代碼都在這個目錄下:

src/componets/entity-board/grahp-canvas

業(yè)務邏輯被拆分成很多 React Hooks:

  • useEdgeChange, 處理關系線被拖動

  • useEdgeLineDraw, 處理畫線動過

  • useEdgeSelect, 處理關系線被選中

  • useEdgesShow, 渲染關系線,包括更新

  • useGraphCreate, 創(chuàng)建 X6 的 Grpah對象

  • useNodeAdd, 處理拖入一個節(jié)點的動作

  • useNodeChange, 處理實體節(jié)點被拖動或者改變大小

  • useNodeSelect, 處理節(jié)點被選中

  • useNodesShow, 渲染實體節(jié)點繁堡,包括更新

撤銷沈善、重做

撤銷、重做不僅跟 ER 圖相關椭蹄,還跟整個 store 樹相關。這就是說净赴,X6 的撤銷绳矩、重做機制用不了,只能自己重新做玖翅。

好在設計模式中的 Command 模式還算簡單翼馆,定義一些 Command,并定義好正負操作金度,可以很容易完成应媚。實現(xiàn)代碼在:

src/componets/entity-board/command

全局狀態(tài) AppStore

按照上問的方法,利用 Mobx 做一個全局的狀態(tài)管理類 AppStore猜极,用于管理整個應用的狀態(tài)中姜,比如彈出操作成功提示,彈出錯誤信息等跟伏。

代碼在 src/store 目錄下丢胚。

接口測試

代碼在 src/components/api-board 目錄下。

很簡單一個模塊受扳,代碼應該很容易懂携龟。使用了 rxmodels-swr 庫,直接參考它的文檔就好勘高。

JSON 輸入控件峡蟋,使用了 monaco 的 react 封裝:react-monaco-editor,使用起來很簡單华望,安裝稍微麻煩一點蕊蝗,需要安裝 react-app-rewired

monaco 用的還不熟練立美,后面熟練了可以加入如下功能輸入提示和代碼校驗等功能匿又。

權限管理

代碼在 src/components/auth-board 目錄下。

這個模塊之主要是后端數(shù)據的組織跟接口定義建蹄,前端代碼很少碌更,基于rxmodels-swr 庫完成裕偿。

權限定義支持表達式,表達式類似 SQL 語句痛单,并內置了變量 $me 指代當前登錄用戶嘿棘。

前端輸入時,需要對 SQL 表達式進行校驗旭绒,所以也引入了開源庫 sql-where-parser鸟妙。

安裝、登錄

安裝代碼在 src/components/install 目錄下挥吵。

登錄頁面是 src/components/login.tsx重父。

代碼一眼就能瞅明白。

后記

這篇文章挺長的忽匈,但是還不確定有沒有把需要說的說清楚房午,有問題的話留言或者聯(lián)系作者吧。

演示能跑起來以后丹允,就已經冒著被踢的危險郭厌,在幾個 QQ 群發(fā)了一下。收到了很多反饋雕蔽,非常感謝熱心的朋友們折柠。

rxModels,終于走出去了第一步...

與前端的第一次接觸

rxModels來了批狐,熱情的走向前端們扇售。

前端們皺起了眉頭,說:“離遠點兒贾陷,你不是我們理想中的樣子缘眶。”

rxModels 說:“我還會改變髓废,還會成長巷懈,未來的某一天,我們一定是最好的搭檔慌洪《パ啵”

下一篇文章

《從 0 構建一個可視化低代碼前端》,估計要等一段時間了冈爹,要先把前端重構完涌攻。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市频伤,隨后出現(xiàn)的幾起案子恳谎,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,126評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件因痛,死亡現(xiàn)場離奇詭異婚苹,居然都是意外死亡,警方通過查閱死者的電腦和手機鸵膏,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,254評論 2 382
  • 文/潘曉璐 我一進店門膊升,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人谭企,你說我怎么就攤上這事廓译。” “怎么了债查?”我有些...
    開封第一講書人閱讀 152,445評論 0 341
  • 文/不壞的土叔 我叫張陵非区,是天一觀的道長。 經常有香客問我攀操,道長院仿,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,185評論 1 278
  • 正文 為了忘掉前任速和,我火速辦了婚禮,結果婚禮上剥汤,老公的妹妹穿的比我還像新娘颠放。我一直安慰自己,他們只是感情好吭敢,可當我...
    茶點故事閱讀 64,178評論 5 371
  • 文/花漫 我一把揭開白布碰凶。 她就那樣靜靜地躺著,像睡著了一般欲低。 火紅的嫁衣襯著肌膚如雪凄鼻。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 48,970評論 1 284
  • 那天纱控,我揣著相機與錄音喷户,去河邊找鬼期犬。 笑死,一個胖子當著我的面吹牛棉安,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 38,276評論 3 399
  • 文/蒼蘭香墨 我猛地睜開眼届囚,長吁一口氣:“原來是場噩夢啊……” “哼饺汹!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 36,927評論 0 259
  • 序言:老撾萬榮一對情侶失蹤粹污,失蹤者是張志新(化名)和其女友劉穎鸭叙,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體滤奈,經...
    沈念sama閱讀 43,400評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡领炫,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 35,883評論 2 323
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了似舵。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片脚猾。...
    茶點故事閱讀 37,997評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖砚哗,靈堂內的尸體忽然破棺而出龙助,到底是詐尸還是另有隱情,我是刑警寧澤蛛芥,帶...
    沈念sama閱讀 33,646評論 4 322
  • 正文 年R本政府宣布提鸟,位于F島的核電站,受9級特大地震影響常空,放射性物質發(fā)生泄漏沽一。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,213評論 3 307
  • 文/蒙蒙 一漓糙、第九天 我趴在偏房一處隱蔽的房頂上張望铣缠。 院中可真熱鬧,春花似錦昆禽、人聲如沸蝗蛙。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,204評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽捡硅。三九已至,卻和暖如春盗棵,著一層夾襖步出監(jiān)牢的瞬間壮韭,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,423評論 1 260
  • 我被黑心中介騙來泰國打工纹因, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留喷屋,地道東北人。 一個月前我還...
    沈念sama閱讀 45,423評論 2 352
  • 正文 我出身青樓瞭恰,卻偏偏與公主長得像屯曹,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子惊畏,可洞房花燭夜當晚...
    茶點故事閱讀 42,722評論 2 345

推薦閱讀更多精彩內容