☰
可视化规则编排实践:LogicFlow图数据到LiteFlow EL表达式的转换
2026/10/29 15:59:00 网站建设 项目流程

简介:这是一个面向 Web 应用开发者的可视化业务流程配置前端项目。项目以 LogicFlow 为流程编辑核心,结合轻量级规则引擎 Liteflow 的设计思路,让使用者通过拖拽节点、连线等操作直观地定义业务规则,并自动将流程配置转换为结构化 JSON 数据,再交给后端解析生成 EL 表达式,实现规则逻辑的可视化编排。资源压缩包共 22 个文件、约 180KB,主要包含 9 个 TypeScript 源码、2 个 Vue 组件、4 个 JSON 配置,以及 HTML、图片、License 等工程文件,是轻量级 Vue + TS 工程,自带入口文件、组件与视图分层目录及效果图片。目前已有 1496 人学习。适合前后端分离开发者、规则引擎初学者或流程设计器二次开发者,能够快速理解可视化配置到规则脚本生成的完整链路;也可作为企业级流程设计器的前端脚手架,参考其目录划分、组件拆分、数据格式约定与打包配置,直接复用或扩展为低代码规则配置模块。

1. 用LogicFlow画规则、让LiteFlow执行:这套配置页面的核心价值在哪

规则引擎liteflow负责把复杂的业务规则编排成可执行链路,但它的原生配置门槛不低——你要写THEN、IF、SWITCH组成的EL表达式,业务人员看一眼就放弃。前端架构LogicFlow恰好补上这块:它把流程编排变成拖拽画图,导出的JSON格式数据又可以程序化处理。于是就有了这个典型组合:前端用LogicFlow做配置页面,用户画完图生成JSON,后端把JSON翻译成LiteFlow的EL并执行。这套方案解决的痛点很直接:业务规则调整不再每次改代码发版,配置员在页面上拖一拖、填几个参数就能上线一条新规则。适合正在做规则平台、流程编排平台或想把审批流、风控策略交给业务自维护的团队。

2. 为什么不能把LogicFlow的JSON直接交给后端:两套数据模型的换算问题

前端画出来的是一条图,后端要执行的是一棵树。这两者看着像,实际差别很大。如果直接把LogicFlow吐出来的JSON丢给后端,LiteFlow是不认的,原因在于两套数据模型描述的不是同一个东西。

2.1 先看清LogicFlow输出的JSON格式:nodes 与 edges 两张表

LogicFlow的getGraphData()输出的是标准的图数据,核心是nodes和edges两个JSON数组。一个最简单的串行流程大概长这样:

{ "nodes": [ { "id": "node_1", "type": "rect", "x": 120, "y": 80, "properties": { "componentId": "orderCheck" } }, { "id": "node_2", "type": "rect", "x": 320, "y": 80, "properties": { "componentId": "stockDeduct" } }, { "id": "node_3", "type": "circle", "x": 520, "y": 80, "properties": {} } ], "edges": [ { "id": "edge_1", "type": "line", "sourceNodeId": "node_1", "targetNodeId": "node_2", "properties": {} }, { "id": "edge_2", "type": "line", "sourceNodeId": "node_2", "targetNodeId": "node_3", "properties": {} } ] }

这个JSON本质上是一张有向图:nodes记录节点位置和业务属性,edges记录谁指向谁。注意这里的type字段只是视觉效果,rect是矩形、circle是圆,跟执行语义毫无关系。真正决定执行行为的是properties里的自定义字段——比如我上面的示例里放了componentId,它指向后端某个LiteFlow组件。也就是说LogicFlow的图只能告诉你"有哪些节点、怎么连",完全没有"先执行谁、条件分支怎么走"这层语义,或者说语义全藏在properties里等着你自己约定。

2.2 LiteFlow真正要的不是图,是一串可执行的EL表达式

LiteFlow的核心抽象是Chain(链路),一条Chain由一段EL表达式描述。比如"先做订单校验,再扣库存"就写成:

THEN(orderCheck, stockDeduct)

带条件分支就写成:

IF(buyerVipCheck, THEN(vipDiscount, stockDeduct), THEN(stockDeduct))

语义由节点之间的组合关系决定——THEN表示顺序执行、IF表示条件分支、SWITCH表示多路分发、WHEN表示并行。EL是一棵嵌套的树,表达的是"执行顺序"和"分支逻辑"。而LogicFlow的JSON是一张平面图,只有节点和连线。从图到树,需要人脑做一次转换,这就是后端转换器存在的意义。

虽然LiteFlow也支持用JSON定义Chain,但EL字符串在动态拼接、版本对比、异常信息展示上都更直观。标题里写"交给后端生成EL",说明最终交付物就是一段EL表达式字符串,后端拿到它去加载和执行。

2.3 后端转换器要替前端补的三件事:语义映射、排序、环检测

从前端JSON到LiteFlow EL,至少有三种差异要抹平。第一是节点映射:LogicFlow里的rect、circle要映射成LiteFlow里的普通节点、IF节点、SWITCH节点,这通常靠前端在properties.type里写入业务语义类型,后端按这个字段转换。第二是排序:图里的节点是无序的,后端必须从起始节点出发做一次拓扑排序,才知道谁先谁后,不能直接按nodes数组下标拼串。第三是合法性检查:图允许画环、允许出现孤立节点,但EL表达式不允许死循环,所以环检测和孤立节点校验必须放在转换之前。

常见做法是把"前端画图 → 后端生成EL"当成一次编译过程:前端负责语法层面(图能不能画出来),后端负责语义层面(图能不能变成合法的EL)。后端转换器不信任前端的任何排序,只信任edges里的连线关系。

3. 后端转换落地:把LogicFlow JSON翻译成LiteFlow的EL表达式

这一章直接给可复现的代码。转换器用Java实现,依赖Jackson解析JSON,输出一段可执行的LiteFlow EL字符串。

3.1 后端工程的最小依赖:liteflow starter 和 运行配置

Spring Boot工程里引入LiteFlow的starter,这是最常规的接入方式:

<dependency> <groupId>com.yomahub</groupId> <artifactId>liteflow-spring-boot-starter</artifactId> <version>2.11.4</version> </dependency>

配置文件中声明规则源位置,LiteFlow启动时会按路径加载:

liteflow: rule-source: config/flow.el.xml slot-size: 1024 enable-log: true

rule-source指向的flow.el.xml文件是启动时的初始规则。实际项目里,页面配置生成的EL不会写在XML里,而是存在数据库或缓存中,运行期通过LiteFlow提供的动态注册API加载。所以XML里只放一个空占位或用不到的保底Chain,避免启动报错。slot-size是流程执行时上下文Slot池大小,默认1024够用,如果并发高可以调大。enable-log建议开发环境打开,LiteFlow会把每个节点的执行耗时和步骤打出来,排查问题全靠它。

3.2 核心转换器:从LogicFlow JSON到EL字符串的Java实现

下面是转换器的核心类。它做的事情按顺序是:解析JSON建图、做基础校验、从起始节点开始深度遍历生成EL。

import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.*; public class LogicFlowToLiteFlowEL { static class GraphNode { String id; String nodeType; // common / if / switch / start / end String componentId; // 对应LiteFlow的组件ID String conditionBean; // IF、SWITCH条件的组件ID List<String> targets = new ArrayList<>(); // 出口节点ID,按画布连线顺序 int income; // 入边数 } private final Map<String, GraphNode> nodeMap = new HashMap<>(); public String convert(String logicFlowJson) throws Exception { JsonNode root = new ObjectMapper().readTree(logicFlowJson); buildNodes(root); buildEdges(root); validateBeforeGenerate(); String startId = findStartNodeId(); return buildEl(startId); } private void buildNodes(JsonNode root) { for (JsonNode n : root.path("nodes")) { GraphNode g = new GraphNode(); g.id = n.path("id").asText(); g.nodeType = n.path("properties").path("nodeType").asText("common"); g.componentId = n.path("properties").path("componentId").asText(g.id); g.conditionBean = n.path("properties").path("conditionBean").asText(""); nodeMap.put(g.id, g); } } private void buildEdges(JsonNode root) { for (JsonNode e : root.path("edges")) { String src = e.path("sourceNodeId").asText(); String dst = e.path("targetNodeId").asText(); GraphNode g = nodeMap.get(src); if (g != null) { g.targets.add(dst); nodeMap.get(dst).income++; } } } private void validateBeforeGenerate() { long startCount = nodeMap.values().stream() .filter(n -> "start".equals(n.nodeType)).count(); if (startCount != 1) { throw new IllegalStateException("必须且只能有一个起始节点,当前数量: " + startCount); } for (GraphNode n : nodeMap.values()) { if (n.income > 1 && !"end".equals(n.nodeType)) { throw new IllegalStateException("节点 " + n.id + " 有多条入边,当前转换器不支持除end外的多入边节点"); } if ("if".equals(n.nodeType) && n.targets.size() != 2) { throw new IllegalStateException("IF节点 " + n.id + " 必须连接两条分支,当前出口数: " + n.targets.size()); } } } private String findStartNodeId() { return nodeMap.values().stream() .filter(n -> "start".equals(n.nodeType)) .findFirst().orElseThrow(() -> new IllegalStateException("图中缺少起始节点")) .id; } private String buildEl(String currentNodeId) { List<String> parts = new ArrayList<>(); String cur = currentNodeId; while (cur != null) { GraphNode g = nodeMap.get(cur); if (g == null) break; if ("end".equals(g.nodeType)) break; if ("if".equals(g.nodeType)) { String trueBranch = buildEl(g.targets.get(0)); String falseBranch = buildEl(g.targets.get(1)); parts.add("IF(" + g.conditionBean + ", " + trueBranch + ", " + falseBranch + ")"); cur = null; } else if ("switch".equals(g.nodeType)) { List<String> branches = new ArrayList<>(); for (String t : g.targets) { branches.add(buildEl(t)); } parts.add("SWITCH(" + g.conditionBean + ").to(" + String.join(", ", branches) + ")"); cur = null; } else if ("start".equals(g.nodeType)) { cur = g.targets.isEmpty() ? null : g.targets.get(0); } else { parts.add(g.componentId); cur = g.targets.isEmpty() ? null : g.targets.get(0); } } if (parts.size() == 1) return parts.get(0); return "THEN(" + String.join(", ", parts) + ")"; } }

逻辑说明:buildNodes读取每个节点的properties.nodeType,这个字段是前端在属性面板里选定的语义类型;componentId是后端真正的组件ID,生成EL时使用它而不是画布节点ID,这一点务必和前端约定清楚。buildEdges只做一件事:把edges数组转成每个节点的出口列表。validateBeforeGenerate做三件事:起始节点唯一、非end节点不允许有多入边、IF节点刚好两个出口。这些约束不是随便定的——IF节点两个出口对应true/false分支,多入边会导致生成EL时分支汇合逻辑复杂到不可控,直接在产品规则上限制最省心。

buildEl是递归生成EL的核心。普通节点收集到parts列表,连续多个普通节点最终拼成THEN(a, b, c);遇到IF节点,先把两个分支分别递归生成,再拼成IF(条件, 分支1, 分支2);start节点只做跳转。单节点时不包THEN,因为IF(cond, a, b)本身就是合法EL,包一层反而冗余。这里没有做DFS环标记,因为校验阶段禁止了非end节点多入边,而DAG里每个节点在生成路径上只可能出现一次。

参数调整建议:validateBeforeGenerate里的多入边限制如果觉得太严格,可以改为"允许end节点多入边"(代码里已是如此),把汇合点统一收到end节点上。如果业务一定要中间汇合,建议给LiteFlow用WHEN加ANY表达式做并行汇聚,但这会让前端画图规则复杂不少,多数项目不值得。

3.3 生成EL后的两个收尾动作:chain注册与执行验证

拿到EL字符串只是第一步,还要把它注册成LiteFlow可用的Chain。最常见的方式是调用LiteFlow的FlowBus动态注册,再通过LiteflowFacade或FlowExecutor执行验证:

@Service public class RulePublishService { public void publishRule(String ruleId, String elExpression) { // 先移除旧定义,避免同名Chain被缓存覆盖不了 FlowBus.removeChain(ruleId); // 注册新Chain FlowBus.addChain(ruleId, elExpression); // 执行一次冒烟验证 LiteflowParam param = LiteflowParam.create(ruleId, null); FlowExecutor executor = FlowBus.getFlowExecutor(); Object result = executor.execute(ruleId, null); // 记录执行结果到日志,失败则回滚 log.info("rule {} published, execute result: {}", ruleId, result); } }

说明:removeChain和addChain搭配使用是为了避免LiteFlow的Chain定义在内存中长期缓存导致"改配置不生效"的玄学问题。冒烟验证建议用一个空上下文执行,目的是确认EL语法能被解析、所有引用的组件ID在Spring容器中都存在。如果执行阶段抛"组件不存在"异常,说明前端配的componentId有拼写错误,此时应当把错误回给前端页面展示,而不是让规则带着半残状态上线。

4. 前端配置页面的关键实现:LogicFlow自定义节点与校验拦截

后端转换器能不能正确工作,取决于前端输出什么。这一章从前端角度讲清楚自定义节点、属性面板和提交前校验,让图数据第一次就符合后端胃口。

4.1 LogicFlow画布的三种自定义节点:把图例接成配置语义

LogicFlow默认节点是矩形、圆形这些纯图形,落地时必须注册自定义节点,把节点形态和后端语义绑定。以Vue3项目为例,初始化画布和注册节点是这样做的:

import { LogicFlow } from "@logicflow/core"; import "@logicflow/core/dist/style/index.css"; const lf = new LogicFlow({ container: document.querySelector("#container"), grid: true, width: 1200, height: 700, }); lf.register({ type: "liteflow-if", className: "liteflow-if-node", model: { getOutgoingAnchor() { return ["true", "false"].map((branch) => ({ id: branch, type: "circle", x: this.x + (branch === "true" ? 50 : -50), y: this.y, })); }, }, view: {}, });

自定义节点的关键在model里的锚点定义。IF节点必须有两条出口边,LogicFlow的连线锚点默认平均分布在节点四周,如果不重写getOutgoingAnchor,用户拖出来的边分不清哪条是true哪条是false。这里把两个锚点分别放到节点右侧和左侧,业务上约定右侧出口永远代表true分支。这样一来,用户画完图,连线的sourceAnchorId字段天然带着"true"或"false"标记,后端在edges里就能读到分支语义,不用再靠猜。

view留空对象是接受LogicFlow的默认渲染,className用来挂CSS调样式。如果项目需要更特殊的节点外观,再在view里重写getShape返回SVG或DOM。阶段建议:先用默认渲染把链路跑通,再回头美化。

4.2 属性面板:让业务人员填参数而不是改代码

节点类型定了之后,还要让配置员填业务参数。LogicFlow本身不带属性面板,常见做法是在页面右侧自己放一个表单组件,监听节点点击事件,把选中节点的properties字段映射到表单控件:

lf.on("node:click", ({ data }) => { currentEditingNode = data; formData.value = { nodeType: data.properties.nodeType || "common", componentId: data.properties.componentId || "", conditionBean: data.properties.conditionBean || "", }; }); function saveNodeProperties() { currentEditingNode.setProperties({ ...currentEditingNode.properties, ...formData.value, }); }

属性面板里至少要暴露三个字段:节点类型下拉框(普通/IF/SWITCH/结束)、组件ID输入框、条件组件ID输入框。普通节点只填组件ID;IF和SWITCH节点额外填条件组件ID。这里有个容易被忽略的细节:nodeType是给后端转换器看的语义类型,componentId要填的是后端LiteFlow组件注解里的那个ID。前端完全不需要理解LiteFlow,但必须理解"组件ID是后端的门牌号",拼错一个字符规则就跑不起来。

条件组件ID的校验可以做成下拉选择,后端提供一个接口返回当前系统里所有可用的组件ID清单,前端渲染成下拉框。这样做从源头杜绝拼写错误,比后端报错再返工效率高一个量级。

4.3 保存前的三道前端校验:起点、孤立节点、分支出口数

前端校验的作用不是替代后端校验,而是把明显错误拦在提交之前,减少无意义的接口往返。提交时写一个简单的校验函数,逻辑很直白:

function validateGraph(graphData) { const { nodes, edges } = graphData; const startNodes = nodes.filter((n) => n.properties.nodeType === "start"); if (startNodes.length !== 1) { return { pass: false, message: "必须有且仅有一个起始节点" }; } const connectedNodeIds = new Set(); edges.forEach((e) => { connectedNodeIds.add(e.sourceNodeId); connectedNodeIds.add(e.targetNodeId); }); const isolated = nodes.filter((n) => !connectedNodeIds.has(n.id)); if (isolated.length > 0) { return { pass: false, message: `存在孤立节点: ${isolated.map((n) => n.id).join(", ")}` }; } const ifNodes = nodes.filter((n) => n.properties.nodeType === "if"); for (const ifNode of ifNodes) { const outEdges = edges.filter((e) => e.sourceNodeId === ifNode.id); if (outEdges.length !== 2) { return { pass: false, message: `IF节点 ${ifNode.id} 必须连接两条分支` }; } } return { pass: true }; }

孤立节点在图上很容易被漏看——画了一个节点忘了连线,从视觉上看并不明显。前端校验的作用就是让这种错误在保存那一秒弹出来。IF节点出口数是后端转换器的硬约束,前端提前拦一道能避免用户白等一次接口。环检测没有放在前端,原因有两点:一是DFS环检测逻辑在前后端重复写成本高,二是有向图的环在前端小数据量下其实不太容易画出来,后端做兜底足够。

5. LiteFlow执行阶段的5条避坑记录:从配置不生效到分支语义反转

转换器写完、前端页面能出JSON,真正的血泪经验才刚刚开始。这一章记录实际使用LiteFlow执行时最容易翻车的五个场景,每一条都按"现象→原因→解决"写清楚,照着排查能省下一个下午。

5.1 改完配置不生效:Chain的缓存和重载机制

现象:页面上把流程从"先校验再扣库存"改成"先扣库存再校验",点保存后执行,跑的还是老逻辑。原因:LiteFlow的Chain定义加载进内存后不会自动感知外部变更,addChain对新ID有效,对已存在的ID重复添加不会覆盖旧定义。解决:发布规则时先FlowBus.removeChain(chainId),再addChain。如果整批规则都改了,可以直接调FlowBus.reloadRule()让LiteFlow按rule-source配置重扫一遍。这个坑在开发和测试环境不明显,因为很多团队直接重启应用,生产环境不重启就暴露了。

5.2 IF分支语义反转:边上的branchType没传给后端

现象:配置员期望"VIP用户走折扣分支"落库后反过来走了普通分支,实际产生资损。原因:前端IF节点上画了两条边,但两条边的properties里没有携带true/false标记,后端按连线顺序默认第一条是true分支。如果用户在画布上先拉了false分支的线,顺序就错了。解决:前端在保存图数据时,为IF节点的出口边自动写入branchType属性,值取锚点的ID(前面自定义节点时已经约定true锚点在右、false锚点在左)。后端转换器读取edges.properties.branchType来判定分支归属,而不是依赖数组顺序。这是整个链路里最容易踩的坑,务必两边同时改。

5.3 画了个环导致转换器死循环:环检测必须前置

现象:后端接口突然卡死或报栈溢出,日志里全是递归调用。原因:配置员在画布上画了"节点A→节点B→节点A"的回边,生成EL的递归函数没有环标记,直接无限递归。解决:转换器进入递归之前,先用Kahn算法或DFS三色标记做一次环检测,发现环直接抛出带节点ID的错误信息。LiteFlow的EL本身是树,天然不支持回边表达,所以这类图在前端就该被禁掉。建议在validateBeforeGenerate方法里补上环检测代码,不要省这一步。

5.4 EL里找不到节点:节点ID和组件ID是两码事

现象:LiteFlow执行时报"无法找到组件xxx",但前端明明配置对了。原因:转换器生成EL时错用了画布节点ID(比如node_1),而LiteFlow要求EL里的每个节点ID必须在Spring容器中找到对应的@LiteflowComponent注解组件。画布节点ID是前端生成的UUID,组件ID是后端Java类的注册名,两者没有必然关系。解决:遵循第3章的设计,转换器里统一用g.componentId拼EL,不用g.id。前端属性面板把"组件ID"做成下拉选择后端已有组件,从入口杜绝填错。

5.5 分支汇合后执行乱序:限制多入边的画法

现象:IF的两个分支分别处理完后都要做统一收尾操作,比如写日志,落库后收尾节点有时迟到、有时甚至不执行。原因:转换器的递归生成逻辑遇到汇合点多入边时,EL表达式的嵌套顺序无法表达"两个分支都结束后再汇合"的语义,代码里直接用income > 1拒绝这种画法,前端却没提示。解决:产品规则上明确"IF/SWITCH分支汇合点只能是end节点",即每个分支独立走到结束,不做中间汇合。如果业务确实需要"分支做完统一收尾",可以让两个分支的尾节点都调用同一个组件组件ID,LiteFlow本身天然支持同一组件在一条链路中被多次引用,效果等价于汇合收尾,还不用改转换器。

6. 把配置体系交付出去:双写存储、版本切换与执行链路快照

最后聊两个让这套体系真正能上生产环境的技巧,比"能跑通"更进一步。

6.1 双写存储:别等回显时才后悔没存LogicFlow原始图

生成EL只是后端视角的产物,页面回显时如果只靠EL是画不回去的。EL是树,LogicFlow的图是带坐标和样式的图,反向转换成本极高,纯属给自己找麻烦。最常见的做法是配置保存时双写:页面提交的LogicFlow JSON原样存一份到数据库,后端生成的EL存一份,同一个规则ID关联两条记录。下次打开配置页面,直接用存下来的JSON渲染画布,用户看到的就是他上次保存的样子,坐标、节点样式、连线方向都不会变。EL那条记录供执行使用。这条经验是我自己踩过的——第一个版本只存了EL,回显时全靠正则猜图,维护了两周就重写了。这个后悔药不用吃。

6.2 用chainId后缀做版本切换与执行链路快照

规则平台需要版本概念,但LiteFlow的Chain是按ID区分的,一个ID只能对应一份定义。务实做法是chainId用"规则编号_版本号"拼接,比如order_rule_1001_v3。每次发布新版本时生成新chainId,发布成功后在配置表里把当前生效版本号指向新版本。这样灰度也简单——先让一部分流量用新版本号执行,稳定后再全量切换。

验证一条规则是否按预期走,靠LiteFlow执行时的快照信息最直接。执行完成后从上下文中拿执行步骤串,能看到每个节点实际执行顺序和耗时:

Object response = executor.execute(chainId, null, context, requestData); String stepStr = String.valueOf(context.getExecuteStepStr()); log.info("执行链路: {}", stepStr);

getExecuteStepStr()返回的是一串按顺序拼接的节点ID,对照配置页面的图就能快速发现问题节点。我现在的习惯是:每个规则发布后的冒烟验证,都把这个执行步骤串打出来,和页面图逐段核对一遍,确认无误才标记发布完成。这套流程听起来朴素,但能把分支语义反转这类问题在发布当场抓住,而不是等业务投诉。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询