前一段時間寫過一篇文章《實戰,一個高擴展、可視化低代碼前端,詳實、完整》,得到了很多朋友的關注。
其中的邏輯編排部分過於簡略,不少朋友希望能寫一些關於邏輯編排的內容,本文就詳細講述一下邏輯編排的實現原理。
邏輯編排的目的,是用最少甚至不用代碼來實現軟件的業務邏輯,包括前端業務邏輯跟後端業務邏輯。本文前端代碼基於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(`🪲${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的相關文章。該節點在編輯器中的表現:
編輯器的狀態管理
如果純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,
}
) => {
...
}}
深度集成的考量
編輯器有時候要跟其它編輯器,比如後端的領域模型編輯器深度集成,共享一套撤銷、重做、刪除按鈕。比如:
這種情況要通過給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就好。
加入控制器以後,組件之間的交互聯動,就變成了控制器之間的交互了,並且可以通過邏輯編排來編排這些控制器。
不同的實現方式,有不同的優點跟缺點。邏輯編排也一樣,他可能並不適合所有的業務邏輯,對於CRUD這樣簡單的業務,邏輯編排反而顯得笨重了。
不需要邏輯編排的場景,可能需要給組件配置一個其它的控制器。所以,組件控制器可以遵循相同的接口,並且可以有不同的實現:
用了不少繼承,這裏也沒必要爭論該用繼承還是該用組合,代碼量不大,習慣了這樣寫。看不慣的朋友,可以用組合再實現一遍。
控制器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、全部控制器註冊到一個全局變量裏面,所有控制器都能訪問這個變量;
2、通過Context下發控制器,子組件的控制器能訪問所有的父組件的控制器,父組件的控制器訪問不了子控件的控制器。
3、前兩種方式的組合,默認通過第二種方式傳遞控制器,如果控制器在設計其中被配置爲全局,則按照第一種方式傳遞。
第一種方式已經很直觀了,爲什麼還要考慮第二種跟第三種?或者說壓根忘掉第二種,直接用第一種。
答案是低代碼平臺的組件設計是否支持,就是跟低代碼平臺的組件設計理念相關。作者本人是業餘選手,項目經驗不多,不知道哪種方案更合理。就把可能的情況羅列一下,有觀點朋友歡迎留言給點意見,不勝感激。
原子化組件設計
不同的低代碼平臺,組件的設計粒度是不一樣的。作者自己的的低碼平臺,提倡的是原子化的組件設計,很多組件的設計粒度很細。這種情況下,不可能每個組件都附加一個控制器,很多組件只是用來調整頁面佈局,根本不需要控制器。
所以,在前端配置頁面,加了控制器選擇組件,用於指示給組件啓用哪種類型的控制器:
這個單選按鈕組,是可以不選的,不選時意味着不需要給組件配置控制器。
原子化組件設計,組件的粒度很細,細到列表也是有各種組件組合而成,列表內的組件是可以隨意拖入的,比如下面這個列表行中的“編輯”、“刪除”按鈕:
這些行組件是根據數據行記錄而動態創建的,原子化的設計對這些列表沒有太多的封裝,所以無法從全局拿到這些動態創建組件的控制器。
在這種情況下,通過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下面派生出來的子類,不管是邏輯編排控制器還是腳本控制器,都沒有實現太多的業務邏輯,而是把業務邏輯留給了邏輯編排或者腳本來完成。
既然組件的粒度變粗了,必然是包含了某些業務邏輯。可以把這些邏輯重新還給組件,讓每個組件或者組件的控制器包含自己的業務邏輯。
粗粒度組件更友好的編排方式
控制器,是軟件設計實現層面的東西,最終用戶可能不需要關心這樣的實現細節。控制器這個概念的添加,無疑會增加用戶的理解成本。對用戶來講,最直觀的理解對象是組件。
在邏輯編排中,如果以組件而不是控制器爲編排對象,會更加有利於用戶的理解,比如:
圖中對話框的這個元件節點,是不是更直觀,更容易理解?
在我們已經設計的架構體系裏,能實現這樣的效果?
當然可以,並且可以繼續用控制器(通用控制器或者特殊控制器都行),加入有個通用控制器叫DefaultController,它是AbstractConroller的一個子類,實現了常用的控制器功能。可以爲對話框組件,定製一個單獨的元件節點,節點保有組件控制器DefaultController的一個引用就可以:
給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項目中,前端編輯器的項目構成:
整個邏輯編排功能分爲兩個頂層包:
- 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就可以。
低代碼平臺中,邏輯編排的配置是以屬性配置組件的形式出現的:
這些控制器是可以注入的,這個跟具體低代碼平臺的實現機制有關,這裏只是提一下思路,根本文主題關係不大,就不詳細展開了。
後端邏輯編排
後端邏輯編排的設計器使用的是logicflow-editor-antd5這個包,編輯器部分的實現跟前端邏輯編排基本一致。本節主要討論後端解析引擎的實現。
前端定義元件節點物料,把物料注入邏輯編排編輯器。後端定義元件實現邏輯(Activity),把Activity注入後端編排解析引擎。節點物料依賴Activity,通過activityName關聯。
邏輯編排編輯器生成產物是JSON格式的編排描述數據,後端引擎消費這個JSON,轉化成具體的執行邏輯。
邏輯編排解析引擎的代碼量並不大,相當於把前面討論的Typescript實現轉譯爲一份golang的實現。項目代碼結構:
這是一個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,使用的時候用數組拆分元,把參數拆出來使用:
組件函數的參數,沒有名字信息不能轉成map:
//這種方式使用函數,參數沒有名字信息,只有順序信息
(...args: unknown[]) => inputOne.push(args)
在後端,作者把一個邏輯編排對應一個graphql接口(field),它的參數是map,沒有順序信息,不能轉成數組,只能用對象拆分元件來拆分參數:
是的,你沒看錯,同樣是參數分解,前後端不一致了,可能會給用戶造成困擾。
目前想了一個折中的辦法,不管是前端編排還是後端編排,都建一個名字是“參數分解”的元件,只是後端是基於對象拆分實現,前端是基於數組拆分實現。
後端的邏輯編排的集成
在低代碼平臺中,後端的邏輯編排要跟後端其它服務集成在一起,有兩種實現方式:
- 邏輯編排是單獨的服務
- 邏輯編排跟模型服務集成在一起
這兩種實現方式,小編傾向於後者。理由有兩個:
- 邏輯編排獨立成一個微服務,更加了事務編排的難度
- 邏輯編排獨立成微服務,不方便給編排添加後面說的類型系統。
展望未來
邏輯編排的介紹接近尾聲了,雖然洋洋灑灑2w字,感覺還是淺嘗輒止,很多內容沒有深入下去,實際代碼相比文章還是要複雜些,感興趣的朋友歡迎翻閱代碼並交流。
文中並沒有涉及邏輯編排中的一個重要內容,類型約束。有了類型約束,用戶編排的效率會提高很多,出錯的概率會降低很多。類型可以編輯器增加智能提示,輸入限制,給解析引擎的類型轉換提供支持。
接下來,會以UML類圖的形式,給自己的低代碼平臺提供領域模型。這個領域模型,貫穿模型前後端,並衍生出一套類型系統,附加到低代碼的各個部分,自然也會附加到邏輯編排這部分。類型系統的思路,來自大佬徐飛的一篇文章。
今年寫了兩篇長文,一篇是《實戰,一個高擴展、可視化低代碼前端,詳實、完整》還有一篇就是本文。上一篇重點在可視化編輯部分,本篇重點是邏輯編排。還欠缺頁面的數據模型部分,會在不遠的將來補上。
總結
本文主要寫了數據流驅動的邏輯編排運行原理、編輯器、解析引擎等內容,涵蓋前端編排跟後端編排。雖然質量不怎麼樣,但是卻是用心在寫,前後花了一個星期的時間。希望能給需要的朋友一點幫助或者啓發,價值便是我的動力所在。
有不同意見的朋友歡迎留言討論,沒事找事的朋友,歡迎來戰。
感謝
感謝板磚團隊的MyBricks產品,它提供了寶貴的思路,讓Rxdrag的邏輯編排部分得以完成。
感謝網友陌路及其團隊成員提供的支持,每次迷茫的時候,跟他們討論一下,總會有一種豁然開朗的感覺。
感謝網友青銅提供支持,正是因爲他的鼎力相助,才能讓我這個環境小白成功的做了一個Monorepo項目。
感謝一致以來關注跟支持我的朋友,朋友們的支持給了我莫大的鼓勵,希望在未來的日子裏,我們還能相互陪伴一起走的更遠。
最後,感謝CCTV,雖然不知道它做了什麼,但是總覺還是要提前感謝一下,說不定它某一天真能做些什麼。
希望在以後的日子裏,幫到的人越來越多。也希望以後文章的感謝列表越來越長。