當前位置: 妍妍網 > 碼農

賦能轉轉回收:LiteFlow視覺化編排方案設計

2024-06-21碼農


  • 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。

    1. 流程必須以一個陣列開始。

    [
    node("a")
    ]

    1. 遇見WHEN分支節點e,建立一個新陣列,並加入上一層陣列。

    [
    node("a"),
    [
    ]
    ]

    1. 分支節點之後的每一個分支都要建立一個陣列,並且加入到分支節點的陣列中。

    [
    node("a"),
    [
    [
    node("b")
    ]
    ]
    ]

    1. 正常的序列,節點直接加入最內層陣列。

    [
    node("a"),
    [
    [
    node("b"),
    node("d")
    ]
    ]
    ]

    1. 遇見匯總節點,什麽也不處理。

    [
    node("a"),
    [
    [
    node("b"),
    node("d")
    ]
    ]
    ]

    1. 繼續向下,將f節點加入WHEN節點所在的陣列,到達遞迴的出口。

    [
    node("a"),
    [
    [
    node("b"),
    node("d")
    ]
    ],
    node("f")
    ]

    這可能有疑問,程式是如何定位到WHEN所在的陣列在哪呢?

    利用棧,遇到WHEN節點的時候會將WHEN節點所在的陣列壓棧,等遇到匯總節點時將陣列出棧,那麽可以確定f節點應該加入出棧時的陣列了。

    1. 因為是從e節點開始有分支流程的,以b節點開頭的分支已經執行完,回溯到另一條分支;同樣c節點屬於e的一條分支,分支節點之後的每一個分支都要建立一個陣列,並且加入到分支節點的陣列中。

    [
    node("a"),
    [
    [
    node("b"),
    node("d")
    ],
    [
    node("c")
    ]
    ],
    node("f")
    ]

    1. 到了匯總節點,因為遍歷以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 效果收益:

    1. 開發人員只需要專註於核心的業務流程設計,而無需在語法規則上耗費過多精力。

    2. 透過直觀的視覺化流程界面,產品和研發團隊之間的溝通變得更為高效,復雜的業務邏輯能夠清晰展現,避免了不必要的溝通。

    3. 營運能夠即時編輯流程節點,並快速了解節點的內容配置;例如「黑名單校驗」節點中配置了哪些使用者,從而更加靈活地管理業務流程。

    4.2 未來規劃

    痛點

    1. 流程編排只是針對現有節點,對於新的業務節點,依然需要開發。

    2. 對外提供服務可能需要呼叫方提供較為詳盡的參數資訊。

    規劃

    1. 希望未來借助動態指令碼,實作全新業務流程的快速搭建,無需進行任何開發工作。

    2. 引入數據字典的概念,將常用的參數整合為數據字典,例如只需要一個訂單號,便能根據數據字典獲取該流程想要的參數,從而降低呼叫方的開發成本。

    關於作者

    蔣韜,轉轉回收技術部的後端工程師

    IT交流群

    組建了程式設計師,架構師,IT從業者交流群,以 交流技術 職位內推 行業探討 為主

    加小編 好友 ,備註"加群"