1 引言
2 LiteFlow簡介
2.1 引入jar包
2.2 定義元件
2.3 執行流程
2.4 官網
3 視覺化編排(形態進階)
3.1 為什麽要視覺化
3.2 方案設計
3.3 推拉結合重新整理流程
3.4 源碼
4 效果收益&未來規劃
4.1 效果收益
4.2 未來規劃
1 引言
LiteFlow解決哪些場景的問題呢?透過下面的例子感受一下。
假設有三個元件(或方法)stepOne、stepTwo、stepThree,並且你想要按照順序打印"one"、"two"、"three",通常我們編寫程式碼的方式可能是這樣的:
@Component
public classPrintService{
/**
* 執行業務程式碼
*/
privatevoiddoExecute(){
stepOne();
stepTwo();
stepThree();
}
privatevoidstepOne(){
// 業務程式碼
System.out.println("one");
}
privatevoidstepTwo(){
// 業務程式碼
System.out.println("two");
}
privatevoidstepThree(){
// 業務程式碼
System.out.println("three");
}
}
這樣寫最簡單粗暴,但是如果之後有調整打印順序的話,例如你想打印two、one、three,或者直接跳過two直接打印one、three,你一定需要修改程式碼並且重新上線。
// 打印two、one、three
publicvoiddoExecute(){
stepTwo();
stepOne();
stepThree();
}
// 打印one、three
publicvoiddoExecute(){
stepOne();
stepThree();
}
對於需要動態調整執行流程的業務場景,顯然不適合將流程寫死在程式碼中。
2 LiteFlow簡介
LiteFlow是一款編排式的規則引擎框架,可以透過運算式的方式來編排元件或方法的執行流程,並且支持一些高級的流程編排。
上述案例如何透過更高級的方式來實作零程式碼修改、無需重新上線即可編排流程了呢?我們基於LiteFlow做一些改造。
2.1 引入jar包
可以去官網根據需要選擇合適的版本,這裏用的是最新版本
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-spring-boot-starter</artifactId>
<version>2.12.0</version>
</dependency>
2.2 定義元件
將打印功能分別定義成一個個元件,繼承NodeComponent 這個抽象父類並實作其中的方法:
@Component
public classPrintOneextendsNodeComponent{
@Override
publicvoidprocess()throws Exception {
// 業務程式碼
System.out.println("one");
}
}
@Component
public classPrintTwoextendsNodeComponent{
@Override
publicvoidprocess()throws Exception {
// 業務程式碼
System.out.println("two");
}
}
@Component
public classPrintThreeextendsNodeComponent{
@Override
publicvoidprocess()throws Exception {
// 業務程式碼
System.out.println("three");
}
}
2.3 執行流程
定義好元件之後,我們就開始編寫元件執行的流程運算式了,官方名稱叫EL運算式;上述案例可以這樣編寫運算式:
THEN(node("printOne"),node("printTwo"),node("pirntThree"));
並給這個流程起個名字(流程唯一標識):print_flow
根據流程名稱執行流程:
@Component
public classPrintService{
@Autowired
private FlowExecutor flowExecutor;
/**
* 執行業務程式碼
*/
publicvoiddoExecute(){
// 開始執行流程
LiteflowResponse response = flowExecutor.execute2Resp("print_flow");
// 根據執行結果進行後續操作
// ......
}
}
一般我們會將流程放到資料庫中,如果想改變打印順序,只需要修改運算式即可,例如:打印two、one、three。
THEN(node("printTwo"),node("printOne"),node("pirntThree"));
打印two、three。
THEN(node("printTwo"),node("printThree"));
然後LiteFlow真正強大的地方遠不止如此,它不僅僅支持簡單的序列編排,還支持更加復雜的邏輯例如(並列編排) WHEN 、(條件編排) IF 、(選擇編排) SWITCH 、(迴圈編排) FOR 等。
2.4 官網
上述的簡單範例旨在為不熟悉LiteFlow框架的夥伴們提供一個初步的認知。要想真正基於LiteFlow將業務流程落地並運用到實際業務場景中,還需要透過官方文件深入了解該框架的運作原理和核心機制。
https://liteflow.cc/
3 視覺化編排(形態進階)
3.1 為什麽要視覺化
官網提供修改運算式的方式只有一個,那就是手寫!官網並沒有提供配套的視覺化工具,手寫可能存在諸多問題和不便,例如:
容易出錯:運算式少一個字母甚至一個逗號都不行!
流程不可視:我們只能完全依賴大腦去構想這些流程,營運或產品團隊想要了解或討論流程,也只能依賴於其他畫圖工具來手動繪制和表達。
節點不可配置:我們的營運會根據不同的場景對節點進行動態配置,沒有視覺化界面,營運改動配置的需求則無從下手。
所以視覺化對於編排流程來說意義重大,對於研發能更準確地理解和設計流程,還能讓營運能更便捷地監控和管理流程。
3.2 方案設計
網上有一些網友開源的計畫,但基本都是個人維護,對於復雜流程的處理不是很好,品質也參差不齊,所以自己進行了調研和設計;支持普通節點、判斷節點、選擇節點、並列節點;迴圈節點目前業務不需要,有需要的可以自己拓展,掌握方案之後拓展節點型別非常簡單。完成視覺化編排需要解決兩個問題:
一款與使用者互動的前端畫布(推薦logicFlow,有自己熟悉的也行)
將畫布數據轉化成EL運算式(手寫演算法,基於DFS的遞迴)
這裏重點講畫布數據轉化為EL的過程。
3.2.1 整體流程
建立流程
流程的核心在第5步,下面會重點講解。
回顯流程
解析EL成本很高,所以我選擇不解析運算式,直接將前端傳入的畫布json數據返回給前端進行回顯。
3.2.2 後端抽象語法樹設計
節點型別列舉
publicenum NodeEnum {
// 普通節點,對應普通元件
COMMON,
// 並列節點,對應並列元件
WHEN,
// 判斷節點,對應判斷元件
IF,
// 選擇節點,對應選擇元件
SWITCH,
// 匯總節點(自訂)
SUMMARY,
// 開始節點(自訂)
START,
// 結束節點(自訂)
END;
}
COMMON
普通節點,入度和出度都為1。
IF
判斷節點,包含一個true分支,一個false分支,入度為1,出度為2。
SWITCH
根據SWITCH返回的tag,來決定執行後續哪個流程。入度為1,出度大於1。
WHEN
官網沒有WHEN節點的概念,我這裏自訂WHEN節點會避免很多問題。
為什麽要定義WHEN節點?
WHEN作為一個出度大於1的節點,和IF、SWITCH不同的是WHEN並沒有一個前置節點去驅動一個流程。
假設這樣一個流程,如果沒有WHEN節點的支持,展示到畫布上的效果很差。
THEN(
IF(node("c"),
WHEN(
node("a"),
node("b"),
node("d"),
node("e")
).ignoreError(true)
),
node("f")
)
SUMMARY
官網沒有這種節點,自訂節點,用於匯總所有分支節點,也就是WHEN、IF、SWITCH節點。入度大於1,出度為1。
為什麽要定義SUMMARY節點?
構建EL演算法是基於遞迴實作的,參考的是深度優先遍歷演算法(DFS),這種巢狀方式如果沒有一個結束標誌會一直執行下去。
舉個例子:
基於圖1生成EL運算式
THEN(
node("c"),
WHEN(
THEN(node("b"),node("e")),
THEN(node("d"),node("e"))
)
)
基於圖2生成EL運算式
THEN(
node("c"),
WHEN(node("b"),node("d")),
node("e")
)
可以看出來圖2的EL運算式才是我們想要的。
市面上有名的工作流引擎在畫布上處理匯總問題也是這樣設計的,比如在activiti中使用並列閘道器開啟會簽,也必須用並列閘道器在會簽結束時進行匯總,否則就會出現重復審批的問題。
START
開始節點,一個流程必須有一個開始節點,入度為0,出度為1。
END
結束節點,一個流程必須有一個結束節點,入度為1,出度為0。
上述節點型別的類別定義
// 抽象父類
@Getter
publicabstract classNode{
// node的唯一id
privatefinal String id;
// node名稱,對應LiteFlow的Bean名稱
privatefinal String name;
// 入度
privatefinal List<Node> pre = Lists.newArrayList();
// 節點型別
privatefinal NodeEnum nodeEnum;
// 出度
privatefinal List<Node> next = Lists.newArrayList();
protectedNode(String id, String name, NodeEnum nodeEnum){
this.id = id;
this.name = name;
this.nodeEnum = nodeEnum;
}
publicvoidaddNextNode(Node node){
next.add(node);
}
publicvoidaddPreNode(Node preNode){
pre.add(preNode);
}
}
// 普通節點
public classCommonNodeextendsNode{
publicCommonNode(@NonNull String id, @NonNull String name){
super(id, name, NodeEnum.COMMON);
}
}
// 並列節點
public classWhenNodeextendsNode{
publicWhenNode(@NonNull String id, @NonNull String name){
super(id, name, NodeEnum.WHEN);
}
}
// 判斷節點
@Getter
public classIfNodeextendsNode{
private Node trueNode;
private Node falseNode;
publicIfNode(@NonNull String id, @NonNull String name){
super(id, name, NodeEnum.IF);
}
publicvoidsetTrueNode(Node trueNode){
this.trueNode = trueNode;
super.addNextNode(trueNode);
}
publicvoidsetFalseNode(Node falseNode){
this.falseNode = falseNode;
super.addNextNode(falseNode);
}
}
// 選擇節點
@Getter
public classSwitchNodeextendsNode{
privatefinal Map<Node, String> nodeTagMap = Maps.newHashMap();
publicSwitchNode(@NonNull String id, @NonNull String name){
super(id, name, NodeEnum.SWITCH);
}
publicvoidputNodeTag(Node node, String tag){
nodeTagMap.put(node, tag);
super.addNextNode(node);
}
}
// 開始節點
public classStartNodeextendsNode{
publicStartNode(@NonNull String id, @NonNull String name){
super(id, name, NodeEnum.START);
}
}
// 結束節點
public classEndNodeextendsNode{
publicEndNode(@NonNull String id, @NonNull String name){
super(id, name, NodeEnum.END);
}
}
// 匯總節點
public classSummaryNodeextendsNode{
publicSummaryNode(@NonNull String id, @NonNull String name){
super(id, name, NodeEnum.SUMMARY);
}
}
3.2.3 畫布JSON數據設計
畫布數據最終體現在JSON語法樹,數據結構如下:
{
"nodeEntities": [
{
"id": "節點的唯一id,由前端生成。必填",
"name": "節點名稱,對應LiteFlow的節點名稱,spring的beanName。必填",
"label": "前端節點展示名稱,到時候給前端。必填",
"nodeType": "節點的型別,有COMMON、IF、SWITCH、WHEN、START、END和SUMMARY。必填",
"x": "x座標。必填",
"y": "y座標。必填"
}
],
"nodeEdges": [
{
"source": "源節點。必填",
"target": "目標節點。必填",
"ifNodeFlag": "if型別節點的true和false,只有ifNode時必填,其他node隨意",
"tag": "switch型別的下層節點的tag,主機有switchNode時必填,其他node隨意"
}
]
}
使用者拖動畫布節點和節點之間連線的過程,其實就是維護節點陣列和邊陣列的過程。
3.2.4 畫布JSON數據合法校驗
下面是針對畫布json數據的一些簡單合法性校驗,可以自己根據需要拓展,實作很簡單,最後有具體實作程式碼,需要的可以下載。
流程必須有一個開始節點和一個結束節點
校驗節點型別,只能是IF、WHEN、COMMON、SWITCH、START、END和SUMMARY
IF、WHEN、SWITCH節點的數量總和與SUMMARY型別節點數量總和校驗
校驗節點和邊的source和target是否能對應上
校驗SWITCH的出度邊是否有tag,且tag不能為空
校驗IF節點有沒有ifNodeFlag的標識,並且總有一條true分支,總有一條false分支
3.2.5 畫布JSON數據轉化為抽象語法樹
舉個簡單的例子:
對應的JSON語法樹如下;避免篇幅過長,這裏只列舉了部份內容。{
"nodeEntities": [
{
"id": "a",
"label": "a",
"nodeType": "COMMON"
},
{
"id": "e",
"label": "e",
"nodeType": "WHEN"
},
{
"id": "b",
"label": "b",
"nodeType": "COMMON"
},
......
],
"nodeEdges": [
{
"source": "a",
"target": "e",
},
{
"source": "e",
"target": "b",
},
{
"source": "e",
"target": "c",
},
......
]
}
JSON轉化為抽象語法樹,實際就是建立節點物件,並維護節點的內容,下面是虛擬碼。
// 建立節點物件
List<Node> nodes = Lists.newArrayList();
for (NodeEntity nodeEntity : nodeEntities) {
Node node = null;
switch (nodeEntity.getNodeType()) {
case NodeEnum.COMMON;
node = new CommonNode("節點的id", "節點的label");
break;
case NodeEnum.WHEN;
node = new WhenNode("節點的id", "節點的label");
break;
case NodeEnum.SUMMARY;
node = new SummaryNode("節點的id", "節點的label");
break;
default:
thrownew RuntimeException("未知的節點型別!");
}
nodes.add(node);
}
// 構建nodeId和node的map
Map<String, Node> nodeIdAndNodeMap = nodes.stream()
.collect(Collectors.toMap(Node::getId, Function.identity()));
// 維護節點間關系
for (NodeEdge nodeEdge : nodeEdges) {
Node sourceNode = nodeIdAndNodeMap.get(nodeEdge.getSource());
Node targetNode = nodeIdAndNodeMap.get(nodeEdge.getTarget());
sourceNode.addNextNode(targetNode);
targetNode.addPreNode(sourceNode);
......
}
疑問:為什麽要設計JSON和AST(抽象語法樹)兩種數據結構?
根據上述JSON數據可以發現,使用者編輯畫布時,前端只需要維護節點和邊兩個陣列即可;而生成EL運算式的操作在後端,生成方法是利用 遞迴 實作的 深度優先遍歷演算法(DFS ) ,顯然JSON是不滿足遞迴需求的,所以JSON轉換為AST。
總之設計JSON和AST就是為了方便前後端去各自維護數據。
3.2.6 抽象語法樹生成EL運算式
整個流程的核心就在這裏,AST生成EL運算式
同樣用上面的例子來模擬生成EL運算式過程,該流程只涉及THEN和WHEN,我們約定把THEN和WHEN當成陣列來處理,例如
THEN(node("a"),node("b"))
對應陣列
[node("a"),node("b")]
,同理WHEN。
流程必須以一個陣列開始。
[
node("a")
]
遇見WHEN分支節點e,建立一個新陣列,並加入上一層陣列。
[
node("a"),
[
]
]
分支節點之後的每一個分支都要建立一個陣列,並且加入到分支節點的陣列中。
[
node("a"),
[
[
node("b")
]
]
]
正常的序列,節點直接加入最內層陣列。
[
node("a"),
[
[
node("b"),
node("d")
]
]
]
遇見匯總節點,什麽也不處理。
[
node("a"),
[
[
node("b"),
node("d")
]
]
]
繼續向下,將f節點加入WHEN節點所在的陣列,到達遞迴的出口。
[
node("a"),
[
[
node("b"),
node("d")
]
],
node("f")
]
這可能有疑問,程式是如何定位到WHEN所在的陣列在哪呢?
利用棧,遇到WHEN節點的時候會將WHEN節點所在的陣列壓棧,等遇到匯總節點時將陣列出棧,那麽可以確定f節點應該加入出棧時的陣列了。
因為是從e節點開始有分支流程的,以b節點開頭的分支已經執行完,回溯到另一條分支;同樣c節點屬於e的一條分支,分支節點之後的每一個分支都要建立一個陣列,並且加入到分支節點的陣列中。
[
node("a"),
[
[
node("b"),
node("d")
],
[
node("c")
]
],
node("f")
]
到了匯總節點,因為遍歷以b節點開頭的分支時已經存取了該匯總節點,這次不處理,到達遞迴的出口。
[
node("a"),
[
[
node("b"),
node("d")
],
[
node("c")
]
],
node("f")
]
如何判斷匯總節點是否存取過?
用Set,存取過的匯總節點加入Set中,下次再存取先判斷Set中有沒有該匯總節點,有就不往下執行,到達遞迴出口。
結束!
根據上面簡單範例,下面是用 遞迴 實作 DFS 的虛擬碼;文末有全量源碼,感興趣的可以下載參考一下。
publicstatic String ast2El(Node head){
if (head == null) {
returnnull;
}
// 用於存放when節點List
Deque<List> stack = new ArrayDeque<>();
// 用於標記是否處理過summary節點了
Set<String> doneSummary = Sets.newHashSet();
List list = tree2El(head, new ArrayList(), stack, doneSummary);
// 將list生成EL,你可以認為框架有對應的方法
return toEL(list);
}
privatestatic List tree2El(Node currentNode,
List currentThenList,
Deque<List> stack,
Set<String> doneSummary){
switch (currentNode.getNodeEnum()) {
case COMMON:
currentThenList.add(currentNode.getId());
for (Node nextNode : currentNode.getNext()) {
tree2El(nextNode, currentThenList, stack, doneSummary);
}
case WHEN:
stack.push(currentThenList);
List whenELList = new ArrayList<>();
currentThenList.add(whenELList);
for (Node nextNode : currentNode.getNext()) {
List thenELList = new ArrayList<>();
whenELList.add(thenELList);
tree2El(nextNode, thenELList, stack, doneSummary);
}
case SUMMARY:
if (!doneSummary.contains(currentNode.getId())) {
doneSummary.add(currentNode.getId());
// 這種節點只有0個或者1個nextNode
for (Node nextNode : currentNode.getNext()) {
tree2El(nextNode, stack.pop(), stack, doneSummary);
}
}
default:
thrownew RuntimeException("未知的節點型別!");
}
return currentThenList;
}
3.2.7 校驗EL運算式的合法性
這是生成EL運算式的最後一步;框架有本身有支持校驗EL合法性的方法,在生成EL之後進行校驗。
// 校驗是否符合EL語法
Boolean isValid = LiteFlowChainELBuilder.validate(el);
進行完最後一步,EL運算式就可以入庫了。
3.3 推拉結合重新整理流程
流程入庫之後並不是立即生效,進行以下操作後生效。
3.3.1 拉
框架會定期從資料庫(或透過配置指定的任何資料來源)中同步最新流程,並將這些流程緩存在記憶體中;新流程同步和緩存的過程是平滑進行的,不會幹擾或打斷現有流程的執行;該框架還允許使用者根據實際需求配置數據重新整理的時間間隔(預設1分鐘),具體配置方法可參照官方文件進行詳細了解。
3.3.2 推
如果我們希望改動的EL運算式立即生效而不是等待框架被動重新整理,我們可以透過官方提供的api進行主動重新整理:
flowExecutor.reloadRule();
需要註意的的是,官方提供的方法只是重新整理單個例項節點的流程;如果是集群環境,我們需要借助訊息佇列以達到通知整個集群的效果。
3.4 源碼
目前這套設計方案已在實際業務場景落地並使用;自己進行過很多復雜流程的驗證,基於這種規則能百分百保證生成EL運算式的正確性。
自己的寫的demo,可以借鑒一下思路;裏面有一個構造好的復雜流程案例,透過調介面的方式自己感受。
https://dl.zhuanstatic.com/fecommon/liteFlow-el.zip
4 效果收益&未來規劃
透過引入流程的視覺化編排,結合LiteFlow框架的支持,顯著提升了流程設計的直觀性和開發效率,為計畫帶來了更為順暢和高效的開發體驗。
4.1 效果收益:
開發人員只需要專註於核心的業務流程設計,而無需在語法規則上耗費過多精力。
透過直觀的視覺化流程界面,產品和研發團隊之間的溝通變得更為高效,復雜的業務邏輯能夠清晰展現,避免了不必要的溝通。
營運能夠即時編輯流程節點,並快速了解節點的內容配置;例如「黑名單校驗」節點中配置了哪些使用者,從而更加靈活地管理業務流程。
4.2 未來規劃
痛點 :
流程編排只是針對現有節點,對於新的業務節點,依然需要開發。
對外提供服務可能需要呼叫方提供較為詳盡的參數資訊。
規劃 :
希望未來借助動態指令碼,實作全新業務流程的快速搭建,無需進行任何開發工作。
引入數據字典的概念,將常用的參數整合為數據字典,例如只需要一個訂單號,便能根據數據字典獲取該流程想要的參數,從而降低呼叫方的開發成本。
關於作者
蔣韜,轉轉回收技術部的後端工程師
IT交流群
組建了程式設計師,架構師,IT從業者交流群,以
交流技術
、
職位內推
、
行業探討
為主
加小編 好友 ,備註"加群"