可視化邏輯編排引擎,包括數據流驅動的邏輯編排原理,業務編排編輯器的實現,頁面控制項聯動,前端業務邏輯與UI層的分離,子編排的復用、自定義迴圈等嵌入式子編排的處理、事務處理等 ...
前一段時間寫過一篇文章《實戰,一個高擴展、可視化低代碼前端,詳實、完整》,得到了很多朋友的關註。
其中的邏輯編排部分過於簡略,不少朋友希望能寫一些關於邏輯編排的內容,本文就詳細講述一下邏輯編排的實現原理。
邏輯編排的目的,是用最少甚至不用代碼來實現軟體的業務邏輯,包括前端業務邏輯跟後端業務邏輯。本文前端代碼基於typescript、react技術棧,後端基於golang。
涵蓋內容:數據流驅動的邏輯編排原理,業務編排編輯器的實現,頁面控制項聯動,前端業務邏輯與UI層的分離,子編排的復用、自定義迴圈等嵌入式子編排的處理、事務處理等
運行快照:
前端項目地址:https://github.com/codebdy/rxdrag
前端演示地址:https://rxdrag.vercel.app/
後端演示尚未提供,代碼地址:https://github.com/codebdy/minions-go
註:為了便於理解,本文使用的代碼做了簡化處理,會跟實際代碼有些細節上的出入。
整體架構
整個邏輯編排,由以下幾部分組成:
- 節點物料,用於定義編輯器中的元件,包含在工具箱中的圖標,埠以及屬性面板中的組件schema。
- 邏輯編排編輯器,顧名思義,可視化編輯器,根據物料提供的元件信息,編輯生成JSON格式的“編排描述數據”。
- 編排描述數據,用戶操作編輯器的生成物,供解析引擎消費
- 前端解析引擎,Typescript 實現的解析引擎,直接解析“編排描述數據”並執行,從而實現的軟體的業務邏輯。
- 後端解析引擎,Golang 實現的解析引擎,直接解析“編排描述數據”並執行,從而實現的軟體的業務邏輯。
邏輯編排實現方式的選擇
邏輯編排,實現方式很多,爭議也很多。
一直以來,小編的思路也很局限。從流程圖層面,以線性的思維去思考,認為邏輯編排的意義並不大。因為,經過這麼多年發展,事實證明代碼才是表達邏輯的最佳方式,沒有之一。用流程圖去表達代碼,最終只能是老闆、客戶的豐滿理想與程式員骨感現實的對決。
直到看到Mybricks項目交互部分的實現方式,才打開了思路。類似unreal藍圖數據流驅動的實現方式,其實大有可為。
這種方式的意義是,跳出迴圈、if等這些底層的代碼細節,以數據流轉的方式思考業務邏輯,從而把業務邏輯抽象為可復用的組件,每個組件對數據進行相應處理或者根據數據執行相應動作,從而達到復用業務邏輯的目的。並且,節點的粒度可大可小,非常靈活。
具體實現方式是,把每個邏輯組件看成一個黑盒,通過入埠流入數據,出埠流出變換後的數據:
舉個例子,一個節點用來從資料庫查詢客戶列表,會是這樣的形式:
用戶不需要關註這個元件節點的實現細節,只需要知道每個埠的功能就可以使用。這個元件節點的功能可以做的很簡單,比如一個fetch,只有幾十行代碼。也可以做到很強大,比如類似useSwr,自帶緩存跟狀態管理,可以有幾百甚至幾千行代碼。
我們希望這些元件節點是可以自行定義,方便插入的,並且我們做到了。
出埠跟入埠之間,可以用線連接,表示元件節點之間的調用關係,或者說是數據的流入關係。假如,數據讀取成功,需要顯示在列表中;失敗,提示錯誤消息;查詢時,顯示等待的Spinning,那麼就可以再加三個元件節點,變成:
如果用流程圖,上面這個編排,會被顯示成如下樣子:
兩個比較,就會發現,流程圖的思考方式,會把人引入條件細節,其實就是試圖用不擅長代碼的圖形來描述代碼。是純線性的,沒有回調,也就無法實現類似js promise的非同步。
而數據流驅動的邏輯編排,可以把人從細節中解放出來,用模塊化的思考方式去設計業務邏輯,更方便把業務邏輯拆成一個個可復用的單元。
如果以程式員的角度來比喻,流程圖相當於一段代碼腳本,是面向過程的;數據流驅動的邏輯編排像是幾個類交互完成一個功能,更有點面向對象的感覺。
朋友,如果是讓你選,你喜歡哪種方式?歡迎留言討論。
另外還有一種類似stratch的實現方式:
感覺這種純粹為了可視化而可視化,只適合小孩子做玩具。會寫代碼的人不願意用,太低效了。不會寫代碼的人,需要理解代碼才會用。適合場景是用直觀的方式介紹什麼是代碼邏輯,就是說只適合相對比較低智力水平的編程教學,比如幼兒園、小學等。商業應用,就免了。
數據流驅動的邏輯編排
一個簡單的例子
從現在開始,放下流程圖,忘記strach,我們從業務角度去思考也邏輯,然後設計元件節點去實現相應的邏輯。
選一個簡單又典型的例子:學生成績單。一個成績單包含如下數據:
假如數據已經從資料庫取出來了,第一步處理,統計每個學生的總分數。設計這麼幾個元件節點來配合完成:
這個編排,輸入成績列表,迴圈輸出每個學生的總成績。為了完成這個編排,設計了四個元件節點:
- 迴圈,入埠接收一個列表,遍歷列表並迴圈輸出,每一次遍歷往“單次輸出”埠發送一條數據,可以理解為一個學生對象(儘量從對象的角度思考,而不是數據記錄),遍歷結束後往“結束埠”發送迴圈的總數。如果按照上面的列表,“單次輸出埠”會被調用4次,每次輸出一個學生對象{姓名:xxx,語文:xxx,數學:xxx...},“結束”埠只被調用一次,輸出結果是 4.
- 拆分對象,這個元件節點的出埠是可以動態配置的,它的功能是把一個對象按照屬性值按照名字分發到指定的出埠。本例中,就是把各科成績拆分開來。
- 收集數組,這個節點也可以叫收集到數組,作用是把串列接收到的數據組合到一個數組裡。他有兩個入埠:input埠,用來接收串列輸入,並緩存到數組;finished埠,表示輸入完成,把緩存到的數據組發送給輸出埠。
- 加和,把輸入埠傳來的數組進行加和計算,輸出總數。
這是一種跟代碼完全不同的思考方式,每一個元件節點,就是一小段業務邏輯,也就是所謂的業務邏輯組件化。我們的項目中,只提供給了有限的預定義元件節點,想要更多的節點,可以自行自定義並註入系統,具體設計什麼樣的節點,完全取決於用戶的業務需求跟喜好。作者更希望設計元件的過程是一個創作的過程,或許具備一定的藝術性。
剛剛的例子,審視之。有人可能會換一個方式來實現,比如拆分對象跟收集數據這兩個節點,合併成一個節點:對象轉數組,可能更方便,適應能力也更強:
對象轉換數組節點,對象屬性與數組索引的對應關係,可以通過屬性面板的配置來完成。
這兩種實現方式,說不清哪種更好,選擇自己喜歡的,或者兩種都提供。
輸入節點、輸出節點
一段圖形化的邏輯編排,通過解析引擎,會被轉換成一段可執行的業務邏輯。這段業務邏輯需要跟外部對接,為了明確對接語義,再添加兩個特殊的節點元件:輸入節點(開始節點),輸出節點(結束節點)。
輸入節點用於標識邏輯編排的入口,輸入節點可以有一個或者多個,輸入節點用細線圓圈表示。
輸出節點用於標識邏輯編排的出口,輸出節點可以有一個或者多個,輸出節點用粗線圓圈表示。
在後面的引擎部分,會詳細描述輸入跟輸出節點如何跟外部的對接。
編排的復用:子編排
一般低代碼中,提升效率的方式是復用,儘可能復用已有的東西,比如組件、業務邏輯,從而達到降本、增效的目的。
設計元件節點是一種創作,那麼使用元件節點進行業務編排,更是一種基於領域的創作。辛辛苦苦創作的編排,如果能被覆用,應該算是對創作本身的尊重吧。
如果編排能夠像元件節點一樣,被其它邏輯編排所引用,那麼這樣的復用方式無疑是最融洽的。也是最方便的實現方式。
把能夠被其它編排引用的編排稱為子編排,上面計算學生總成績的編排,轉換成子編排,被引入時的形態應該是這樣的:
子編排元件的輸入埠對應邏輯編排實現的輸入節點,輸出埠對應編排實現的輸出節點。
嵌入式編排節點
前文設計的迴圈組件非常簡單,迴圈直接執行到底,不能被中斷。但是,有的時候,在處理數據的時候,要根據每次遍歷到的數據做判斷,來決定繼續迴圈還是終止迴圈。
就是說,需要一個迴圈節點,能夠自定義它的處理流程。依據這個需求,設計了自定義迴圈元件,這是一種能夠嵌入編排的節點,形式如下:
這種嵌入式編排節點,跟其它元件節點一樣,事先定義好輸入節點跟輸出節點。只是它不完全是黑盒,其中一部分通過邏輯編排這種白盒方式來實現。
這種場景並不多見,除了迴圈,後端應用中,還有事務元件也需要類似實現方式:
嵌入式元件跟其它元件節點一樣,可以被其它元件連接,嵌入式節點在整個編排中的表現形式:
基本概念
為了進一步深入邏輯編排引擎跟編輯器的實現原理,先梳理一些基本的名詞、概念。
邏輯編排,本文特指數據流驅動的邏輯編排,是由圖形表示的一段業務邏輯,由元件節點跟連線組成。
元件節點,簡稱元件、節點、編排元件、編排單元。邏輯編排中具體的業務邏輯處理單元,帶副作用的,可以實現數據轉換、頁面組件操作、資料庫數據存取等功能。一個節點包含零個或多個輸入埠,包含零個或多個輸出埠。在設計其中,以圓角方形表示:
埠,分為輸入埠跟輸出埠兩種。是元件節點流入或流出數據的通道(或者介面)。在邏輯單元中,用小圓圈表示。
輸入埠,簡稱入埠、入口。輸入埠位於元件節點的左側。
輸出埠,簡稱出埠、出口。輸出埠位於元件節點的右側。
單入口元件,只有一個入埠的元件節點。
多入口元件,有多個入埠的元件節點。
單出口元件,只有一個出埠的元件節點。
多出口元件,有多個出埠的元件節點。
輸入節點,一種特殊的元件節點,用於描述邏輯編排的起點(開始點)。轉換成子編排後,會對應子編排相應的入埠。
輸出節點,一種特殊的元件節點,用於描述邏輯編排的終點(結束點)。轉換成子編排後,會對應子編排相應的出埠。
嵌入式編排,特殊的元件節點,內部實現由邏輯編排完成。示例:
子編排,特殊的邏輯編排,該編排可以轉換成元件節點,供其它邏輯編排使用。
連接線,簡稱連線、線。用來連接各個元件節點,表示數據的流動關係。
定義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類的對象聯動起來。這個實現是邏輯編排的核心,雖然實現代碼只有幾百行,但是很繞,需要靜下心來好好研讀接下來的部分。
編排引擎的設計
編排引擎類圖
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。
註意,這是實例的關係,如果對應到類圖,就是這樣的關係:
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也按照輸入跟輸出來分組:
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的連接關係:
邏輯引擎在解析編排圖元件時,會給每一個元件埠創建一個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方法,把數據分發出去:
具體實現代碼:
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內部的流程就跑通了:
出埠掛接其它元件節點
入埠關聯的是Activity的自身處理函數,出埠關聯的是外部處理函數,這些外部處理函數有能是其它連接器(Jointer)的push方法,也可能來源於其它跟應用對接的部分。
如果是關聯的是其他節點的Jointer,關聯關係是通過邏輯編排圖中的連線定義的。
解析器先構造完所有的節點,然後遍歷一遍連線,調用連線源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)
}
特殊的元件節點:開始節點、結束節點
到目前為止,解析引擎部分,已經能夠成功解析普通的元件併成功連線,但是一個編排的入口跟出口尚未處理,對應的是編排圖的輸入節點(開始節點)跟輸出節點(結束節點)
這兩個節點,沒有任何業務邏輯,只是輔助把外部輸入,連接到內部的元件;或者把內部的輸出,發送給外部。所以,這兩個節點,只是簡單的Jointer就夠了。
如果把一個邏輯編排看作一個元件節點:
輸入元件節點對應的是輸入埠,輸出元件節點對應的是輸出埠。既然邏輯編排也有自己埠,那麼LogicFlow也要聚合ActivityJointers:
引擎解析的時候,要根據開始元件節點跟結束元件節點,構建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。
還有一種節點,它的輸入埠是不固定的,可以動態增加或者刪除。比如:
合併節點就是動態入口的節點,它的功能是接收入口傳來的數據,等所有數據到齊以後,合併成一個對象轉發到輸出埠。這個節點,有非同步等待的功能。
為了處理這種節點,我們引入新的註解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相關註解。
嵌入式編排的解析
邏輯編排中,最複雜的部分,就是嵌入式編排的解析,希望小編能解釋清楚。
再看一遍嵌入式編排的表現形式:
這是自定義迴圈節點。雖然它埠直接跟內部的編排節點相連,但是實際上這種情況是無法直接調用new LogicFlow 來解析內部邏輯編排的,需要進行轉換。引擎解析的時候,把會把上面的子編排重組成如下形式:
首先,給子編排添加輸入節點,名稱跟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)
}
}
基礎的邏輯編排引擎,基本全部介紹完了,清楚了節點之間的編排機制,是時候定義節點的連線規則了。
節點的連線規則
一個節點,是一個對象。有狀態,有副作用。有狀態的對象沒有約束的互連,是非常危險的行為。
這種情況會面臨一個誘惑,或者說用戶自己也分不清楚。就是把節點當成無狀態對象使用,或者直接認為節點就是無狀態的,不加限制的把連線連到某個節點的入口上。
比如上面計算學生總分例子,可能會被糊塗的用戶連成這樣:
這種連接方式,直接造成收集數組節點無法正常工作。
邏輯編排之所以直觀,在於它把每一個個數據流通的路徑都展示出來了。在一個通路上的一個節點,最好只完成一個該通路的功能。另一個通路如果想完成同樣的功能,最好再新建一個對象:
這樣兩個收集數組節點,就互不幹擾了。
要實現這樣的約束,只需要加一個連線規則:同一個入埠,只能連一條線。
有了這條規則,節點對象狀態帶來的不利影響,基本消除了。
在這樣的規則下,收集數組節點的入口不能連接多條連線,只需要把它重新設計成如下形式:
一個出埠,可以往外連接多條連線,用於表示並行執行。另一條規則就是:同一個出埠,可以有多條連線。
數據是從左往右流動,所以再加上最後一條規則:入埠在節點左側,出埠在節點右側。
所有的連線規則完成了,蠻簡單的,編輯器層面可以直接做約束,防止用戶輸錯。
編輯器的實現
編輯器佈局
整個編輯器分為圖中標註的四個區域。
- ① 工具欄,編輯器常規操作,比如撤銷、重做、刪除等。
- ② 工具箱(物料箱),存放可以被拖放的元件物料,這些物料是可以從外部註入到編輯器的。
- ③ 畫布區,繪製邏輯編排圖的畫布。每個節點都有自己的坐標,要基於這個對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(`