前、後端通用的可視化邏輯編排

前一段時間寫過一篇文章《實戰,一個高擴展、可視化低代碼前端,詳實、完整》,得到了很多朋友的關注。
其中的邏輯編排部分過於簡略,不少朋友希望能寫一些關於邏輯編排的內容,本文就詳細講述一下邏輯編排的實現原理。
邏輯編排的目的,是用最少甚至不用代碼來實現軟件的業務邏輯,包括前端業務邏輯跟後端業務邏輯。本文前端代碼基於typescript、react技術棧,後端基於golang。
涵蓋內容:數據流驅動的邏輯編排原理,業務編排編輯器的實現,頁面控件聯動,前端業務邏輯與UI層的分離,子編排的複用、自定義循環等嵌入式子編排的處理、事務處理等
運行快照:
image.png
前端項目地址:https://github.com/codebdy/rxdrag
前端演示地址:https://rxdrag.vercel.app/
後端演示尚未提供,代碼地址:https://github.com/codebdy/minions-go
注:爲了便於理解,本文使用的代碼做了簡化處理,會跟實際代碼有些細節上的出入。

整體架構

image.png
整個邏輯編排,由以下幾部分組成:

  • 節點物料,用於定義編輯器中的元件,包含在工具箱中的圖標,端口以及屬性面板中的組件schema。
  • 邏輯編排編輯器,顧名思義,可視化編輯器,根據物料提供的元件信息,編輯生成JSON格式的“編排描述數據”。
  • 編排描述數據,用戶操作編輯器的生成物,供解析引擎消費
  • 前端解析引擎,Typescript 實現的解析引擎,直接解析“編排描述數據”並執行,從而實現的軟件的業務邏輯。
  • 後端解析引擎,Golang 實現的解析引擎,直接解析“編排描述數據”並執行,從而實現的軟件的業務邏輯。

邏輯編排實現方式的選擇

邏輯編排,實現方式很多,爭議也很多。
一直以來,小編的思路也很侷限。從流程圖層面,以線性的思維去思考,認爲邏輯編排的意義並不大。因爲,經過這麼多年發展,事實證明代碼纔是表達邏輯的最佳方式,沒有之一。用流程圖去表達代碼,最終只能是老闆、客戶的豐滿理想與程序員骨感現實的對決。
直到看到Mybricks項目交互部分的實現方式,纔打開了思路。類似unreal藍圖數據流驅動的實現方式,其實大有可爲。
這種方式的意義是,跳出循環、if等這些底層的代碼細節,以數據流轉的方式思考業務邏輯,從而把業務邏輯抽象爲可複用的組件,每個組件對數據進行相應處理或者根據數據執行相應動作,從而達到複用業務邏輯的目的。並且,節點的粒度可大可小,非常靈活。
具體實現方式是,把每個邏輯組件看成一個黑盒,通過入端口流入數據,出端口流出變換後的數據:
image.png
舉個例子,一個節點用來從數據庫查詢客戶列表,會是這樣的形式:
image.png
用戶不需要關注這個元件節點的實現細節,只需要知道每個端口的功能就可以使用。這個元件節點的功能可以做的很簡單,比如一個fetch,只有幾十行代碼。也可以做到很強大,比如類似useSwr,自帶緩存跟狀態管理,可以有幾百甚至幾千行代碼。
我們希望這些元件節點是可以自行定義,方便插入的,並且我們做到了。
出端口跟入端口之間,可以用線連接,表示元件節點之間的調用關係,或者說是數據的流入關係。假如,數據讀取成功,需要顯示在列表中;失敗,提示錯誤消息;查詢時,顯示等待的Spinning,那麼就可以再加三個元件節點,變成:
image.png
如果用流程圖,上面這個編排,會被顯示成如下樣子:
image.png
兩個比較,就會發現,流程圖的思考方式,會把人引入條件細節,其實就是試圖用不擅長代碼的圖形來描述代碼。是純線性的,沒有回調,也就無法實現類似js promise的異步。
而數據流驅動的邏輯編排,可以把人從細節中解放出來,用模塊化的思考方式去設計業務邏輯,更方便把業務邏輯拆成一個個可複用的單元。
如果以程序員的角度來比喻,流程圖相當於一段代碼腳本,是面向過程的;數據流驅動的邏輯編排像是幾個類交互完成一個功能,更有點面向對象的感覺。
朋友,如果是讓你選,你喜歡哪種方式?歡迎留言討論。
另外還有一種類似stratch的實現方式:
image.png
感覺這種純粹爲了可視化而可視化,只適合小孩子做玩具。會寫代碼的人不願意用,太低效了。不會寫代碼的人,需要理解代碼纔會用。適合場景是用直觀的方式介紹什麼是代碼邏輯,就是說只適合相對比較低智力水平的編程教學,比如幼兒園、小學等。商業應用,就免了。

數據流驅動的邏輯編排

一個簡單的例子

從現在開始,放下流程圖,忘記strach,我們從業務角度去思考也邏輯,然後設計元件節點去實現相應的邏輯。
選一個簡單又典型的例子:學生成績單。一個成績單包含如下數據:
學生成績單
假如數據已經從數據庫取出來了,第一步處理,統計每個學生的總分數。設計這麼幾個元件節點來配合完成:
image.png
這個編排,輸入成績列表,循環輸出每個學生的總成績。爲了完成這個編排,設計了四個元件節點:

  • 循環,入端口接收一個列表,遍歷列表並循環輸出,每一次遍歷往“單次輸出”端口發送一條數據,可以理解爲一個學生對象(儘量從對象的角度思考,而不是數據記錄),遍歷結束後往“結束端口”發送循環的總數。如果按照上面的列表,“單次輸出端口”會被調用4次,每次輸出一個學生對象{姓名:xxx,語文:xxx,數學:xxx...},“結束”端口只被調用一次,輸出結果是 4.
  • 拆分對象,這個元件節點的出端口是可以動態配置的,它的功能是把一個對象按照屬性值按照名字分發到指定的出端口。本例中,就是把各科成績拆分開來。
  • 收集數組,這個節點也可以叫收集到數組,作用是把串行接收到的數據組合到一個數組裏。他有兩個入端口:input端口,用來接收串行輸入,並緩存到數組;finished端口,表示輸入完成,把緩存到的數據組發送給輸出端口。
  • 加和,把輸入端口傳來的數組進行加和計算,輸出總數。

這是一種跟代碼完全不同的思考方式,每一個元件節點,就是一小段業務邏輯,也就是所謂的業務邏輯組件化。我們的項目中,只提供給了有限的預定義元件節點,想要更多的節點,可以自行自定義並注入系統,具體設計什麼樣的節點,完全取決於用戶的業務需求跟喜好。作者更希望設計元件的過程是一個創作的過程,或許具備一定的藝術性。
剛剛的例子,審視之。有人可能會換一個方式來實現,比如拆分對象跟收集數據這兩個節點,合併成一個節點:對象轉數組,可能更方便,適應能力也更強:
image.png
對象轉換數組節點,對象屬性與數組索引的對應關係,可以通過屬性面板的配置來完成。
這兩種實現方式,說不清哪種更好,選擇自己喜歡的,或者兩種都提供。

輸入節點、輸出節點

一段圖形化的邏輯編排,通過解析引擎,會被轉換成一段可執行的業務邏輯。這段業務邏輯需要跟外部對接,爲了明確對接語義,再添加兩個特殊的節點元件:輸入節點(開始節點),輸出節點(結束節點)。
image.png
輸入節點用於標識邏輯編排的入口,輸入節點可以有一個或者多個,輸入節點用細線圓圈表示。
輸出節點用於標識邏輯編排的出口,輸出節點可以有一個或者多個,輸出節點用粗線圓圈表示。
在後面的引擎部分,會詳細描述輸入跟輸出節點如何跟外部的對接。

編排的複用:子編排

一般低代碼中,提升效率的方式是複用,儘可能複用已有的東西,比如組件、業務邏輯,從而達到降本、增效的目的。
設計元件節點是一種創作,那麼使用元件節點進行業務編排,更是一種基於領域的創作。辛辛苦苦創作的編排,如果能被複用,應該算是對創作本身的尊重吧。
如果編排能夠像元件節點一樣,被其它邏輯編排所引用,那麼這樣的複用方式無疑是最融洽的。也是最方便的實現方式。
把能夠被其它編排引用的編排稱爲子編排,上面計算學生總成績的編排,轉換成子編排,被引入時的形態應該是這樣的:
image.png
子編排元件的輸入端口對應邏輯編排實現的輸入節點,輸出端口對應編排實現的輸出節點。

嵌入式編排節點

前文設計的循環組件非常簡單,循環直接執行到底,不能被中斷。但是,有的時候,在處理數據的時候,要根據每次遍歷到的數據做判斷,來決定繼續循環還是終止循環。
就是說,需要一個循環節點,能夠自定義它的處理流程。依據這個需求,設計了自定義循環元件,這是一種能夠嵌入編排的節點,形式如下:
image.png
這種嵌入式編排節點,跟其它元件節點一樣,事先定義好輸入節點跟輸出節點。只是它不完全是黑盒,其中一部分通過邏輯編排這種白盒方式來實現。
這種場景並不多見,除了循環,後端應用中,還有事務元件也需要類似實現方式:
image.png
嵌入式元件跟其它元件節點一樣,可以被其它元件連接,嵌入式節點在整個編排中的表現形式:
image.png

基本概念

爲了進一步深入邏輯編排引擎跟編輯器的實現原理,先梳理一些基本的名詞、概念。
邏輯編排,本文特指數據流驅動的邏輯編排,是由圖形表示的一段業務邏輯,由元件節點跟連線組成。
元件節點,簡稱元件、節點、編排元件、編排單元。邏輯編排中具體的業務邏輯處理單元,帶副作用的,可以實現數據轉換、頁面組件操作、數據庫數據存取等功能。一個節點包含零個或多個輸入端口,包含零個或多個輸出端口。在設計其中,以圓角方形表示:
image.png
端口,分爲輸入端口跟輸出端口兩種。是元件節點流入或流出數據的通道(或者接口)。在邏輯單元中,用小圓圈表示。
輸入端口,簡稱入端口、入口。輸入端口位於元件節點的左側。
輸出端口,簡稱出端口、出口。輸出端口位於元件節點的右側。
單入口元件,只有一個入端口的元件節點。
多入口元件,有多個入端口的元件節點。
單出口元件,只有一個出端口的元件節點。
多出口元件,有多個出端口的元件節點。
輸入節點,一種特殊的元件節點,用於描述邏輯編排的起點(開始點)。轉換成子編排後,會對應子編排相應的入端口。
輸出節點,一種特殊的元件節點,用於描述邏輯編排的終點(結束點)。轉換成子編排後,會對應子編排相應的出端口。
嵌入式編排,特殊的元件節點,內部實現由邏輯編排完成。示例:
image.png
子編排,特殊的邏輯編排,該編排可以轉換成元件節點,供其它邏輯編排使用。
連接線,簡稱連線、線。用來連接各個元件節點,表示數據的流動關係。

定義DSL

邏輯編排編輯器生成一份JSON,解析引擎解析這份JSON,把圖形化的業務邏輯轉化成可執行的邏輯,並執行。
編輯器跟解析引擎之間要有份約束協議,用來約定JSON的定義,這個協議就是這裏定義的DSL。在typescript中,用interface、enum等元素來表示。
這些DSL僅僅是用來描述頁面上的圖形元素,通過activityName屬性跟具體的實現代碼邏輯關聯起來。比如一個循環節點,它的actvityName是Loop,解析引擎會根據Loop這個名字找到該節點對應的實現類,並實例化爲一個可執行對象。後面的解析引擎會詳細展開描述這部分。

節點類型

元件節點類型叫NodeType,用來區分不同類型的節點,在TypeScript中是一個枚舉類型。

export enum NodeType {
  //開始節點
  Start = 'Start',
  //結束節點
  End = 'End',
  //普通節點
  Activity = 'Activity',
  //子編排,對其它編排的引用
  LogicFlowActivity = "LogicFlowActivity",
  //嵌入式節點,比如自定義邏輯編排
  EmbeddedFlow = "EmbeddedFlow"
}

端口

export interface IPortDefine {
  //唯一標識
  id: string;
  //端口名詞
  name: string;
  //顯示文本
  label?: string;
}

元件節點

//一段邏輯編排數據
export interface ILogicFlowMetas {
  //所有節點
  nodes: INodeDefine<unknown>[];
  //所有連線
  lines: ILineDefine[];
}
export interface INodeDefine<ConfigMeta = unknown> {
  //唯一標識
  id: string;
  //節點名稱,一般用於開始結束、節點,轉換後對應子編排的端口
  name?: string;
  //節點類型
  type: NodeType;
  //活動名稱,解析引擎用,通過該名稱,查找構造節點的具體運行實現
  activityName: string;
  //顯示文本
  label?: string;
  //節點配置
  config?: ConfigMeta;
  //輸入端口
  inPorts?: IPortDefine[];
  //輸出端口
  outPorts?: IPortDefine[];
  //父節點,嵌入子編排用
  parentId?: string;
  // 子節點,嵌入編排用
  children?: ILogicFlowMetas
}

連接線

//連線接頭
export interface IPortRefDefine {
  //節點Id
  nodeId: string;
  //端口Id
  portId?: string;
}

//連線定義
export interface ILineDefine {
  //唯一標識
  id: string;
  //起點
  source: IPortRefDefine;
  //終點
  target: IPortRefDefine;
}

邏輯編排

//這個代碼上面出現過,爲了使extends更直觀,再出現一次
//一段邏輯編排數據
export interface ILogicFlowMetas {
  //所有節點
  nodes: INodeDefine<unknown>[];
  //所有連線
  lines: ILineDefine[];
}
//邏輯編排
export interface ILogicFlowDefine extends ILogicFlowMetas {
  //唯一標識
  id: string;
  //名稱
  name?: string;
  //顯示文本
  label?: string;
}

解析引擎的實現

解析引擎有兩份實現:Typescript實現跟Golang實現。這裏介紹基於原理,以Typescript實現爲準,後面單獨章節介紹Golang的實現方式。也有朋友根據這個dsl實現了C#版自用,歡迎朋友們實現不同的語言版本並開源。
DSL只是描述了節點跟節點之間的連接關係,業務邏輯的實現,一點都沒有涉及。需要爲每個元件節點製作一個單獨的處理類,才能正常解析運行。比如上文中的循環節點,它的DSL應該是這樣的:

{
  "id": "id-1",
  "type": "Activity",
  "activityName": "Loop",
  "label": "循環",
  "inPorts": [
    {
      "id":"port-id-1",
      "name":"input",
      "label":""
    }
  ],
  "outPorts": [
    {
      "id":"port-id-2",
      "name":"output",
      "label":"單次輸出"
    },
    {
      "id":"port-id-3",
      "name":"finished",
      "label":"結束"
    }
  ]
}

開發人員製作一個處理類LoopActivity用來處理循環節點的業務邏輯,並將這個類註冊入解析引擎,key爲loop。這個類,我們叫做活動(Activity)。解析引擎,根據activityName查找類,並創建實例。LoopActivity的類實現應該是這樣:

export interface IActivity{
  inputHandler (inputValue?: unknown, portName:string);
}
export class LoopActivity implements IActivity{
  constructor(protected meta: INodeDefine<ILoopConfig>) {}
  //輸入處理
  inputHandler (inputValue?: unknown, portName:string){
    if(portName !== "input"){
      console.error("輸入端口名稱不正確")
      return      
    }
    let count = 0
    if (!_.isArray(inputValue)) {
      console.error("循環的輸入值不是數組")
    } else {
      for (const one of inputValue) {
        this.output(one)
        count++
      }
    }
    //輸出循環次數
    this.next(count, "finished")
  }
  //單次輸出
  output(value: unknown){
    this.next(value, "output")
  }
  
  next(value:unknown, portName:string){
     //把數據輸出到指定端口,這裏需要解析器注入代碼
  }
}

解析引擎根據DSL,調用inputHanlder,把控制權交給LoopActivity的對象,LoopActivity處理完成後把數據通過next方法傳遞出去。它只需要關注自身的業務邏輯就可以了。
這裏難點是,引擎如何讓所有類似LoopActivity類的對象聯動起來。這個實現是邏輯編排的核心,雖然實現代碼只有幾百行,但是很繞,需要靜下心來好好研讀接下來的部分。

編排引擎的設計

編排引擎類圖

類圖1
LogicFlow類,代表一個完整的邏輯編排。它解析一張邏輯編排圖,並執行該圖所代表的邏輯。
IActivity接口,一個元件節點的執行邏輯。不同的邏輯節點,實現不同的Activity類,這類都實現IActivity接口。比如循環元件,可以實現爲

export class LoopActivity implements IActivity{
    id: string
    config: LoopActivityConfig
}

LogicFlow類解析邏輯編排圖時,根據解析到的元件節點,創建相應的IActivity實例,比如解析到Loop節點的時候,就創建LoopActivity實例。
LogicFlow還有一個功能,就是根據連線,給構建的IActivity實例建立連接關係,讓數據能在不同的IActivity實例之間流轉。先明白引擎中的數據流,是理解上述類圖的前提。

解析引擎中的Jointer

在解析引擎中,數據按照以下路徑流動:
連接器間的數據流轉
有三個節點:節點A、節點B、節點C。數據從節點A的“a-in-1”端口流入,通過一些處理後,從節點A的“a-out-1”端口流出。在“a-out-1”端口,把數據分發到節點B的“b-in-1”端口跟節點C的“c-in-1”端口。在B、C節點以後,繼續重複類似的流動。
端口“a-out-1”要把數據分發到端口“b-in-1”和端口“c-in-1”,那麼端口“a-out-1”要保存端口“b-in-1”和端口“c-in-1”的引用。就是說在解析引擎中,端口要建模爲一個類,端口“a-out-1”是這個類的對象。要想分發數據,端口類跟自身是一個聚合關係。這種關係,讓解析引擎中的端口看起來像連接器,故取名Jointer。一個Joniter實例,對應一個元件節點的端口。
在邏輯編排圖中,一個端口,可以連接多個其它端口。所以,一個Jointer也可以連接多個其它Jointer。
連接器的包含關係
注意,這是實例的關係,如果對應到類圖,就是這樣的關係:
類圖2
Jointer通過調用push方法把數據傳遞給其他Jointer實例。
connect方法用於給兩個Joiner構建連接關係。
用TypeScript實現的話,代碼是這樣的:

//數據推送接口
export type InputHandler = (inputValue: unknown, context?:unknown) => void;
export interface IJointer {
  name: string;
  //接收上一級Jointer推送來的數據
  push: InputHandler;
  //添加下游Jointer
  connect: (jointerInput: InputHandler) => void;
}

export class Jointer implements IJointer {
  //下游Jonter的數據接收函數
  private outlets: IJointer[] = []

  constructor(public id: string, public name: string) {
  }

  //接收上游數據,並分發到下游
  push: InputHandler = (inputValue?: unknown, context?:unknown) => {
    for (const jointer of this.outlets) {
      //推送數據
      jointer.push(inputValue, context)
    }
  }

  //添加下游Joninter
  connect = (jointer: IJointer) => {
    //往數組加數據,跟上面的push不一樣
    this.outlets.push(jointer)
  }

  //刪除下游Jointer
  disconnect = (jointer: InputHandler) => {
    this.outlets.splice(this.outlets.indexOf(jointer), 1)
  }
}

在TypeScript跟Golang中,函數是一等公民。但是在類圖裏面,這個獨立的一等公民是不好表述的。所以,上面的代碼只是對類圖的簡單翻譯。在實現時,Jointer的outlets可以不存IJointer的實例,只存Jointer的push方法,這樣的實現更靈活,並且更容易把一個邏輯編排轉成一個元件節點,優化後的代碼:


//數據推送接口
export type InputHandler = (inputValue: unknown, context?:unknown) => void;

export interface IJointer {
  //當key使用,不參與業務邏輯
  id: string;
  name: string;
  //接收上一級Jointer推送來的數據
  push: InputHandler;
  //添加下游Jointer
  connect: (jointerInput: InputHandler) => void;
}

export class Jointer implements IJointer {
  //下游Jonter的數據接收函數
  private outlets: InputHandler[] = []

  constructor(public id: string, public name: string) {
  }

  //接收上游數據,並分發到下游
  push: InputHandler = (inputValue?: unknown, context?:unknown) => {
    for (const jointerInput of this.outlets) {
      jointerInput(inputValue, context)
    }
  }

  //添加下游Joninter
  connect = (inputHandler: InputHandler) => {
    this.outlets.push(inputHandler)
  }

  //刪除下游Jointer
  disconnect = (jointer: InputHandler) => {
    this.outlets.splice(this.outlets.indexOf(jointer), 1)
  }
}

記住這裏的優化:Jointer的下游已經不是Jointer了,是Jointer的push方法,也可以是獨立的其它方法,只要參數跟返回值跟Jointer的push方法一樣就行,都是InputHandler類型。這個優化,可以讓把Activer的某個處理函數設置爲入Jointer的下游,後面會有進一步介紹。

Activity與Jointer的關係

一個元件節點包含多個(或零個)入端口和多個(或零個)出端口。那麼意味着一個IActivity實例包含多個Jointer,這些Jointer也按照輸入跟輸出來分組:
類圖3
TypeScript定義的代碼如下:

export interface IActivityJointers {
  //入端口對應的連接器
  inputs: IJointer[];
  //處端口對應的連接器
  outputs: IJointer[];

  //通過端口名獲取出連接器
  getOutput(name: string): IJointer | undefined
  //通過端口名獲取入連接器
  getInput(name: string): IJointer | undefined
}

//活動接口,一個實例對應編排圖一個元件節點,用於實現元件節點的業務邏輯
export interface IActivity<ConfigMeta = unknown> {
  id: string;
  //連接器,跟元件節點的端口異議對應
  jointers: IActivityJointers,
  //元件節點配置,每個Activity的配置都不一樣,故而用泛型
  config?: ConfigMeta;
  //銷燬
  destory(): void;
}

入端口掛接業務邏輯

入端口對應一個Jointer,這個Jointer的連接關係:
image.png
邏輯引擎在解析編排圖元件時,會給每一個元件端口創建一個Jointer實例:

 //構造Jointers
 for (const out of activityMeta.outPorts || []) {
   //出端口對應的Jointer
   activity.jointers.outputs.push(new Jointer(out.id, out.name))
 }
 for (const input of activityMeta.inPorts || []) {
   //入端口對應的Jointer
   activity.jointers.inputs.push(new Jointer(input.id, input.name))
 }

新創建的Jointer,它的下游是空的,就是說成員變量的outlets數組是空的,並沒有掛接到真實的業務處理。要調用Jointer的connect方法,把Activity的處理函數作爲下游連接過去。
最先想到的實現方式是Acitvity有一個inputHandler方法,根據端口名字分發數據到相應處理函數:

export interface IActivity<ConfigMeta = unknown> {
  id: string;
  //連接器,跟元件節點的端口異議對應
  jointers: IActivityJointers,
  //元件節點配置,每個Activity的配置都不一樣,故而用泛型
  config?: ConfigMeta;
  //入口處理函數
  inputHandler(portName:string, inputValue: unknown, context?:unknown):void
  //銷燬
  destory(): void;
}

export abstract class SomeActivity implements IActivity<SomeConfigMeta> {
  id: string;
  jointers: IActivityJointers;
  config?: SomeConfigMeta;
  constructor(public meta: INodeDefine<ConfigMeta>) {
    this.id = meta.id
    this.jointers = new ActivityJointers()
    this.config = meta.config;
  }

  //入口處理函數
  inputHandler(portName:string, inputValue: unknown, context?:unknown){
    switch(portName){
      case PORTNAME1:
        port1Handler(inputValue, context)
        break
      case PORTNAME2:
        ...
        break
      ...
    }
  }

  //端口1處理函數
  port1Handler = (inputValue: unknown, context?:unknown)=>{
    ...
  }
  
  destory = () => {
    //銷燬處理
    ...
  }
}

LogicFlow解析編排JSON,碰到SomeActivity對應的元件時,如下處理:

//創建SomeActivity實例
const someNode = new SomeActivity(meta)

 //構造Jointers
 for (const out of activityMeta.outPorts || []) {
   //出端口對應的Jointer
   activity.jointers.outputs.push(new Jointer(out.id, out.name))
 }
 for (const input of activityMeta.inPorts || []) {
   //入端口對應的Jointer
   const jointer = new Jointer(input.id, input.name)
   activity.jointers.inputs.push(jointer)
   //給入口對應的連接器,掛接輸入處理函數
   jointer.connect(someNode.inputHandler)
 }

業務邏輯掛接到出端口

入口處理函數,處理完數據以後,需要調用出端口連接器的push方法,把數據分發出去:
image.png
具體實現代碼:

export abstract class SomeActivity implements IActivity<SomeConfigMeta> {
  jointers: IActivityJointers;
  ...
  //入口處理函數
  inputHandler(portName:string, inputValue: unknown, context?:unknown){
    switch(portName){
      case PORTNAME1:
        port1Handler(inputValue, context)
        break
      case PORTNAME2:
        ...
        break
      ...
    }
  }

  //端口1處理函數
  port1Handler = (inputValue: unknown, context?:unknown)=>{
    ...
    //處理後得到新的值:newInputValue 和新的context:newContext
    //把數據分發到相應出口
    this.jointers.getOutput(somePortName).push(newInputValue, newContext)
  }
  ...
}

入端口跟出端口,連貫起來,一個Activtity內部的流程就跑通了:
image.png

出端口掛接其它元件節點

入端口關聯的是Activity的自身處理函數,出端口關聯的是外部處理函數,這些外部處理函數有能是其它連接器(Jointer)的push方法,也可能來源於其它跟應用對接的部分。
如果是關聯的是其他節點的Jointer,關聯關係是通過邏輯編排圖中的連線定義的。
image.png
解析器先構造完所有的節點,然後遍歷一遍連線,調用連線源Jointer的conect方法,參數是目標Jointer的push,就把關聯關係構建起來了:

    for (const lineMeta of this.flowMeta.lines) {
      //先找起始節點,這個後面會詳細介紹,現在可以先忽略
      let sourceJointer = this.jointers.inputs.find(jointer => jointer.id === lineMeta.source.nodeId)
      if (!sourceJointer && lineMeta.source.portId) {
        sourceJointer = this.activities.find(reaction => reaction.id === lineMeta.source.nodeId)?.jointers?.outputs.find(output => output.id === lineMeta.source.portId)
      }
      if (!sourceJointer) {
        throw new Error("Can find source jointer")
      }

      //先找起終止點,這個後面會詳細介紹,現在可以先忽略
      let targetJointer = this.jointers.outputs.find(jointer => jointer.id === lineMeta.target.nodeId)
      if (!targetJointer && lineMeta.target.portId) {
        targetJointer = this.activities.find(reaction => reaction.id === lineMeta.target.nodeId)?.jointers?.inputs.find(input => input.id === lineMeta.target.portId)
      }

      if (!targetJointer) {
        throw new Error("Can find target jointer")
      }

      //重點關注這裏,把一條連線的首尾相連,構造起連接關係
      sourceJointer.connect(targetJointer.push)
    }

特殊的元件節點:開始節點、結束節點

到目前爲止,解析引擎部分,已經能夠成功解析普通的元件併成功連線,但是一個編排的入口跟出口尚未處理,對應的是編排圖的輸入節點(開始節點)跟輸出節點(結束節點)
image.png
這兩個節點,沒有任何業務邏輯,只是輔助把外部輸入,連接到內部的元件;或者把內部的輸出,發送給外部。所以,這兩個節點,只是簡單的Jointer就夠了。
如果把一個邏輯編排看作一個元件節點:
image.png
輸入元件節點對應的是輸入端口,輸出元件節點對應的是輸出端口。既然邏輯編排也有自己端口,那麼LogicFlow也要聚合ActivityJointers:
類圖4
引擎解析的時候,要根據開始元件節點跟結束元件節點,構建LogicFlow的Jointer:

export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];

  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析節點
    this.constructActivities()
    ...
  }

  //構建一個圖的所有節點
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
        case NodeType.Start:
          //start只有一個端口,可能會變成其它流程的端口,所以name謹慎處理
          this.jointers.inputs.push(new Jointer(activityMeta.id, activityMeta.name || "input"));
          break;
        case NodeType.End:
          //end 只有一個端口,可能會變成其它流程的端口,所以name謹慎處理
          this.jointers.outputs.push(new Jointer(activityMeta.id, activityMeta.name || "output"));
          break;
      }
      ...
    }
  }
}

經過這樣的處理,一個邏輯編排就可以變成一個元件節點,被其他邏輯編排所引用,具體實現細節,本文後面再展開敘述。

根據元件節點創建Activity實例

在邏輯編排圖中,一種類型的元件節點,在解析引擎中會對應一個實現了IActivity接口的類。比如,循環節點,對應LoopActivity;條件節點,對應ConditionActivity;調試節點,對應DebugActivity;拆分對象節點,對應SplitObjectActivity。
這些Activity要跟具體的元件節點建立一一對應關係,在DSL中以activityName作爲關聯樞紐。這樣解析引擎根據activityName查找相應的Activity類,並創建實例。

工廠方法

如何找到並創建節點單元對應的Activity實例呢?最簡單的實現方法,是給每個Activity類實現一個工廠方法,建立一個activityName跟工廠方法的映射map,解析引擎根據這個map實例化相應的Activity。簡易代碼:

//工廠方法的類型定義
export type ActivityFactory = (meta:ILogiFlowDefine)=>IActivity

//activityName跟工廠方法的映射map
export const activitiesMap:{[activityName:string]:ActivityFactory} = {}

export class LoopActivity implements IActivity{
  ...
  constructor(protected meta:ILogiFlowDefine){}
  inputHandler=(portName:string, inputValue:unknown, context:unknown)=>{
    if(portName === "input"){
      //邏輯處理
      ...
    }
  }
  ...
}

//LoopActivity的工廠方法
export const LoopActivityFactory:ActivityFactory = (meta:ILogiFlowDefine)=>{
  return new LoopActivity(meta)
}

//把工廠方法註冊進map,跟循環節點的activityName對應好
activitiesMap["loop"] = LoopActivityFactory

//LogicFlow的解析代碼
export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];

  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析節點
    this.constructActivities()
    ...
  }

  //構建一個圖的所有節點
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
      	...
        case NodeType.Activity:
          //查找元件節點對應的ActivityFactory
          const activityFactory = activitiesMap[activityMeta.activityName]
          if(activityFactory){
            //創建Activity實例
            this.activities.push(activityFactory(activityMeta))
          }else{
            //提示錯誤
          }
          break;
      }
      ...
    }
  }
}

引入反射

正常情況下,上面的實現方法,已經夠用了。但是,作爲一款開放軟件,會有大量的自定義Activity的需求。上面的實現方式,會讓Activity的實現代碼略顯繁瑣,並且所有的輸入端口都要通過switch判斷轉發到相應處理函數。
我們希望把這部分工作推到框架層做,讓具體Activity的實現更簡單。所以,引入了Typescipt的反射機制:註解。通過註解自動註冊Activity類,通過註解直接關聯端口與相應的處理函數,省去switch代碼。
代碼經過改造以後,就變成這樣:

//通過註解註冊LoopActivity類
@Activity("loop")
export class LoopActivity implements IActivity{
  ...
  constructor(protected meta:ILogiFlowDefine){}
  //通過註解把input端口跟該處理函數關聯
  @Input("input")
  inputHandler=(inputValue:unknown, context:unknown)=>{
    //邏輯處理
    ...
  }
  ...
}


//LogicFlow的解析代碼
export class LogicFlow {
  id: string;
  jointers: IActivityJointers = new ActivityJointers();
  activities: IActivity[] = [];

  constructor(private flowMeta: ILogicFlowDefine) {
  	...
    //第一步,解析節點
    this.constructActivities()
    ...
  }

  //構建一個圖的所有節點
  private constructActivities() {
    for (const activityMeta of this.flowMeta.nodes) {
      switch (activityMeta.type) {
      	...
        case NodeType.Activity:
          //根據反射拿到Activity的構造函數
          const activityContructor = ...//此處是反射代碼
          if(activityContructor){
            //創建Activity實例
            this.activities.push(activityContructor(activityMeta))
          }else{
            //提示錯誤
          }
          break;
      }
      ...
    }
  }
}

LogicFlow是框架層代碼,用戶不需要關心具體的實現細節。LoopActivity的代碼實現,明顯簡潔了不少。
Input註解接受一個參數作爲端口名稱,參數默認值是input。
還有一種節點,它的輸入端口是不固定的,可以動態增加或者刪除。比如:
image.png
合併節點就是動態入口的節點,它的功能是接收入口傳來的數據,等所有數據到齊以後,合併成一個對象轉發到輸出端口。這個節點,有異步等待的功能。
爲了處理這種節點,我們引入新的註解DynamicInput。實際項目中合併節點Activity的完整實現:


import {
  AbstractActivity,
  Activity,
  DynamicInput
} from '@rxdrag/minions-runtime';
import { INodeDefine } from '@rxdrag/minions-schema';

@Activity(MergeActivity.NAME)
export class MergeActivity extends AbstractActivity<unknown> {
  public static NAME = 'system.merge';
  private noPassInputs: string[] = [];
  private values: { [key: string]: unknown } = {};

  constructor(meta: INodeDefine<unknown>) {
    super(meta);
    this.resetNoPassInputs();
  }

  @DynamicInput
  inputHandler = (inputName: string, inputValue: unknown) => {
    this.values[inputName] = inputValue;、
    //刪掉已經收到數據的端口名
    this.noPassInputs = this.noPassInputs.filter(name=>name !== inputName)
    if (this.noPassInputs.length === 0) {
      //next方法,把數據轉發到指定出口,第二個參數是端口名,默認值input
      this.next(this.values);
      this.resetNoPassInputs();
    }
  };

  resetNoPassInputs(){
    for (const input of this.meta.inPorts || []) {
      this.noPassInputs.push(input.name);
    }
  }
}

註解DynamicInput不需要綁定固定的端口,所以就不需要輸入端口的名稱。

子編排的解析

子編排就是一段完整的邏輯編排,跟普通的邏輯編排沒有任何區別。只是它需要被其它編排引入,這個引入是通過附加一個Activity實現的。

export interface ISubLogicFLowConfig {
  logicFlowId?: string
}

export interface ISubMetasContext{
  subMetas:ILogicFlowDefine[]
}

@Activity(SubLogicFlowActivity.NAME)
export class SubLogicFlowActivity implements IActivity {
  public static NAME = "system-react.subLogicFlow"
  id: string;
  jointers: IActivityJointers;
  config?: ISubLogicFLowConfig;
  logicFlow?: LogicFlow;

  //context可以從引擎外部注入的,此處不必糾結它是怎麼來的這個細節
  constructor(meta: INodeDefine<ISubLogicFLowConfig>, context: ISubMetasContext) {
    this.id = meta.id
    //通過配置中的LogicFlowId,查找子編排對應的JSON數據
    const defineMeta = context?.subMetas?.find(subMeta => subMeta.id === meta.config?.logicFlowId)
    if (defineMeta) {
      //解析邏輯編排,new LogicFlow 就是解析一段邏輯編排,也可以在別處被調用
      this.logicFlow = new LogicFlow(defineMeta, context)
      //把解析後的連接器對應到本Activity
      this.jointers = this.logicFlow.jointers
    } else {
      throw new Error("No meta on sub logicflow")
    }
  }
  
  destory(): void {
    this.logicFlow?.destory();
    this.logicFlow = undefined;
  }
}

因爲不需要把端口綁定到相應的處理函數,故該Activity並沒有使用Input相關注解。

嵌入式編排的解析

邏輯編排中,最複雜的部分,就是嵌入式編排的解析,希望小編能解釋清楚。
再看一遍嵌入式編排的表現形式:
image.png
這是自定義循環節點。雖然它端口直接跟內部的編排節點相連,但是實際上這種情況是無法直接調用new LogicFlow 來解析內部邏輯編排的,需要進行轉換。引擎解析的時候,把會把上面的子編排重組成如下形式:
image.png
首先,給子編排添加輸入節點,名稱跟ID分別對應自定義循環的入端口名稱跟ID;添加輸出節點,名稱跟ID分別對應自定義循環的出端口名稱跟ID。
然後,把一個圖中的紅色數字標註的連線,替換成第二個圖中藍色數字標註的連線。
容器節點的端口,並不會跟轉換後的輸入節點或者輸出節點直接連接,而是在實現中根據業務邏輯適時調用,故用粗虛線表示。
自定義循環具體實現代碼:

import { AbstractActivity, Activity, Input, LogicFlow } from "@rxdrag/minions-runtime";
import { INodeDefine } from "@rxdrag/minions-schema";
import _ from "lodash"

export interface IcustomizedLoopConifg {
  fromInput?: boolean,
  times?: number
}

@Activity(CustomizedLoop.NAME)
export class CustomizedLoop extends AbstractActivity<IcustomizedLoopConifg> {
  public static NAME = "system.customizedLoop"
  public static PORT_INPUT = "input"
  public static PORT_OUTPUT = "output"
  public static PORT_FINISHED = "finished"
  
  finished = false

  logicFlow?: LogicFlow;

  constructor(meta: INodeDefine<IcustomizedLoopConifg>) {
    super(meta)
    if (meta.children) {
      //通過portId關聯子流程的開始跟結束節點,端口號對應節點號
      //此處的children是被引擎轉換過處理的
      this.logicFlow = new LogicFlow({ ...meta.children, id: meta.id }, undefined)

      //把子編排的出口,掛接到本地處理函數
      const outputPortMeta = this.meta.outPorts?.find(
        port=>port.name === CustomizedLoop.PORT_OUTPUT
      )
      if(outputPortMeta?.id){
        this.logicFlow?.jointers?.getOutput(outputPortMeta?.name)?.connect(
          this.oneOutputHandler
        )
      }else{
        console.error("No output port in CustomizedLoop")
      }

      const finishedPortMeta = this.meta.outPorts?.find(
        port=>port.name === CustomizedLoop.PORT_FINISHED
      )
      if(finishedPortMeta?.id){
        this.logicFlow?.jointers?.getOutput(finishedPortMeta?.id)?.connect(
          this.finisedHandler
        )
      }else{
        console.error("No finished port in CustomizedLoop")
      }
      
    } else {
      throw new Error("No implement on CustomizedLoop meta")
    }
  }

  @Input()
  inputHandler = (inputValue?: unknown, context?:unknown) => {
    let count = 0
    if (this.meta.config?.fromInput) {
      if (!_.isArray(inputValue)) {
        console.error("Loop input is not array")
      } else {
        for (const one of inputValue) {
          //轉發輸入到子編排
          this.getInput()?.push(one, context)
          count++
          //如果子編排調用了結束
          if(this.finished){
            break
          }
        }
      }
    } else if (_.isNumber(this.meta.config?.times)) {
      for (let i = 0; i < (this.meta.config?.times || 0); i++) {
        //轉發輸入到子編排
        this.getInput()?.push(, context)
        count++
        //如果子編排調用了結束
        if(this.finished){
          break
        }
      }
    }
    //如果子編排中還沒有被調用過finished
    if(!this.finished){
      this.next(count, CustomizedLoop.PORT_FINISHED, context)
    }
  }

  getInput(){
    return this.logicFlow?.jointers?.getInput(CustomizedLoop.PORT_INPUT)
  }

  oneOutputHandler = (value: unknown, context?:unknown)=>{
    //輸出到響應端口
    this.output(value, context)
  }

  finisedHandler = (value: unknown, context?:unknown)=>{
    //標識已調用過finished
    this.finished = true
    //輸出到響應端口
    this.next(value, CustomizedLoop.PORT_FINISHED, context)
  }

  output = (value: unknown, context?:unknown) => {
    this.next(value, CustomizedLoop.PORT_OUTPUT, context)
  }
}

基礎的邏輯編排引擎,基本全部介紹完了,清楚了節點之間的編排機制,是時候定義節點的連線規則了。

節點的連線規則

一個節點,是一個對象。有狀態,有副作用。有狀態的對象沒有約束的互連,是非常危險的行爲。
這種情況會面臨一個誘惑,或者說用戶自己也分不清楚。就是把節點當成無狀態對象使用,或者直接認爲節點就是無狀態的,不加限制的把連線連到某個節點的入口上。
比如上面計算學生總分例子,可能會被糊塗的用戶連成這樣:
image.png
這種連接方式,直接造成收集數組節點無法正常工作。
邏輯編排之所以直觀,在於它把每一個個數據流通的路徑都展示出來了。在一個通路上的一個節點,最好只完成一個該通路的功能。另一個通路如果想完成同樣的功能,最好再新建一個對象:
image.png
這樣兩個收集數組節點,就互不干擾了。
要實現這樣的約束,只需要加一個連線規則:同一個入端口,只能連一條線
有了這條規則,節點對象狀態帶來的不利影響,基本消除了。
在這樣的規則下,收集數組節點的入口不能連接多條連線,只需要把它重新設計成如下形式:
image.png
一個出端口,可以往外連接多條連線,用於表示並行執行。另一條規則就是:同一個出端口,可以有多條連線
數據是從左往右流動,所以再加上最後一條規則:入端口在節點左側,出端口在節點右側
所有的連線規則完成了,蠻簡單的,編輯器層面可以直接做約束,防止用戶輸錯。

編輯器的實現

編輯器佈局

image.png
整個編輯器分爲圖中標註的四個區域。

  • ① 工具欄,編輯器常規操作,比如撤銷、重做、刪除等。
  • ② 工具箱(物料箱),存放可以被拖放的元件物料,這些物料是可以從外部注入到編輯器的。
  • ③ 畫布區,繪製邏輯編排圖的畫布。每個節點都有自己的座標,要基於這個對DSL進行擴展,給節點附加座標信息。畫布基於阿里antv X6實現。
  • ④ 屬性面板,編輯元件節點的配置信息。物料是從編輯器外部注入的,物料對應節點的配置是變化的,所以屬性面板內的組件也是變化的,使用RxDrag的低代碼渲染引擎來實現,外部注入的物料要寫到相應的Schema信息。低代碼Schema相關內容,請參考另一篇文章《實戰,一個高擴展、可視化低代碼前端,詳實、完整

擴展DSL

前面定義的DSL用在邏輯編排解析引擎裏,足夠了。但是,在畫布上展示,還缺少節點位置跟尺寸信息。設計器畫布是基於X6實現的,要添加X6需要的信息,來擴展DSL:

export interface IX6NodeDefine {
  /** 節點x座標 */
  x: number;
  /** 節點y座標  */
  y: number;
  /** 節點寬度 */
  width: number;
  /** 節點高度 */
  height: number;
}
// 擴展後節點
export interface IActivityNode extends INodeDefine {
  x6Node?: IX6NodeDefine
}

這些信息,足以在畫布上展示一個完整的邏輯編排圖了。

元件物料定義

工具箱區域②跟畫布區域③顯示節點時,使用了共同的元素:元件圖標,元件標題,圖標顏色,這些可以放在物料的定義裏。
物料還需要:元件對應的Acitvity名字,屬性面板④ 的配置Schema。具體定義:

import { NodeType, IPortDefine } from "./dsl";

//端口定義
export interface IPorts {
  //入端口
  inPorts?: IPortDefine[];
  //出端口
  outPorts?: IPortDefine[];
}

//元件節點的物料定義
export interface IActivityMaterial<ComponentNode = unknown, NodeSchema = unknown, Config = unknown, MaterialContext = unknown> {
  //標題
  label: string;
  //節點類型,NodeType在DLS中定義,這裏根據activityType決定畫上的圖形樣式
  activityType: NodeType;
  //圖標代碼,react的話,相當於React.ReactNode
  icon?: ComponentNode;
  //圖標顏色
  color?: string;
  //屬性面板配置,可以適配不同的低代碼Schema,使用RxDrag的話,這可以是INodeSchema類型
  schema?: NodeSchema;
  //默認端口,元件節點的端口設置的默認值,大部分節點端口跟默認值是一樣的,
  //部分動態配置端口,會根據配置有所變化
  defaultPorts?: IPorts;
  //畫布中元件節點顯示的子標題 
  subTitle?: (config?: Config, context?: MaterialContext) => string | undefined;
  //對應解析引擎裏的Activity名稱,根據這個名字實例化相應的節點業務邏輯對象
  activityName: string;
}

//物料分類,用於在工具欄上,以手風琴風格分組物料
export interface ActivityMaterialCategory<ComponentNode = unknown, NodeSchema = unknown, Config = unknown, MaterialContext = unknown> {
  //分類名
  name: string;
  //分類包含的物料
  materials: IActivityMaterial<ComponentNode, NodeSchema, Config, MaterialContext>[];
}

只要符合這個定義的物料,都是可以被注入設計器的。
在前面定義DSL的時候, INodeDefine 也有一個一樣的屬性是 activityName。沒錯,這兩個activityName指代的對象是一樣的。畫布渲染dsl的時候,會根據activityName查找相應的物料,根據物料攜帶的信息展示,入圖標、顏色、屬性配置組件等。
在做前端物料跟元件的時候,爲了重構方便,會把activityName以存在Activity的static變量裏,物料定義直接引用,端口名稱也是類似的處理。看一個最簡單的節點,Debug節點的代碼。
Activity代碼:

import { Activity, Input, AbstractActivity } from "@rxdrag/minions-runtime"
import { INodeDefine } from "@rxdrag/minions-schema"

//調試節點配置
export interface IDebugConfig {
  //提示信息
  tip?: string,
  //是否已關閉
  closed?: boolean
}

@Activity(DebugActivity.NAME)
export class DebugActivity extends AbstractActivity<IDebugConfig> {
  //對應INodeDeifne 跟IActivityMaterial的 activityName
  public static NAME = "system.debug"

  constructor(meta: INodeDefine<IDebugConfig>) {
    super(meta)
  }

  //入口處理函數
  @Input()
  inputHandler(inputValue: unknown): void {
    if (!this.config?.closed) {
      console.log(`🪲${this.config?.tip || "Debug"}:`, inputValue)
    }
  }
}


物料代碼:

import { createUuid } from "@rxdrag/shared";
import { debugSchema } from "./schema";
import { NodeType } from "@rxdrag/minions-schema";
import { Debug, IDebugConfig } from "@rxdrag/minions-activities"
import { debugIcon } from "../../icons";
import { DEFAULT_INPUT_NAME } from "@rxdrag/minions-runtime";
import { IRxDragActivityMaterial } from "../../interfaces";

//debug節點物料
export const debugMaterial: IRxDragActivityMaterial<IDebugConfig> = {
  //對應Activity的Name
  activityName: Debug.NAME,
  //Svg格式的表
  icon: debugIcon,
  //顯示標題,工具欄直接多語言化後顯示,畫布上節點Title的初值是這個,可以通過
  //屬性面板修改
  label: "$debug",
  //節點類型,普通的Activity
  activityType: NodeType.Activity,
  //圖標顏色
  color: "orange",
  //默認端口
  defaultPorts: {
    inPorts: [
      {
        id: createUuid(),
        name: DEFAULT_INPUT_NAME,
        label: "",
      },
    ],
  },
  //子標題,顯示配置用的tip
  subTitle: (config?: IDebugConfig) => {
    return config?.tip
  },
  //屬性面板Schema
  schema: debugSchema,
}

屬性面板schema的配置就不展開了,感興趣的朋友請參考Rxdrag的相關文章。該節點在編輯器中的表現:
image.png

編輯器的狀態管理

如果純React應用的話,Recoil是不錯的狀態管理方案,幻想有一天可能會適配其它UI框架,就使用了對框架依賴較少的Redux作爲狀態管理工具。
編輯器所有狀態:

import { INodeDefine, ILineDefine } from "@rxdrag/minions-schema";

//操作快照
export interface ISnapshot {
  //全部節點
  nodes: INodeDefine<unknown>[];
  //全部連線
  lines: ILineDefine[];
  //當前選中元素
  selected?: string,
}

//編輯器狀態
export interface IState {
  //是否被修改,該標識用於提示是否需要保存
  changeFlag: number,
  //撤銷快照列表
  undoList: ISnapshot[],
  //重做快照列表
  redoList: ISnapshot[],
  //全部節點
  nodes: INodeDefine<unknown>[];
  //全部連線
  lines: ILineDefine[];
  //當前選中元素
  selected?: string,
  //畫布縮放數值
  zoom: number,
  //是否顯示小地圖
  showMap: boolean,
}

編輯器就是圍繞這份狀態數據做功。建一個單獨的類用來操作redux store:

//一個用來操作Redux狀態數據的類,可以修改數據,也可以訂閱數據的變化
export class EditorStore {
  store: Store<IState>
  constructor(debugMode?: boolean,) {
    this.store = makeStoreInstance(debugMode || false)
  }

  dispatch = (action: Action) => {
    this.store.dispatch(action)
  }
  //節省篇幅,本類不展開了,詳細代碼青島rxdrag代碼庫查看
  //地址:https://github.com/codebdy/rxdrag/blob/master/packages/minions/editor/logicflow-editor/src/classes/EditorStore.ts
  ...
}

編輯器接口

編輯器被設計成一個React Library,方便其它項目的引用。其接口定義如下:

//主題顏色接口
export interface IThemeToken {
  colorBorder?: string;
  colorBgContainer?: string;
  colorText?: string;
  colorTextSecondary?: string;
  colorBgBase?: string;
  colorPrimary?: string;
}

//邏輯編排編輯器屬性
export type LogicFlowEditorProps = {
  value: ILogicMetas,
  onChange?: (value: ILogicMetas) => void,
  //編輯器支持的所有物料
  materialCategories: ActivityMaterialCategory<ReactNode>[],
  //屬性面板用的的空間
  setters?: IComponents,
  logicFlowContext?: unknown,
  //可以被引用的子編排
  canBeReferencedLogflowMetas?: ILogicFlowDefine[],
  //工具欄,false表示隱藏
  toolbar?: false | React.ReactNode,
}

//邏輯編排編輯器對應的React組件
export const LogicMetaEditor = memo((
  props: LogicFlowEditorAntd5rProps&{
    token: IThemeToken,
  }
) => {
  ...
}}

深度集成的考量

編輯器有時候要跟其它編輯器,比如後端的領域模型編輯器深度集成,共享一套撤銷、重做、刪除按鈕。比如:
image.png
這種情況要通過給toolbar屬性傳入false,把編輯器原來的工具條隱藏掉。另外,要跟領域模型編輯器共用一個工具欄區域,需要在編輯器能在外部控制Redux store裏面的內容。
爲了實現這樣的集成,添加一個上下文,用於下發一個全局的EditorStore。定義一個標籤,叫LogicFlowEditorScope,用於限定邏輯編排編輯器的範圍,只要是在標籤內部,store數據可以隨意修改。

import { memo, useMemo } from "react"
import { LogicFlowEditorStoreContext } from "../contexts";
import { EditorStore } from "../classes";
import { useEditorStore } from "../hooks";

//用於創建全局EditorStore,並通過Context下發
const ScopeInner = memo((props: {
  children?: React.ReactNode
}) => {
  const { children } = props;
  const store: EditorStore = useMemo(() => {
    return new EditorStore()
  }, [])

  return (
    <LogicFlowEditorStoreContext.Provider value={store}>
        {children}
    </LogicFlowEditorStoreContext.Provider>
  )
})

//編輯器Scope定義
export const LogicFlowEditorScope = memo((
  props: {
    children?: React.ReactNode
  }
) => {
  const { children } = props;
  //去外層Store
  const parentStore = useEditorStore()

  return (
    //如果外層已經創建Scope,那麼直接用外層的,反之新建一個
    parentStore ?
      <>{children}</>
      :
      <ScopeInner>
        {children}
      </ScopeInner>
  )
})

這個LogicFlowEditorScope會在邏輯編輯器的根部放置一個,如果編輯器外部沒有定義,就是用這個默認的。如果外部已經定義了,那麼就用外部的。
領域模型編輯器集成的時候,只要在工具欄外層放置一個LogicFlowEditorScope,就可以方便的操作編輯器裏的內容了:

import { memo } from "react"
import { UmlEditorInner, UmlEditorProps } from "./UmlEditorInner"
import { RecoilRoot } from "recoil"
import { LogicFlowEditorScope } from "@rxdrag/minions-logicflow-editor"

//領域模型UML編輯器部分
export const UmlEditor = memo((props: UmlEditorProps) => {

  return <RecoilRoot>
    //外層放置邏輯編排的Scope
    <LogicFlowEditorScope>
      <UmlEditorInner {...props} />
    </LogicFlowEditorScope>
  </RecoilRoot>
})
...
//創建邏輯編排編輯起的時候,toolbar賦值false
   <LogicFlowEditorAntd5
     materialCategories={activityMaterialCategories}
     locales={activityMaterialLocales}
     token={token}
     value={value?.logicMetas || EmpertyLogic}
     logicFlowContext={logicFlowContext}
     onChange={handleChange}
     setters={{
       SubLogicFlowSelect,
     }}
     canBeReferencedLogflowMetas={canBeReferencedLogflowMetas}
     //隱藏默認工具欄
     toolbar={false}
   />
  ...

前端邏輯編排

前端邏輯編排主要編排的內容是組件的聯動,組件數據的填充以及服務端數據的獲取及存儲等內容。這些內容,就是一般被稱作業務邏輯的內容。
通常的實現方式中,這些業務邏輯會與ui組件緊密結合,甚至很多被寫在了組件內部。想讓邏輯編排的適應能力更強,最好能夠把這些內容從組件中剝離出來,形成獨立的業務層,儘可能壓縮UI層的厚度,UI層就像美女,瘦的總是比胖的好看些,如果不認同的話歡迎留言討論。
像React這樣的框架,組件有自己的生命週期管理,在低代碼項目中,如果業務邏輯跟組件攪在一起,業務邏輯就跟組件的生命週期也攪在一起了,處理這樣的代碼,是非常痛苦的過程。
所以,有人喜歡mobx,可以不用過度關注React的生命週期。
mobx是把雙刃劍,有有點也有缺點。如果用mobx做低代碼平臺,基本不好兼容現有的組件庫,所有組件都要重新封裝一層,就像formily做的那樣。這樣的方式不能說不好,只是小編不喜歡。就像姑娘,自己不喜歡的姑娘照樣很多人搶,自己夢寐以求的姑娘,別人可能敬而遠之。或許,這就是生活吧。百花齊放的世界裏,可以肆意選擇的感覺挺好。

組件控制器

想要ui層變瘦,就不要在組件內部加太多的業務邏輯,只是通過組件自身的props控制組件的行爲,只需在組件外層加一個控制器,來控制組件的props就好。
image.png
加入控制器以後,組件之間的交互聯動,就變成了控制器之間的交互了,並且可以通過邏輯編排來編排這些控制器。
不同的實現方式,有不同的優點跟缺點。邏輯編排也一樣,他可能並不適合所有的業務邏輯,對於CRUD這樣簡單的業務,邏輯編排反而顯得笨重了。
不需要邏輯編排的場景,可能需要給組件配置一個其它的控制器。所以,組件控制器可以遵循相同的接口,並且可以有不同的實現:
image.png
用了不少繼承,這裏也沒必要爭論該用繼承還是該用組合,代碼量不大,習慣了這樣寫。看不慣的朋友,可以用組合再實現一遍。
控制器IController接口實現了兩個接口:屬性控制器(IPropsController)跟變量控制器(IVariableController)。屬性控制器用來管理組件的屬性,這裏的屬性只是數值類型的屬性,不包含函數類型的屬性(也就事件),事件需要單獨處理。變量控制器用來管理控制器的自定義變量,有點類似類的成員變量。也是不同控制器之間交換數據的重要手段。
不管是屬性控制器還是變量控制器,都實現了相應的訂閱(subscribe)方法,用於監聽其變化,有點mobx之類的Proxy感覺,控制器監聽到了Props的變化,會通過自身的subscribeToPropsChange方法發佈出去,用於更新組件。要注意的是,屬性控制器(IPropsController)的訂閱方法訂閱的是單個屬性的變化,而這裏訂閱的是全部屬性的變化,這裏實現的內部使用了屬性控制器(IPropsController)的subscribeToPropChange方法。
AbstractController是所有Controller的基類,封裝了控制器的通用邏輯。
下面的三個控制器分別是:

  • 邏輯編排控制器(LogicFlowController),顧名思義,用邏輯編排實現控制器的業務邏輯。這是框架內置控制器,
  • 腳本控制器(ScriptController),用JS腳本實現控制器的業務邏輯。這也是框架內置控制器。
  • 簡單控制器(SimpleController),本控制器不是框架內置的,屬於自定義控制器,放在了代碼的Expamle部分(代碼中可能叫ShortcutController)。因爲有些簡單的CRUD操作,幾不需要編排控制器,也不需要腳本控制器,就定義這些簡易控制器。還可以根據需要定義其它控制器,並把這些控制器注入到編輯器跟解析引擎。

控制器在Page Schema中的配置

前端邏輯編排的應用場景是低代碼,低代碼平臺中,用DSL(通常是JSON Schema)來描述一個頁面。這裏以RxDrag的Schema定義爲例,介紹控制器的配置,邏輯編排部分可以獨立於Rxdrag運行,您可以用類似的方式整合到其它低碼平臺的Schema中。
RxDrag中組件元數據的定義:

export interface INodeMeta<
  IField = unknown,
  INodeController = unknown
> {
  componentName: string;
  props?: {
    [key: string]: unknown;
  };
  'x-field'?: IField;
  //節點控制器,邏輯編排用
  'x-controller'?: INodeController;
  //鎖定子控件
  locked?: boolean;
  //自己渲染,引擎不渲染
  selfRender?: boolean;
}

泛型屬性x-controller是控制器的DSL,在我們這裏給它這樣一個定義:

import { ILogicFlowDefine } from "@rxdrag/minions-schema";

//控制器變量定義
export interface IVariableDefineMeta {
  //變量標識
  id: string;
  //變量名稱
  name: string;
  //變量默認值
  defaultValue?: unknown;
}

//控制器元數據定義,相當於控制器配置的DSL
export interface IControllerMeta {
  //控制器標識
  id: string;
  //控制器類型,因爲控制器可以注入很多種,類型不固定,這裏不能用枚舉,只能用字符串
  controllerType?: string;
  //是否全局,配置控制器的可見範圍
  global?: boolean;
  //控制器名稱
  name?: string;
}

//邏輯編排控制器
export interface ILogicFlowControllerMeta extends IControllerMeta {
  //組件事件對應的邏輯編排,通過name與組件的事件建立聯繫
  events?: ILogicFlowDefine[];
  //控制器的交互,相當於子編排,可以被其他編排調用
  reactions?: ILogicFlowDefine[];
  //控制器的變量
  variables?: IVariableDefineMeta[];
}

//腳本控制器
export interface IScriptControllerMeta extends IControllerMeta {
  //腳本代碼
  script?: string
}

只給出了邏輯編排控制器跟腳本控制器DSL的定義,簡易控制器跟框架實現關係不大,屬於自定義控制器,這裏就不深入展開了。

控制器與組件的綁定

低代碼一般兩種方式渲染頁面:1、有一個渲染引擎,渲染頁面DSL(通常是JSON);2、生成前端代碼。
作者自己的低代碼平臺還沒做出碼,這裏只討論第一種情況。
低代碼渲染引擎以組件節點爲單位,遞歸渲染頁面Schema:根據componentName拿到組件實現函數,並渲染。當渲染引擎解析到字段x-controller時,就在外面套一個高階組件withController,在這個高階組件裏完成控制器與目標組件的綁定。
渲染引擎相關代碼:

export const ComponentView = memo((
  props: ComponentViewProps
) => {
  const { node, ...other } = props
  //拿到組件定義函數
  const com = usePreviewComponent(node.componentName)

  //根據需要包裝組件
  const Component = useMemo(() => {
    return com && 
      withController(//通過高階組件,綁定控制器與目標組件
        com,
        node["x-controller"] as ILogicFlowControllerMeta,
        node.id,
      )
  }, [com, node]);

  ...

  return (
    Component &&
    (
      node.children?.length ?
        <Component {...node.props} {...other}>
          {
            !node.selfRender && node.children?.map(child => {
              return (<ComponentView key={child.id} node={child} />)
            })
          }
        </Component>
        : <Component {...node.props}  {...other} />
    )
  )
})

高階組件內部的綁定代碼:

export function withController(WrappedComponent: ReactComponent, meta: IControllerMeta | undefined, schemaId: string): ReactComponent {
  if (!meta?.id || !meta?.controllerType) {
    return WrappedComponent
  }

  return memo((props: any) => {
    const [changedProps, setChangeProps] = useState<any>()
    const [controller, setController] = useState<IController>()
    //運行時引擎,通過它來創建控制器
    const runtimeEngine = useRuntimeEngine();
    //拿到controller對應的唯一標識
    const controllerKey = useControllerKey(meta, schemaId)
    const handlePropsChange = useCallback((name: string, value: any) => {
      setChangeProps((changedProps: any) => {
        return ({ ...changedProps, [name]: value })
      })
    }, [])
    useEffect(() => {
      if (meta?.controllerType && runtimeEngine && controllerKey) {
        //創建控制器
        const ctrl = runtimeEngine.getOrCreateController(meta, controllerKey)
        //初始化
        ctrl.init(controllers, logicFlowContext);
        //訂閱屬性變化
        const unlistener = ctrl?.subscribeToPropsChange(handlePropsChange)
        setController(ctrl)
        return () => {
          ctrl?.destory()
          unlistener?.()
        }
      }
    }, [controllerKey, handlePropsChange, runtimeEngine])

    const newProps = useMemo(() => {
      //組裝最新的Props,注意,組件的事件也在這裏完成的綁定
      return { ...props, ...controller?.events, ...changedProps }
    }, [changedProps, controller?.events, props])

    return (
      controller
        //通過上下文下發Controller,這樣可以在組件內拿到Controller
        //只是應對特例,因爲只有極少情況需要在組件內部調用Controller
        ? <ControllerContext.Provider value={controller}>
            //最新的Props傳入目標組件
            <WrappedComponent {...newProps} />
          </ControllerContext.Provider>
        : <>Can not creat controller </>
    )
  })

控制器的可見範圍

上面只完成了一個控制器,並且這個控制器是孤立的,並沒有跟其他控制器發生關係。想要發生關係,控制器就不能是孤立的,需要相互認識(一方知道另一方的引用,或者一方知道另一方的查找方法)。
讓控制器相互知道的方式有:
1、全部控制器註冊到一個全局變量裏面,所有控制器都能訪問這個變量;
image.png
2、通過Context下發控制器,子組件的控制器能訪問所有的父組件的控制器,父組件的控制器訪問不了子控件的控制器。
image.png
3、前兩種方式的組合,默認通過第二種方式傳遞控制器,如果控制器在設計其中被配置爲全局,則按照第一種方式傳遞。
第一種方式已經很直觀了,爲什麼還要考慮第二種跟第三種?或者說壓根忘掉第二種,直接用第一種。
答案是低代碼平臺的組件設計是否支持,就是跟低代碼平臺的組件設計理念相關。作者本人是業餘選手,項目經驗不多,不知道哪種方案更合理。就把可能的情況羅列一下,有觀點朋友歡迎留言給點意見,不勝感激。

原子化組件設計

不同的低代碼平臺,組件的設計粒度是不一樣的。作者自己的的低碼平臺,提倡的是原子化的組件設計,很多組件的設計粒度很細。這種情況下,不可能每個組件都附加一個控制器,很多組件只是用來調整頁面佈局,根本不需要控制器。
所以,在前端配置頁面,加了控制器選擇組件,用於指示給組件啓用哪種類型的控制器:
image.png
這個單選按鈕組,是可以不選的,不選時意味着不需要給組件配置控制器。
原子化組件設計,組件的粒度很細,細到列表也是有各種組件組合而成,列表內的組件是可以隨意拖入的,比如下面這個列表行中的“編輯”、“刪除”按鈕:
image.png
這些行組件是根據數據行記錄而動態創建的,原子化的設計對這些列表沒有太多的封裝,所以無法從全局拿到這些動態創建組件的控制器。
在這種情況下,通過context下發控制器是個不錯的選擇,所有子組件的控制都可以訪問父組件控制器。部分需要全局共享的控制器,就在界面中配置爲全局。這兩種方式結合,基本可以滿足控制器之間的交互需求。
高階組件withController的實現代碼變成:

import { ReactComponent } from "@rxdrag/react-shared"
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import { ControllerContext, ControllersContext } from "../contexts"
import { useControllers } from "../hooks/useControllers"
import { Controllers, IController, ILogicFlowControllerMeta as IControllerMeta } from "@rxdrag/minions-runtime-react"
import { useLogicFlowContext } from "../hooks/useLogicFlowContext"
import { useRuntimeEngine } from "../hooks/useRuntimeEngine"
import { useControllerKey } from "../hooks/useControllerKey"

export function withController(WrappedComponent: ReactComponent, meta: IControllerMeta | undefined, schemaId: string): ReactComponent {
  if (!meta?.id || !meta?.controllerType) {
    return WrappedComponent
  }

  return memo((props: any) => {
    //發生變化的props
    const [changedProps, setChangeProps] = useState<any>()
    //組件自身的控制器
    const [controller, setController] = useState<IController>()
    //所有上級控制器+全局控制器
    const controllers = useControllers()
    //管理控制器的運行時引擎
    const runtimeEngine = useRuntimeEngine();
    //控制器唯一標識,根據層級關係跟控制器本身ID組合產生,用於唯一標識一個控制器
    const controllerKey = useControllerKey(meta, schemaId)

    //處理控制器中的props變化
    const handlePropsChange = useCallback((name: string, value: any) => {
      setChangeProps((changedProps: any) => {
        return ({ ...changedProps, [name]: value })
      })
    }, [])
    useEffect(() => {
      if (meta?.controllerType && runtimeEngine && controllerKey) {
        //給組件創建控制器
        const ctrl = runtimeEngine.getOrCreateController(meta, controllerKey)
        //初始化控制器
        ctrl.init(controllers);
        //監聽props變化
        const unlistener = ctrl?.subscribeToPropsChange(handlePropsChange)
        setController(ctrl)
        return () => {
          ctrl?.destory()
          unlistener?.()
        }
      }
    }, [controllerKey, controllers, handlePropsChange, runtimeEngine])

    //把該組件可見的控制器打包成一個數組
    const newControllers: Controllers = useMemo(() => {
      return controller ? { ...controllers, [controller.id]: controller } : controllers
    }, [controller, controllers])

    //最新的props
    const newProps = useMemo(() => {
      return { ...props, ...controller?.events, ...changedProps }
    }, [changedProps, controller?.events, props])

    return (
      controller
        ? <ControllersContext.Provider value={newControllers}>
          <ControllerContext.Provider value={controller}>
            <WrappedComponent {...newProps} />
          </ControllerContext.Provider>
        </ControllersContext.Provider>
        : <>Can not creat controller </>
    )
  })
}

這種實現方式其實已經足夠靈活了,能夠應對幾乎常見的需求,但是,比起後面的粗粒度組件設計,用戶體驗確實要差一些,用戶需要理解的概念有點多,需要配置的東西也有點多。
當然,實際的項目中,從列表外面訪問列表行內組件的場景幾乎沒有,所以只要把外圍的組件控制器放入全局,供列表內組件使用,也是可以的。這樣,就不需要context了,但是這樣應對不了列表套列表的情況。或許列表套列表的情況不多吧。
小編也想用戶體驗更好些,但是做起來就不由自主的兼顧了更多的靈活性,可能把最終用戶當成自己了吧。後面可能需要更多的在項目中磨練自己,有跟作者方向一致的朋友,歡迎聯繫作者,我們可以一起做一些項目相互學習、共同成長。

粗粒度組件設計

粗粒度組件的設計,會帶來非常良好的用戶體驗。
這是跟原子化組件完全不同的設計理念,每個組件的功能比較多,一個頁面僅需要非常少的組件就能完成。
既然組件較少,每個組件配置一個控制器(或者類似控制器的東西),把這些控制器設置爲全局,邏輯編排的時候,這些全局控制器可以相互調用,直觀方方便。
粗粒度的組件,同樣會有列表,列表要如何處理呢?
可以把列表做成功能全面的組件,行內組件以卡槽的方式插入列表。這種情況下,自然的會假設沒有編排列表套列表這樣的變態需求。
邏輯編排編輯器設計的時候,可以直接從物料箱(工具箱)選擇組件控制器,進行編排。
在上文中,我們只定義了基礎的IController接口,實現了基礎的抽象類AbstractController。AbstractController下面派生出來的子類,不管是邏輯編排控制器還是腳本控制器,都沒有實現太多的業務邏輯,而是把業務邏輯留給了邏輯編排或者腳本來完成。
既然組件的粒度變粗了,必然是包含了某些業務邏輯。可以把這些邏輯重新還給組件,讓每個組件或者組件的控制器包含自己的業務邏輯。

粗粒度組件更友好的編排方式

控制器,是軟件設計實現層面的東西,最終用戶可能不需要關心這樣的實現細節。控制器這個概念的添加,無疑會增加用戶的理解成本。對用戶來講,最直觀的理解對象是組件。
在邏輯編排中,如果以組件而不是控制器爲編排對象,會更加有利於用戶的理解,比如:
image.png
圖中對話框的這個元件節點,是不是更直觀,更容易理解?
在我們已經設計的架構體系裏,能實現這樣的效果?
當然可以,並且可以繼續用控制器(通用控制器或者特殊控制器都行),加入有個通用控制器叫DefaultController,它是AbstractConroller的一個子類,實現了常用的控制器功能。可以爲對話框組件,定製一個單獨的元件節點,節點保有組件控制器DefaultController的一個引用就可以:
image.png
給DialgoActivity配置上相應物料,就可以以組件的面貌參與邏輯編排了,雖然內部基於控制器IController實現,但用戶是感知不到IController存在的。元件物料定義:

//上下文中的控制器參數
export interface IControllerEditorContextParam {
  //所有能訪問的控制器
  controllers?: ILogicFlowControllerMeta[],
  //當前組件控制器
  controller?: ILogicFlowControllerMeta,
}

export const dialogMaterial: IRxDragActivityMaterial<IPropConfig, IControllerEditorContextParam> = {
  icon: dialogIcon,
  label: "對話框",
  activityType: NodeType.Activity,
  defaultPorts: {
    inPorts: [
      {
        id: createUuid(),
        name: DialogAtivity.PORT_OPEN,
        label: "打開",
      },
    ],
    outPorts: [
      {
        id: createUuid(),
        name: DialogAtivity.PORT_CLOSE,
        label: "關閉",
      },
    ],
  },
  //屬性面板Schema
  schema: dialogSchema,
  //副標題顯示具體哪個對話框
  subTitle: (config?: IPropConfig, context?: IControllerEditorContextParam) => {
    const controllerName = context?.controllers?.find(controler => controler.id === config?.param?.controllerId)?.name
    return controllerName ? (controllerName + "/" + (config?.param?.prop || "")) : ""
  },
  activityName: DialogAtivity.NAME,
}

DialogActivity的定義大致如下(作者沒做粗粒度組件,故以下代碼不來自真實代碼,只是示意):

export interface IControllerContext {
  controllers: Controllers,
}

export interface IControllerParam {
  controllerId?: string
}
export interface IDialogConfig {
  param?: IControllerParam
}

@Activity(DialogActivity.NAME)
export class DialogActivity extends AbstractActivity<IDialogConfig> {
  public static NAME = "dialog"
  public static PORT_OPEN = "open"
  public static PORT_CLOSE = "close"
  
  //組件控制器
  controller: IController
  constructor(meta: INodeDefine<IDialogConfig>, context?: IControllerContext) {
    super(meta, context)

    if (!meta.config?.param?.controllerId) {
      throw new Error("ReadProp not set controller id")
    }
    const controller = context?.controllers?.[meta.config?.param?.controllerId]
    if (!controller) {
      throw new Error("Can not find controller")
    }
    this.controller = controller
  }

  @Input(DialogActivity.PORT_OPEN)
  openHandler = () => {
    this.controller.setProp("open", true)
  }

  @Input(DialogActivity.PORT_CLOSE)
  closeHandler = () => {
    this.controller.setProp("close", true)
  }
}

朋友,讀到這裏,業務邏輯跟ui層解耦的魅力,您體會到了嗎?

前端邏輯編排編輯器

Rxdrag項目中,前端編輯器的項目構成:
image.png
整個邏輯編排功能分爲兩個頂層包:

  • minions,包含邏輯編排運行時、設計器跟DSL定義(schema),本包不依賴antd,就是說如果你的項目不想引入antd或者說antd的版本跟作者不一樣,可以使用這個包,但不能使用下面另一個包。
  • minions-antd5,跟antd5相關的部分,全部都在這個包裏,這個包大部分都是設計器相關的東西,運行時相關的東西僅包含Activity的定義。

具體每個包的詳細解釋:
minions
  -editor 編輯器相關,可能會依賴runtime包。
    -controller-editor 控制器編排編輯器。包含了對組件控制器的編排,主要用於前端,基於下面的logicflow-editor實現。
    -logicflow-editor 最基礎的邏輯編排編輯器,不包含前端控制器編排相關內容。後端編排編輯器可以基於這個實現。
  -runtime 運行時,邏輯編排的解析引擎相關,不依賴於editor包的任何東西。
    -activities 預定義的Activities,也就是元件節點的實現邏輯。比如循環、條件等
    -runtime-core 邏輯編排解析引擎核心包,react無關。
    -runtime-react 邏輯編排解析引擎react相關部分,包括控制器相關內容。
minions-antd5,
  -controller-editor-antd5 控制器編排編輯器,依賴antd5,依賴logicflow-editor-antd5
  -logicflow-editor-antd5 普通編排編輯器,依賴antd5
  -minions-react-antd5-activites antd5相關的Activities
  -minions-react-materials 物料定義,這些物料的實現依賴antd5和rxdrag低代碼引擎部分。
具體代碼實現內容不少,感興趣的話自行翻代碼庫看看吧,篇幅所限,無法進一步展開了。

控制器的注入

本節內容只是針對原子化組件低代碼平臺的,並未考慮粗粒度組件,粗粒度組件不需要注入控制器,只需要使用DefaultController就可以。
低代碼平臺中,邏輯編排的配置是以屬性配置組件的形式出現的:
image.png
這些控制器是可以注入的,這個跟具體低代碼平臺的實現機制有關,這裏只是提一下思路,根本文主題關係不大,就不詳細展開了。

後端邏輯編排

後端邏輯編排的設計器使用的是logicflow-editor-antd5這個包,編輯器部分的實現跟前端邏輯編排基本一致。本節主要討論後端解析引擎的實現。
image.png
前端定義元件節點物料,把物料注入邏輯編排編輯器。後端定義元件實現邏輯(Activity),把Activity注入後端編排解析引擎。節點物料依賴Activity,通過activityName關聯。
邏輯編排編輯器生成產物是JSON格式的編排描述數據,後端引擎消費這個JSON,轉化成具體的執行邏輯。
邏輯編排解析引擎的代碼量並不大,相當於把前面討論的Typescript實現轉譯爲一份golang的實現。項目代碼結構:
image.png
這是一個golang library,可以在其他golang項目中被引用,作者在自己的低代碼平臺中,使用了這個庫。代碼結構關鍵部分:

  • activities,預定義元件的實現邏輯。
  • dsl,就是上面Typescript定義的那份DSL,這裏轉譯成Golang。
  • example,該庫使用例子,後面會實現,現在還沒有。
  • runtime,邏輯編排解析引擎。

DSL轉譯

//端口定義
type PortDefine struct {
	//ID
	Id    string `json:"id"`
	//名稱
	Name  string `json:"name"`
	//標題
	Label string `json:"label"`
}

//線的連接點
type PortRef struct {
	//節點ID
	NodeId string `json:"nodeId"`
	//端口ID
	PortId string `json:"portId"`
}

//連線定義
type LineDefine struct {
	Id string `json:"id"`
	//源節點
	Source PortRef `json:"source"`
	//目標節點
	Target PortRef `json:"target"`
}
//節點定義
type NodeDefine struct {
	Id           string                 `json:"id"`
	//名稱
	Name         string                 `json:"name"` //嵌入編排,端口轉換成子節點時使用
	//節點類型,對應Typescript的枚舉
	Type         string                 `json:"type"`
	//元件對應Activity名稱
	ActivityName string                 `json:"activityName"`
	//標題
	Label        string                 `json:"label"`
	//配置
	Config       map[string]interface{} `json:"config"`
	//入端口
	InPorts      []PortDefine           `json:"inPorts"`
	//出端口
	OutPorts     []PortDefine           `json:"outPorts"`
	//子節點,嵌入式節點用,比如自定義循環節點、事務節點
	Children     LogicFlowMeta          `json:"children"`
}
//一段邏輯編排
type LogicFlowMeta struct {
	//所有節點
	Nodes []NodeDefine `json:"nodes"`
	//所有連線
	Lines []LineDefine `json:"lines"`
}

//子編排,可以被其它編排調用
type SubLogicFlowMeta struct {
    //組合一段編排數據
	LogicFlowMeta
    //用於調用尋址
	Id string
}

就是簡單定義,沒有什麼需要特殊解釋的。

Activity的實現

在前端編排引擎的實現中,我們用了放射中的註解來收集Ativity跟它的端口處理函數。golang中並沒有註解,並且也沒有辦法通過Struct的名字拿到Struct。這部分,就不得不做一些變通的處理。
第一次嘗試,充分利用泛型,使用工廠方法,在runtime模塊定義一個註冊工廠方法的函數:

//註冊工廠方法,用於創建Activity實例
func RegisterActivity(name string, factory interface{}) {
	activitiesMap.Store(name, factory)
}
//工廠方法的泛型
func NewActivity[Config any, T Activity[Config]](meta *dsl.ActivityDefine) *T {
	var activity T
	activity.GetBaseActivity().Init(meta)
	return &activity
}

Activity相應的實現方式:

type DebugConfig struct {
	Tip    string `json:"tip"`
	Closed bool   `json:"closed"`
}
type DeubugActivity struct {
	BaseActivity runtime.BaseActivity[DebugConfig]
}

func init() {
    //註冊工廠函數
	runtime.RegisterActivity(
		"debug",
        //把泛型定義的工廠方法實例化爲具體方法
		runtime.NewActivity[DebugConfig, DeubugActivity],
	)
}

func (d* DeubugActivity) GetBaseActivity() *runtime.BaseActivity[DebugConfig] {
	return &d.BaseActivity
}


這個實現方式能夠正常運行,並且引擎代碼相對簡單。但是,實現一個Activity的代碼看起來有些複雜,增加了用戶的心智負擔,還是希望Activity的定義能夠更簡單些。
最後,放棄了泛型,具體類型由引擎通過反射來識別,註冊時只傳入一個Activity實例,Activity的複雜度,轉移到了引擎內部:

type DebugConfig struct {
	Tip    string `json:"tip"`
	Closed bool   `json:"closed"`
}
type DebugActivity struct {
	Activity runtime.Activity[DebugConfig]
}

func init() {
	runtime.RegisterActivity(
		"debug",
		DebugActivity{},
	)
}

是不是看起來簡單多了?

端口與端口處理函數的綁定

golang沒有註解,不能像Typescript那樣,通過註解把端口跟端口處理函數關聯起來。但是golang有反射,通過一個變量,能夠拿到變量的類型,並且創建另一個同類型的實例。通過方法名稱,能夠拿到並調用這個實例上的方法。所以,不需要註解,可以直接通過端口名字調用對應端口處理函數,只要端口名字跟端口處理函數的名字一致(首字母大小寫不敏感),這些都是在框架內完成的。
上面Debug對應的input端口處理函數,不需要任何額外處理,直接這麼寫,框架能自動完成綁定,下面這個函數會被自動綁定到input入端口:

func (d *DebugActivity) Input(inputValue any, ctx context.Context) {
	config := d.Activity.GetConfig()
    ...
}

編排引擎的詳細實現原理這裏就不展開了,感興趣的話請參考上面的前段編排引擎部分吧。

對接函數參數的處理

在前端,一個編排對應的是組件的一個事件,事件是一個函數,事件的參數會以數組的形式傳給入口的inputValue,使用的時候用數組拆分元,把參數拆出來使用:
image.png
組件函數的參數,沒有名字信息不能轉成map:

//這種方式使用函數,參數沒有名字信息,只有順序信息
(...args: unknown[]) => inputOne.push(args)

在後端,作者把一個邏輯編排對應一個graphql接口(field),它的參數是map,沒有順序信息,不能轉成數組,只能用對象拆分元件來拆分參數:
image.png
是的,你沒看錯,同樣是參數分解,前後端不一致了,可能會給用戶造成困擾。
目前想了一個折中的辦法,不管是前端編排還是後端編排,都建一個名字是“參數分解”的元件,只是後端是基於對象拆分實現,前端是基於數組拆分實現。
image.png

後端的邏輯編排的集成

在低代碼平臺中,後端的邏輯編排要跟後端其它服務集成在一起,有兩種實現方式:

  • 邏輯編排是單獨的服務

image.png

  • 邏輯編排跟模型服務集成在一起

image.png
這兩種實現方式,小編傾向於後者。理由有兩個:

  1. 邏輯編排獨立成一個微服務,更加了事務編排的難度
  2. 邏輯編排獨立成微服務,不方便給編排添加後面說的類型系統。

展望未來

邏輯編排的介紹接近尾聲了,雖然洋洋灑灑2w字,感覺還是淺嘗輒止,很多內容沒有深入下去,實際代碼相比文章還是要複雜些,感興趣的朋友歡迎翻閱代碼並交流。
文中並沒有涉及邏輯編排中的一個重要內容,類型約束。有了類型約束,用戶編排的效率會提高很多,出錯的概率會降低很多。類型可以編輯器增加智能提示,輸入限制,給解析引擎的類型轉換提供支持。
接下來,會以UML類圖的形式,給自己的低代碼平臺提供領域模型。這個領域模型,貫穿模型前後端,並衍生出一套類型系統,附加到低代碼的各個部分,自然也會附加到邏輯編排這部分。類型系統的思路,來自大佬徐飛的一篇文章。
今年寫了兩篇長文,一篇是《實戰,一個高擴展、可視化低代碼前端,詳實、完整》還有一篇就是本文。上一篇重點在可視化編輯部分,本篇重點是邏輯編排。還欠缺頁面的數據模型部分,會在不遠的將來補上。

總結

本文主要寫了數據流驅動的邏輯編排運行原理、編輯器、解析引擎等內容,涵蓋前端編排跟後端編排。雖然質量不怎麼樣,但是卻是用心在寫,前後花了一個星期的時間。希望能給需要的朋友一點幫助或者啓發,價值便是我的動力所在。
有不同意見的朋友歡迎留言討論,沒事找事的朋友,歡迎來戰。

感謝

感謝板磚團隊的MyBricks產品,它提供了寶貴的思路,讓Rxdrag的邏輯編排部分得以完成。
感謝網友陌路及其團隊成員提供的支持,每次迷茫的時候,跟他們討論一下,總會有一種豁然開朗的感覺。
感謝網友青銅提供支持,正是因爲他的鼎力相助,才能讓我這個環境小白成功的做了一個Monorepo項目。
感謝一致以來關注跟支持我的朋友,朋友們的支持給了我莫大的鼓勵,希望在未來的日子裏,我們還能相互陪伴一起走的更遠。
最後,感謝CCTV,雖然不知道它做了什麼,但是總覺還是要提前感謝一下,說不定它某一天真能做些什麼。
希望在以後的日子裏,幫到的人越來越多。也希望以後文章的感謝列表越來越長。

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