规则引擎Liteflow可视化形态进阶

1 引言

Liteflow解决哪些场景的问题呢?通过下面的例子感受一下。

假设有三个组件(或方法)stepOne、stepTwo、stepThree,并且你想要按照顺序打印 “one”、“two”、“three”,通常我们编写代码的方式可能是这样的:

@Component
public class PrintService {
    /**
     * 执行业务代码
     */
    private void doExecute() {
        stepOne();
        stepTwo();
        stepThree();
    }

    private void stepOne() {
        // 业务代码
        System.out.println("one");
    }

    private void stepTwo() {
        // 业务代码
        System.out.println("two");
    }

    private void stepThree() {
        // 业务代码
        System.out.println("three");
    }
}

这样写最简单粗暴,但是如果之后有调整打印顺序的话,例如你想打印two、one、three,或者直接跳过two直接打印one、three,你一定需要修改代码并且重新上线。

	// 打印two、one、three
    public void doExecute() {
        stepTwo();
        stepOne();
        stepThree();
    }
    // 打印one、three
    public void doExecute() {
        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 class PrintOne extends NodeComponent {

    @Override
    public void process() throws Exception {
        // 业务代码
        System.out.println("one");
    }
}

@Component
public class PrintTwo extends NodeComponent {

    @Override
    public void process() throws Exception {
        // 业务代码
        System.out.println("two");
    }
}

@Component
public class PrintThree extends NodeComponent {

    @Override
    public void process() throws Exception {
        // 业务代码
        System.out.println("three");
    }
}

2.3 执行流程

定义好组件之后,我们就开始编写组件执行的流程表达式了,官方名称叫EL表达式;
上述案例可以这样编写表达式:

THEN(node(“printOne”),node(“printTwo”),node(“pirntThree”));

并给这个流程起个名字(流程唯一标识):print_flow
根据流程名称执行流程:

@Component
public class PrintService {

    @Autowired
    private FlowExecutor flowExecutor;

    /**
     * 执行业务代码
     */
    public void doExecute() {
        // 开始执行流程
        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 可视化编排(需要有Liteflow基础)

3.1 为什么要可视化

官网提供修改表达式的方式只有一个,那就是手写!官网并没有提供配套的可视化工具,手写可能存在诸多问题和不便,例如:

1. 容易出错:表达式少一个字母甚至一个逗号都不行!
2. 流程不可视:我们只能完全依赖大脑去构想这些流程。即使是运营或产品团队想要了解或讨论流程,也只能依赖于其他画图工具来手动绘制和表达;每次去修改表达式都需要去维护绘制的流程图。
3. 节点不可配置:我们的运营会根据不同的场景对节点进行动态配置,没有可视化界面,运营改动配置的需求则无从下手。

所以可视化对于编排流程来说意义重大,对于研发能更准确地理解和设计流程,还能让运营能更便捷地监控和管理流程。

3.2 方案设计

网上有一些网友开源的项目,但对于复杂流程的处理不是很好,所以自己进行了调研和设计;支持普通节点、判断节点、选择节点、并行节点;循环节点目前业务不需要,有需要的可以自己拓展,掌握方案之后拓展节点类型非常简单。
完成可视化编排需要解决两个问题:

1. 一款与用户交互的前端画布(推荐logicFlow,有自己熟悉的也行)
2. 将画布信息转化成EL表达式(手写算法,基于DFS的递归)

3.2.1 整体流程

创建画布
流程的核心在第5步,下面会重点讲解。

1.前端画布
构建流程
2.生成json语
法树传给后端
3.json语法树
合法性校验
4.json画布数据
转化成抽象语法树
5.抽象语法树
生成EL表达式
6.检验EL表达
式有效性
7.入库
8.推拉结合
刷新流程

回显画布
解析EL成本很高,所以我选择不解析表达式,直接将前端传入的画布json数据返回给前端进行回显。

3.2.2 后端抽象语法树设计

节点类型枚举

public enum NodeEnum {
    // 普通节点,对应普通组件
    COMMON,
    // 并行节点,对应并行组件
    WHEN,
    // 判断节点,对应判断组件
    IF,
    // 选择节点,对应选择组件
    SWITCH,
    // 汇总节点(自定义)
    SUMMARY,
    // 开始节点(自定义)
    START,
    // 结束节点(自定义)
    END;
}

COMMON
普通节点,入度和出度都为1
普通节点
WHEN
判断节点,包含一个true分支,一个false分支,入度为1,出度为2
并行节点
IF
根据SWITCH返回的tag,来决定执行后续哪个流程。入度为1,出度大于1。
选择节点
WHEN
官网没有WHEN节点的概念,我这里自定义WHEN节点会避免很多问题。
并行节点
为什么要定义WHEN节点?

WHEN作为一个出度大于1的节点,和IF、SWITCH不同的是WHEN并没有一个前置节点去驱动一个流程。

THEN(
	IF(node("c"), 
		WHEN(
			node("a"),
			node("b"),
			node("d"),
			node("e")
		).ignoreError(true)
	),
	node("f")
)

假设这样一个流程,如果没有WHEN节点的支持,展示到画布上的效果很差。
在这里插入图片描述
SUMMARY
官网没有这种节点,自定义节点,用于汇总所有分支节点,也就是WHEN、IF、SWITCH节点。入度大于1,出度为1
在这里插入图片描述
为什么要定义SUMMARY节点?

构建EL算法是基于递归实现的,参考的是深度优先遍历算法(DFS),这种嵌套方式如果没有一个结束标志会一直执行下去。

举个例子:
在这里插入图片描述
基于图一生成EL表达式

THEN(
	node("c"),
	WHEN(
		THEN(node("b"),node("e")),
		THEN(node("d"),node("e"))
	)
)

基于图二生成EL表达式

THEN(
	node("c"),
	WHEN(node("b"),node("d")),
	node("e")
)

可以看出来图二EL表达式才是正确的。

START
开始节点,一个流程必须有一个开始节点,入度为0,出度为1
END
结束节点,一个流程必须有一个结束节点,入度为1,出度为0
上述节点类型的类定义

// 抽象父类
@Getter
public abstract class Node {

    // node的唯一id
    private final String id;

    // node名称,对应LIteFlow的Bean名称
    private final String name;

    // 入度
    private final List<Node> pre = Lists.newArrayList();

    // 节点类型
    private final NodeEnum nodeEnum;

    // 出度
    private final List<Node> next = Lists.newArrayList();

    protected Node(String id, String name, NodeEnum nodeEnum) {
        this.id = id;
        this.name = name;
        this.nodeEnum = nodeEnum;
    }

    public void addNextNode(Node node) {
        next.add(node);
    }

    public void addPreNode(Node preNode) {
        pre.add(preNode);
    }
}
// 普通节点
public class CommonNode extends Node {

    public CommonNode(@NonNull String id, @NonNull String name) {
        super(id, name, NodeEnum.COMMON);
    }
}
// 并行节点
public class WhenNode extends Node {

    public WhenNode(@NonNull String id, @NonNull String name) {
        super(id, name, NodeEnum.WHEN);
    }
}
// 判断节点
@Getter
public class IfNode extends Node {

    private Node trueNode;

    private Node falseNode;

    public IfNode(@NonNull String id, @NonNull String name) {
        super(id, name, NodeEnum.IF);
    }

    public void setTrueNode(Node trueNode) {
        this.trueNode = trueNode;
        super.addNextNode(trueNode);
    }

    public void setFalseNode(Node falseNode) {
        this.falseNode = falseNode;
        super.addNextNode(falseNode);
    }
}
// 选择节点
@Getter
public class SwitchNode extends Node {

    private final Map<Node, String> nodeTagMap = Maps.newHashMap();

    public SwitchNode(@NonNull String id, @NonNull String name) {
        super(id, name, NodeEnum.SWITCH);
    }

    public void putNodeTag(Node node, String tag) {
        nodeTagMap.put(node, tag);
        super.addNextNode(node);
    }
}
// 开始节点
public class StartNode extends Node {

    public StartNode(@NonNull String id, @NonNull String name) {
        super(id, name, NodeEnum.START);
    }
}
// 结束节点
public class EndNode extends Node {

    public EndNode(@NonNull String id, @NonNull String name) {
        super(id, name, NodeEnum.END);
    }
}
// 汇总节点
public class SummaryNode extends Node {

    public SummaryNode(@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和AST(抽象语法树)两种数据结构?

根据上述JSON数据可以发现,用户编辑画布时,前端只需要维护节点和边两个数组即可;而生成EL表达式的操作在后端,生成方法是利用递归实现的深度优先遍历算法(DFS),显然JSON是不满足递归需求的,所以JSON转换为AST。

设计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中有没有该汇总节点,有就不往下执行,到达递归出口。

3.2.7 校验EL表达式的合法性

这是生成EL表达式的最后一步;框架有本身有支持校验EL合法性的方法,在生成EL之后进行校验。

// 校验是否符合EL语法
Boolean isValid = LiteFlowChainELBuilder.validate(el);

进行完最后一步,EL表达式就可以入库了。

3.3 推拉结合刷新流程

3.3.1 拉

框架会定期从数据库(或通过配置指定的任何数据源)中同步最新数据,并将这些数据缓存在内存中;数据同步和缓存的过程是平滑进行的,不会干扰或打断现有流程的执行;该框架还允许用户根据实际需求配置数据刷新的时间间隔,具体配置方法可参照官方文档进行详细了解。

3.3.2 推

如果我们希望改动的EL表达式立即生效而不是等待框架被动刷新,我们可以通过官方提供的api进行主动刷新:

flowExecutor.reloadRule();

需要注意的的是,官方提供的方法只是刷新单个实例节点的流程;如果是集群环境,我们需要借助消息队列以达到通知整个集群的效果。

3.4 源码

目前这套设计方案已在实际业务场景落地并使用。
在这里插入图片描述

目前这套设计方案已在实际业务场景落地并使用;自己进行过很多复杂流程的验证,基于这种规则能百分百保证生成EL表达式的正确性。
点击下载

<think>好的,我现在需要帮用户理解他们提供的代码中的递归可视化部分。用户之前已经得到了一个关于汉诺塔递归调用的详细解析,现在他们希望更深入地了解递归过程是如何可视化的。首先,我需要回顾之前的对话,确保这次回答的连贯性。 用户的问题集中在递归可视化,也就是如何将递归调用的步骤以可视化的方式呈现出来。在之前的回复中,已经有一个递归过程可视化的表格,用n=3的情况展示了步骤、操作和参数交换逻辑。用户可能希望更详细地了解这个表格是如何构建的,或者想了解如何自己生成这样的可视化过程。 接下来,我需要分析用户的需求。他们可能在学习递归时遇到了困难,希望通过可视化来更好地理解递归调用的流程。汉诺塔问题是一个经典的递归案例,但递归的层层调用和参数变化可能会让人感到困惑。因此,用户需要一种直观的方式来跟踪每一步递归调用及其对应的实际操作。 用户提到的“递归可视化”可能指的是如何将递归的抽象过程转化为具体的步骤说明,比如表格或流程图。这可能包括递归的展开、参数的交换、以及每一步移动盘子的具体操作。需要确保解释清晰,结构分明,帮助用户逐步理解递归的执行过程。 现在,我需要考虑如何组织回答。首先,回顾汉诺塔问题的基本递归逻辑,然后以n=3为例,详细分解每一步的递归调用、参数变化和实际操作。可能需要使用示例说明,比如步骤分解表格,并解释每个步骤对应的代码执行部分。同时,要强调参数交换在递归中的作用,以及如何通过参数顺序的变化来实现角色切换,而无需显式的变量交换。 另外,需要确保回答符合之前的格式要求:使用中文,行内数学表达式用$...$,独立公式用$$...$$,结构清晰,步骤明确。同时,要检查是否有遗漏的关键点,比如递归栈的状态变化,或者为什么参数交换能有效管理递归过程。 用户可能存在的潜在疑问包括:为什么参数交换的顺序会影响盘子的移动?递归调用是如何维护状态的?每个递归层次对应的盘子移动是怎样的?这些问题需要在回答中得到解答,以帮助用户彻底理解可视化过程。 最后,需要总结递归可视化的关键点,强调参数交换和递归调用的对应关系,以及如何通过这种可视化方法加深对递归的理解。确保语言通俗易懂,避免过于技术化的术语,同时保持专业性,帮助用户建立清晰的递归思维模型。</think>--- ### 递归可视化深度解析(以n=3为例) 我们通过**递归展开时序表**和**调用栈空间变化**两个维度,完整还原代码执行轨迹。以下为逐层拆解: --- #### 一、递归执行时序图 **初始调用**:`hanoi("A", "B", "C", 3)` **总移动次数**:$2^3 -1 =7$次(符合汉诺塔公式) | 步骤 | 操作 | 参数状态 | 当前移动 | 递归深度 | 调用栈变化 | |------|-----------------------|-------------------|-----------------|----------|------------------------| | 1 | `hanoi(A,B,C,3)`进入 | (A,B,C,3) | - | 1 | [主调用] | | 2 | ↳调用`hanoi(A,C,B,2)` | (A,C,B,2) | 进入第一层递归 | 2 | [A,B,C,3]→[A,C,B,2] | | 3 | ↳调用`hanoi(A,B,C,1)` | (A,B,C,1) | 进入第二层递归 | 3 | +[A,B,C,1] | | 4 | ↳执行`A→C` (cnt=1) | 完成最上层移动 | **#1移动** | 3→2 | 弹出[A,B,C,1] | | 5 | 执行`A→B` (cnt=2) | 移动中层盘子 | **#2移动** | 2 | 当前栈顶[A,C,B,2] | | 6 | ↳调用`hanoi(C,A,B,1)` | (C,A,B,1) | 新递归分支 | 3 | +[C,A,B,1] | | 7 | ↳执行`C→B` (cnt=3) | 完成中层后续移动 | **#1移动** | 3→2 | 弹出[C,A,B,1] | | 8 | 执行`A→C` (cnt=4) | 移动底层大盘子 | **#3移动** | 1→2 | 弹出[A,C,B,2] | | 9 | ↳调用`hanoi(B,A,C,2)` | (B,A,C,2) | 右子树递归开始 | 2 | +[B,A,C,2] | | 10 | ↳调用`hanoi(B,C,A,1)` | (B,C,A,1) | 进入第三层递归 | 3 | +[B,C,A,1] | | 11 | ↳执行`B→A` (cnt=5) | 右侧首次移动 | **#1移动** | 3→2 | 弹出[B,C,A,1] | | 12 | 执行`B→C` (cnt=6) | 移动右侧中层盘子 | **#2移动** | 2 | 当前栈顶[B,A,C,2] | | 13 | ↳调用`hanoi(A,B,C,1)` | (A,B,C,1) | 最终递归调用 | 3 | +[A,B,C,1] | | 14 | ↳执行`A→C` (cnt=7) | 完成最终移动 | **#1移动** | 3→0 | 栈空,程序结束 | --- #### 二、关键过程动画演示 **移动序列**:`A→C, A→B, C→B, A→C, B→A, B→C, A→C` 对应代码输出: ``` #1: A->C #2: A->B #1: C->B #3: A->C #1: B->A #2: B->C #1: A->C ``` --- #### 三、递归调用树形结构 ``` hanoi(A,B,C,3) ├─ hanoi(A,C,B,2) │ ├─ hanoi(A,B,C,1) → A→C │ ├─ A→B │ └─ hanoi(C,A,B,1) → C→B ├─ A→C └─ hanoi(B,A,C,2) ├─ hanoi(B,C,A,1) → B→A ├─ B→C └─ hanoi(A,B,C,1) → A→C ``` --- ### 四、参数交换动态演示 通过**三柱角色轮换**实现逻辑交换(以第一个递归调用为例): 1. **原始参数**:`A(源)=A`, `B(辅助)=B`, `C(目标)=C` 2. **第一次递归调用**:`hanoi(A, C, B, n-1)` - 新参数解读: - 源柱保持为`A` - **原目标柱C变为辅助柱** - **原辅助柱B变为目标柱** 3. **效果**:在不修改实际柱子对象的情况下,通过参数传递实现逻辑位置交换 --- ### 五、递归栈内存变化 以步骤1-8为例,展示内存栈状态: ``` 执行前栈:[MAIN] 1. 压入hanoi(A,B,C,3) → 栈高=1 2. 压入hanoi(A,C,B,2) → 栈高=2 3. 压入hanoi(A,B,C,1) → 栈高=3 4. 弹出 → 栈高=2(完成A→C) 5. 保持 → 执行A→B 6. 压入hanoi(C,A,B,1) → 栈高=3 7. 弹出 → 栈高=2(完成C→B) 8. 弹出 → 栈高=1(完成A→C) ``` --- ### 六、可视化规律总结 1. **奇偶层交替**: - 奇数层递归:总是移动最小盘子 - 偶数层递归:处理中间盘子 2. **参数交换周期**: - 每深入一层递归,参数顺序按`(源, 新辅助, 新目标)`重组 - 重组规则:$新辅助 = 原目标$,$新目标 = 原辅助$ 3. **移动方向判定**: - 当递归深度$d$与盘子编号$k$满足$d=k$时,执行实际移动 - 移动方向由当前层的参数顺序决定 --- ### 七、调试辅助技巧 若要自行验证递归过程,可添加追踪代码: ```cpp void hanoi(string A, string B, string C, int n, int depth=0){ cout << "【递归进入】深度=" << depth << " 参数:" << A << "->" << C << " 使用辅助柱" << B << endl; // ...原有逻辑... cout << "【递归返回】深度=" << depth << " 完成移动:" << A << "->" << C << endl; } ``` **输出示例**: ``` 【递归进入】深度=0 参数:A->C 使用辅助柱B 【递归进入】深度=1 参数:A->B 使用辅助柱C 【递归进入】深度=2 参数:A->C 使用辅助柱B 【递归返回】深度=2 完成移动:A->C 【递归返回】深度=1 完成移动:A->B ...(后续输出省略)... ``` --- ### 终极理解钥匙 递归可视化的本质是**将多维递归展开为线性操作序列**。通过: 1. **参数重映射**:每个递归调用创建新的视角 2. **栈空间记录**:自动保存父级状态 3. **最简子问题**:当n=1时触发实际移动 这实现了用**有限代码**描述**指数级操作**的魔法效果。
评论 10
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值