从if-else地狱到配置化流程:轻量级规则引擎ruflo的设计与实践复盘

发布时间:2026/9/8 17:07:28
从if-else地狱到配置化流程:轻量级规则引擎ruflo的设计与实践复盘 ruflo 是我这一年业余时间一直在琢磨的一个轻量级规则流引擎名字就是 Rule Flow 的缩写核心目标特别朴素把业务代码里那一堆嵌套到没法看的 if-else 判断从代码里抽出来变成一条可以在运行期加载、修改、重新编排的规则流程。如果你所在的项目也遇到过产品经理三天两头改活动规则、每次改动都要发版、测试回归一大片的情况那这篇关于 ruflo 的完整复盘应该能对得上你的痛点。我会从最开始的动机讲起把这个引擎的设计取舍、核心抽象、落地案例以及我踩过的几个坑完整过一遍适合正在考虑自研规则引擎或者对流程编排感兴趣的后端开发同学参考。1. 为什么业务代码里的 if-else 会失控一个真实信贷审批场景1.1 一段典型的嵌套地狱代码我以前参与过一个信贷审批服务表面上是很标准的三层架构但最核心的审批决策逻辑全集中在一个巨大的 service 方法里从上到下串了七八十个 if-else。每个 if 都是一条业务规则比如“申请人年龄在 22 到 55 之间”“近三个月查询次数小于 6”“收入负债比超过 55% 直接拒绝”。单个条件都不难懂问题是它们一旦叠加起来代码的可读性基本归零。我随手简化一个版本你们感受一下那种结构if (user ! null user.getAge() 22 user.getAge() 55) { if (creditQueryCount 6) { if (debtRatio 0.55) { result reject(负债过高); } else if (isRiskUser(user.getMobile())) { result manualReview(); } else { result passWithLimit(10000); } } } else { result reject(不符合年龄要求); }这已经是我做过精简的版本了。真实环境里条件之间还有各种隐式关联比如某些产品的准入规则只在特定渠道下生效某些新客活动规则又和首单金额强绑定。结果就是产品经理每提一个新规则研发就得先顺藤摸瓜找到对应插入点小心翼翼加一个分支再紧张地等测试回归。线上出了兼容性问题大家第一反应也是去翻那个几百行的主方法。当时我们统计过那套审批服务平均每个月要改两到三次规则每次改动的代码量不大但涉及的回归范围很广。最痛苦的一次是双十一大促前临时加规则开发、测试、产品三拨人挤在一个会议室里逐条核对判断逻辑生怕漏掉一个分支。那次之后我就彻底想明白了判断逻辑如果一直以代码形态存在它就会永远这么乱下去。1.2 现成规则引擎的取舍为什么不是 Drools 或者 LiteFlow说到把规则从代码里拆出来很多人第一反应是用现成的规则引擎。我也认真评估过不能脑子一热就自研。Drools 确实强复杂事件处理CEP、推理、规则冲突解决功能非常全。但它的学习曲线真的陡DRL 那套语法和运行机制团队新同学上手基本要一两周而且它会引入一整套规则生命周期管理概念对我们这种“大部分规则就是简单条件判断 动作执行”的场景来说属于重型武器。用它的代价不只是引入一个依赖而是团队所有人的思维都要往 Drools 的模型上靠这个成本我不能忽视。LiteFlow 的思路我反而更喜欢它把业务拆成组件用 XML 或者 JSON 来编排组件执行顺序强调“组件复用 流程编排”。我们在技术选型讨论时参考了很多它的设计理念但当时它在一些细节上跟我们现有系统的契合度不够比如我希望流程定义能跟我们的配置中心无缝对接、支持按节点级别的监控统计这些在那个阶段都要自己再包一层。两个现成方案都试过一圈之后我决定针对自己的真实场景写一个足够轻、足够透、还能按需扩展的引擎。我给它定了几条硬指标只做规则流编排这件事不搞大而全的 CEP 和事件处理学习成本控制在半天以内后端同学看完 demo 就能上手配置格式要直观最好产品经理也能理解大概意思运行期支持热加载能在配置中心改完立即生效外部依赖尽可能少能嵌进现有服务而不是反过来被它绑架。这几个方案我做了个对比不一定客观但很能说明我当时的决策逻辑维度DroolsLiteFlowruflo核心模型规则集 推理 CEP组件 流程编排条件/动作/网关节点配置方式DRL 专用语法XML / JSONJSON学习曲线陡平缓平缓热加载支持但偏重支持支持且更轻性能开销较高低低与现有系统融合难度大中小当然这个表格带着明显的个人使用偏好。Drools 在复杂规则集场景下依然是标杆只是对中小团队和轻量场景确实不是最优解。对我来说一个能让我同事半天上手、能按自己需求改造的引擎才是真正能落地的引擎。2. ruflo 的建模思路把规则流拆成三种节点2.1 极简节点模型为什么只保留三种类型设计一个流程引擎最容易犯的错就是一上来想满足所有需求。我见过不少自研流程引擎的项目最后都死在过度设计上又是事件、又是消息、又是子流程建模搞了一大堆真正用起来大部分功能都在吃灰。ruflo 只保留了三种节点类型条件节点ConditionNode对传进来的事实对象做判定输出 true 或 false决定流程下一步往哪走动作节点ActionNode执行一个具体的业务动作比如改订单字段、加优惠、调外部接口、写审计日志网关节点GatewayNode控制流程组织方式目前实现了串行网关和并行网关并行网关可以把一个事实同时交给多个互不依赖的规则分支处理最后再汇聚结果。为什么不做事件和消息以我在信贷审批和电商营销场景里的经验90% 以上的规则流用这三种节点就能表达清楚。事件和消息会让引擎骨架变得复杂而且一旦引入异步事件调试和排查问题的难度立刻上升一个台阶。做规则引擎最好的状态是“配置即流程流程一眼能看懂”而不是“配置像写程序”。2.2 事实对象与执行上下文并行分支的数据隔离规则引擎里被规则判定的数据通常叫 Fact。ruflo 里 Fact 就是一个普通 POJO用户自己定义字段引擎不关心内部结构。比如订单场景里Fact 可能有用户等级、订单金额、是否新人、商品分类这些字段审批场景里Fact 就是申请人的资料和征信数据。每次执行规则流引擎会创建一个 ExecutionContext里面持有当前 Fact 的引用、流程定义、节点执行记录、以及各分支产生的临时结果。这个上下文是单次执行专用的默认不允许跨执行共享。这里有一个很重要的设计决策并发场景下上下文不能直接共享。规则流经常要并行分支而并行一旦引入就会出现很多隐蔽问题。最典型的是两个并行分支同时修改 Fact 的同一个字段最后结果依赖线程调度顺序这就是竞态。ruflo 的处理方式是“分支回写合并”每个并行分支在自己的受限上下文里执行动作产生的 ActionResult 统一在汇聚节点提交给主 ExecutionContext冲突处理策略由用户定义可以是后者覆盖、直接报错、或者抛给自定义合并器。这样既保留了并行执行的性能优势又避免无脑共享引用带来的脏数据问题。2.3 JSON 流程定义让非技术同事也能看懂规则流程定义语言我选了 JSON。原因有三个可读性好、生态成熟、配置下发和版本管理都方便。一个最简流程大概长这样{ flowId: new_user_discount, name: 新人首单立减, nodes: [ { id: n1, type: condition, name: 是否新用户, expression: fact.newUser true, trueTarget: n2, falseTarget: n_end }, { id: n2, type: action, name: 立减10元, actionType: discountAction, actionConfig: { amount: 10 } } ], endNodes: [n_end] }这里面的 expression 是整个流程定义的灵魂。ruflo 默认使用 Spring Expression LanguageSpEL做表达式求值。为什么选 SpEL因为它在 Java 生态里普及度高、类型安全、支持对象属性访问和方法调用后端同学基本都会写不用额外学一门新的规则语言。但是这个选择也给我埋了一个性能坑后面专门有一节讲这个事。配置里还有几个值得注意的设计点trueTarget和falseTarget是条件节点的两个出口语义直接对应 if-else 的两个分支动作节点不直接写死业务代码而是用actionType指向一个注册好的 Action 实例actionConfig是传给这个实例的参数endNodes用来标识哪些节点是流程终点方便引擎判断流程是否走完。3. 跑通一个最小流程新人首单立减案例全解析3.1 引入依赖与准备环境ruflo 的定位是内嵌式轻量引擎不需要单独部署服务就是普通依赖。Maven 坐标大致是这样的目前还在整理发布到中央仓库本地用 install 到私服就行dependency groupIdio.github.ruflo/groupId artifactIdruflo-core/artifactId version0.3.2/version /dependency引擎运行环境是 JDK 8 及以上没有强制要求 Spring Boot但如果你项目里已经有 Spring用起来会更顺手因为 SpEL 表达式本身来自 Spring 的 expression 模块。当项目里没有 Spring也能用只是需要多配置一个 SpEL 的依赖。这里提前说一句在引入引擎之前规则管理的权限边界一定要想清楚。规则能热加载的前提是“谁能改规则”必须有严格约束否则一个错误配置推下去影响的是全量业务。我当时就是在接入第一周就遇到过同事误把金额阈值从 50 改成 5导致一批不该享受优惠的订单全部减了钱。好在这种配置能通过版本回滚救回来但血的教训告诉我权限模型和配置校验在设计初期就必须一起做掉。3.2 Fact、Action 与流程配置怎么配合我用一个电商场景来演示完整链路新用户首单金额大于等于 50 元自动立减 10 元。第一步定义 Factpublic class OrderFact { private boolean newUser; private BigDecimal orderAmount; private BigDecimal discountAmount; // getter / setter 省略 }第二步定义一个动作。public class DiscountAction implements RufloAction { Override public ActionResult execute(ActionContext context) { OrderFact fact (OrderFact) context.getFact(); BigDecimal amount new BigDecimal(context.getConfigValue(amount).toString()); fact.setDiscountAmount(amount); return ActionResult.ok(已立减 amount 元); } }注意到 Action 里通过context.getConfigValue拿到配置参数这比把参数写死在代码里灵活得多。如果一个动作要在不同业务线重复使用只需要注册同一个 Action 类、配置不同的参数就行这让动作组件天然具备复用性。第三步把规则流配置写进 JSON{ flowId: new_user_discount, nodes: [ { id: n1, type: condition, expression: fact.newUser true fact.orderAmount 50, trueTarget: n2, falseTarget: n_end }, { id: n2, type: action, actionType: discountAction, actionConfig: { amount: 10 } } ] }第四步在应用启动时加载流程然后执行FlowEngine engine RufloEngineBuilder.builder() .registerAction(discountAction, new DiscountAction()) .build(); engine.loadFlow(new ClassPathResource(flows/new_user_discount.json)); OrderFact fact new OrderFact(true, new BigDecimal(80)); FlowInstance inst engine.execute(new_user_discount, fact); System.out.println(inst.getResult()); // 已立减 10 元 System.out.println(fact.getDiscountAmount()); // 10看到这里你应该有感觉了新增一条规则核心工作变成了写 JSON 配置然后让引擎加载不再需要改代码、走发布流程。业务规则的变更时长从“一个版本周期”缩短到了“分钟级”。这也就是 ruflo 能落地的关键价值。3.3 执行引擎内部解析、调度、执行、回写很多人第一次接触流程引擎时会把注意力放在配置上但实际上配置只是入口引擎真正的复杂度在调度和执行逻辑里。ruflo 的运行过程大致分三个阶段构建期把 JSON 解析成内存中的 FlowDefinition构建节点 Map 和目标跳转表同时对有向图做环检测。解析和校验只发生在加载阶段运行期不会重复做字符串解析。调度期引擎维护一个待执行节点队列从起始节点开始根据条件节点的判定结果决定下一步去哪个节点遇到并行网关就把后续分支拆成子任务提交到线程池。执行期动作节点通过 actionType 找到注册的 Action 实例执行Action 内部修改 Fact 或产生 ActionResult并行分支汇聚时统一把结果合并到主 ExecutionContext。调度器核心部分可以理解为这样一个过程public void executeNode(String nodeId, ExecutionContext ctx) { FlowNode node flow.getNode(nodeId); if (node instanceof ConditionNode) { boolean ok evaluator.eval( ((ConditionNode) node).getExpression(), ctx); executeNode(ok ? node.getTrueTarget() : node.getFalseTarget(), ctx); } else if (node instanceof ActionNode) { ActionResult result actionRegistry .get(node.getActionType()) .execute(ctx); ctx.collectResult(node.getId(), result); executeNode(node.getNextTarget(), ctx); } else if (node instanceof GatewayNode) { // 并行就拆任务串行就走下一步 } }有两点设计细节值得强调。条件节点不允许有副作用。它只做判定不修改任何数据。这样可以支持 DryRun预执行模式也就是把条件判断全部跑一遍但动作节点只是模拟执行、不真正提交。这个功能在规则上线前验证流程是否走得通时特别有用我后来好几次生产事故都是靠它提前拦截的。动作节点可以改 Fact但必须显式声明。这看起来是一个约束实际上反而让流程更容易排查你想知道谁改了订单金额只需要去查流程定义里的动作节点而不是在一堆代码里找赋值语句。3.4 DryRun 模式上线前的安全阀DryRun 实现上很简单ExecutionMode 分为 RUN 和 DRY_RUN 两种动作节点执行前判断一下模式如果是 DRY_RUN就只记录“要执行什么”不真正调用下游。但就是这个小小的模式开关价值远超我的预期。举个例子运营同事要临时上一个大促规则在配置后台保存了一份新的流程 JSON。如果直接加载到生产环境一旦表达式写错比如引用了不存在的字段运行期才会报错。但有了 DryRun引擎可以在加载时用最近的真实脱敏数据跑一遍预执行把所有可能出错的节点提前暴露出来。我现在已经把它做成了配置后台的“预览”按钮保存前先点一下30 秒内就能知道这套规则在真实数据上会产生什么结果。4. 落地案例电商优惠计算从“代码改到吐”到“配置说了算”4.1 旧逻辑的痛点搞完 demo 之后我把它应用到一个实际的电商营销服务里这个服务负责订单结算页的优惠计算。重构前的代码大概是下面这种模式public BigDecimal calcDiscount(Order order) { BigDecimal total BigDecimal.ZERO; if (order.isNewUser()) { total total.add(new BigDecimal(10)); } if (order.getAmount().compareTo(new BigDecimal(100)) 0) { total total.add(order.getAmount().multiply(new BigDecimal(0.05))); } if (user.isVip() order.getCategory().equals(3C)) { total total.add(new BigDecimal(20)); } // 继续叠加... }这套代码初看逻辑不太复杂但它的本质问题是硬编码。每加一种优惠类型就在主方法里加一个 if 分支方法越来越长。多个优惠之间的互斥关系也维护在代码里营销同事根本看不见、摸不着。最要命的是每次大促前规则频繁调整开发、测试、产品三方联动节奏非常痛苦。4.2 用 ruflo 重构后的规则流我把优惠计算整理成一个主流程加若干子流程。主流程先识别当前订单能参与哪些优惠然后用并行网关让每个优惠子流程独立计算自己的优惠金额最后在汇聚节点统一做互斥和叠加输出最终优惠明细。流程的核心配置大概是这样的{ flowId: order_discount_flow, name: 订单优惠主流程, nodes: [ { id: gateway_parallel, type: parallel, name: 并行计算所有可用优惠, branches: [new_user_discount, full_reduction, vip_discount] }, { id: merge, type: action, name: 汇总互斥叠加, actionType: mergeDiscountAction } ] }每个优惠子流程本身就是一套独立的规则流。比如“满减”子流程{ flowId: full_reduction, nodes: [ { id: c1, type: condition, expression: fact.orderAmount 200, trueTarget: a1, falseTarget: n_end }, { id: a1, type: action, actionType: addDiscountAction, actionConfig: { type: FULL_REDUCTION, amount: 30 } } ] }这段配置里最有价值的不是 JSON 本身而是重构带来的三个变化优惠规则从代码里彻底迁移到了配置中营销策略调整不需要再提需求单每个子流程有独立 flowId可以单独上线、单独回滚。之前一个优惠出问题会拖垮全部叠加逻辑现在只要回滚问题子流程就行流程的执行结构清晰了每次大促前拿着流程图就能逐条核对不需要再去翻代码。4.3 热加载与版本管理如何避免替换掉正在执行的流程线上规则要支持配置中心变更后自动刷新这个能力很自然就演进出来了。但热加载本身藏着一个坑如果配置变了直接替换内存里的 FlowDefinition那正在执行中的流程实例怎么办如果一个实例跑到一半它引用的节点的后继节点已经被删了执行到下一步就直接空指针。ruflo 的解决方法是版本化定义这是我在做数据库迁移时学到的思路这里用在了流程管理上FlowDefinition 增加 version 字段执行中的实例始终持有它启动那一刻的版本引用引擎侧保留新旧两代版本新请求走新版本已经在执行的旧实例继续跑旧版本旧版本超过最大存活时长后自动从缓存中清理。这个机制实现起来不难但非常有必要。我见过不少团队做配置热加载光顾着“生效”忽略了“存量执行实例”结果线上偶发出现节点不存在的诡异报错排查半天才知道是因为配置被热更新替换了。5. 踩坑记录几个能让线上炸掉的细节5.1 循环依赖一个被运营拖出来的栈溢出第一个正式版本解析流程的时候我为了省事没有做环检测想着不会有用户蠢到把 A 节点的 true 分支指回 A 吧。结果还真有。运营同事在可视化配置面板上拖拽不小心把两个节点的连线连成了一个环引擎执行时直接走递归调用很快 StackOverflowError 把整个线程打挂。当时的排查链路是这样的线上报警线程池任务执行异常大量线程报 StackOverflowError用 jstack 抓线程栈发现挂掉的线程全部卡在 FlowGraph.next() 的递归调用上顺着节点 id 查配置发现 flow 定义里确实存在环。修复方案很简单加载流程时做一次拓扑排序排序能成功就说明无环如果剩下的节点数大于 0说明存在环路。同时卡一道节点深度上限超过 200 层直接判定为非法流程。这两个校验放在构建期成本几乎可以忽略但能挡住所有弱智配置。5.2 SpEL 表达式求值的性能坑最初版本的 ruflo 对表达式的处理方式很简单每次执行到条件节点就做一次 parse 再 getValue。功能完全没问题但一压测就露馅了单条流程执行耗时里 60% 以上花在表达式求值上。低并发下好像无所谓一旦到了大促的流量级别这个开销会被无限放大。优化路径我梳理了三步第一步表达式预编译。在加载流程时直接把字符串转成 SpEL 的 Expression 对象运行期不再 parse。// 优化前每次执行都解析 boolean ok parser.parseExpression(expr).getValue(ctx, Boolean.class); // 优化后加载阶段编译一次运行期复用 Expression compiled parser.parseExpression(expr); boolean ok compiled.getValue(ctx, Boolean.class);第二步执行结果缓存。对于只依赖 Fact 的“纯函数型”表达式在同一个流程实例里相同表达式只求值一次。这一步对重复条件命中很高的场景特别有效。第三步开启 SpEL 编译模式。Spring 提供了 SpEL Compiler能把表达式编译成字节码我把它做成开关默认关闭在性能敏感场景手动开启。做完这三步单次规则流执行耗时从最开始的 25ms 降到 1ms 左右。这个数据让我确认了一件事规则引擎的核心瓶颈往往在表达式层而不是流程调度层。做性能优化一定要先从表达式解析和求值入手别一上来就怀疑线程池。5.3 ThreadLocal 引发的混乱并行分支数据串路并行网关上线后我遇到过一个特别隐蔽的 bug多个并行分支同时执行都调用了 Context.setVariable结果变量互相覆盖汇聚时拿到的完全是错误数据。出问题后我第一反应是线程池的并发设置排查了一圈发现不是。真正原因是我在 BaseExecutionContext 里用了 ThreadLocal 来保存当前分支上下文。并行场景下子线程各自持有 ThreadLocal子线程设置的值无法同步回主线程线程复用的时候甚至会出现上一次执行的残留数据。最终的修复方案放弃 ThreadLocal改为显式传递 ExecutionContext 对象每个并行分支创建独立的 BranchContext归属到同一个 FlowInstanceId汇聚节点统一 merge 分支结果。这次折腾给我的教训很深刻并发调度组件里ThreadLocal 虽然用起来方便但它模糊了“这个上下文到底属于谁”的问题。显式传递虽然代码看起来啰嗦但每一步都清清楚楚出问题也好定位。5.4 外部动作节点必须有的超时与熔断动作节点如果只是改改 Fact 还行但实际情况是很多动作要调外部 RPC比如调用风控接口、读取营销配置、发送优惠券。外部系统只要一慢我们的流程就跟着慢把核心链路的响应时间拖垮。我给 Action 接口增加了一个可选的 asyncTimeout 配置执行时通过线程池 Future 实现超时中断。另外针对连续失败的动作节点ruflo 提供了一种简单的熔断机制统计最近 100 次执行中的失败率超过阈值就把该动作标记为短路状态后续流程直接走 fallback 分支。这样即使下游系统抖一下也不会拖垮整个规则链路。这个能力一开始没进路线图是某次大促演练时被真实慢调用打出来之后才补的。所以我想提醒所有自研引擎的朋友熔断、限流、超时这类“旁路能力”即便初期只留一个开关也比后面回炉重造轻松得多。5.5 两个不够深但值得留意的边界问题还有两个坑不像前面那么致命但使用中经常会碰到值得单独提一下。一是表达式里字段命名拼写错误。SpEL 本质上是运行时解析如果表达式里把 fact.orderAmount 写成 fact.orderamount只有执行到这一节点才会暴露。现在的做法是在流程加载后做一轮静态检查拿 Fact 的字段元数据和表达式中出现过的字段做匹配尽量提前拦截低级错误。二是动作节点的幂等性。规则流支持某个节点执行失败后重试但如果动作本身不是幂等的比如重复调了一次发券接口就会给用户发两张券。这个不在引擎层面解决但我给了 Action 接口一个 requestId 上下文便于实现方做幂等判断。不做的话重试越可靠线上事故越严重。6. 后续路线图以及给想自研规则引擎的朋友几句实在话ruflo 目前在我个人的几个偏业务向的项目里已经跑了信贷审批和电商营销两个大场景后续规划主要围绕以下几个点展开可视化编排面板。目标是让运营同事通过拖拽自行配置规则流程而不是每改一次都要来找研发帮忙看 JSON规则版本对比与灰度发布。新版本规则只对一部分流量生效观察指标后再全量放开流程级监控报表。每个节点的通过率、耗时分布、失败原因都要能查到不然规则引擎就变成一个黑盒引擎核心模块进一步拆薄争取做到非 Spring 项目也能低依赖嵌入。最后说点实在的。如果你问我要不要自研规则引擎我的回答是先别急着设计通用抽象拿你自己最痛的两个业务场景去压看模型够不够用。通用性从来不是设计出来的是跑业务跑出来的。我自己最开始的思路和最后落地的模型其实差了很多一开始想做一个标准化程度很高、随便什么业务都能套的引擎结果被真实需求一顿捶打之后反而是“只保留三种节点”的极简模型最耐打。ruflo 现在这个状态算不上完美但每一处设计都能回答一个问题当时遇到了什么场景才把它做成这样。这一点我觉得比任何漂亮架构都重要。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询