ruflo 这个名字是我给自己做的那个轻量级规则流引擎起的。取 rule 和 flow 两个词各截一段拼在一起意思很直白把规则排成一条流让数据顺着流走完就知道该做什么了。做这个东西的起因很实际当时我负责的促销订单逻辑已经难改到离谱——满减、折扣、赠品、会员等级、优惠叠加全部塞在一个 Service 方法里里面累计嵌套了十几层 if-else一条规则变了就要发版一个分支错了线上立刻出问题。我最初只是想把这些判断从代码里剥出去没想到最后做了一个小引擎出来。这篇文章会把 ruflo 从想法、建模到核心代码实现完整讲一遍包括我在真实业务里踩过的坑、被坑过的并发问题以及最后它长成了什么样子。适合那些正在被复杂业务判断折磨、想自己动手写一套轻量规则编排工具的开发者也适合刚接触规则引擎、想理解底层原理的新手。1. ruflo 从哪来的一张被 if-else 塞满的促销订单1.1 那个让我想重构的 Monday Morning事情是周一早上开始的。运营在群里说凌晨那波限时折扣和会员叠加算错了用户实际支付的金额比预期多了十几块钱工单已经打到技术侧。我打开那个出名的PromotionService顺着条件一层层往下翻先是判断是否新手、再判断品类、再判断会员等级、再判断是否在活动时间窗、再判断优惠券是否可用……一个方法小两百行光if就有四五十个。最后定位到的问题其实特别小新人专享价和限时秒杀之间的优先级写反了运营在后台加了一个新活动类型代码里的顺序没跟上。这种问题技术难度为零却花了我快两个小时。真正让我难受的不是那一次线上问题而是我发现这个类已经变成了一团谁都说不清楚“到底什么规则在生效”的毛线球。运营改了配置代码没有对应分支代码加了分支配置又没同步两边各改各的最终只能靠线上故障来暴露。需求本身从来不复杂无非是“满足某些条件 → 执行某个动作”。复杂的是条件越来越多、动作越来越丰富、排列组合越来越无规律可循。当这种判断逻辑全部长在代码里代码就沦为业务规则的活文档而且是没人维护的那种。1.2 为什么不用现成的规则引擎想解决这个问题第一反应是引入现成规则引擎。我确实认真调研过 Drools、Easy Rules、Flowable 这些方案结论是它们都很强但和当时的场景不太匹配。Drools 的规则语法 DRL 非常强大Rete 算法的匹配效率也很高适合规则多、模式匹配复杂的场景。但它的问题是学习曲线太陡团队里大多数人没写过 DRL光一个“规则优先级和冲突解决策略”就能把人绕晕。而且 Drools 引入进来之后规则文件的管理、调试、单测也要跟着搭一套对一个小团队来说是大炮打蚊子。Flowable 和 Activiti 这类工作流引擎又是另一个方向它们解决的是“人参与的任务流转”注重环节之间的审批、会签、驳回本质是状态机加任务分配。而促销规则这种场景更多是“数据进来 → 判断 → 计算 → 输出”不需要人来审批用工作流引擎反而显得笨重。至于 Groovy 脚本热加载这种野路子灵活是灵活但没有结构脚本多了以后依然是每个脚本一个世界彼此之间怎么衔接、怎么组合、怎么可视化全靠自觉。我真正想要的是一个刚好的东西能描述一条线性的判断流程每个环节是可复用的小单元条件用配置表达而不是写死在代码里同时小到任何一个开发都能在半小时内看懂它的所有代码。1.3 ruflo 的定位用一个文件描述一条规则流所以 ruflo 的定位从一开始就没有摇摆不做一个通用的规则语言做一个能描述“流程式规则判断”的轻量引擎。它把业务判断拆成节点节点之间用连线组成流数据带着上下文从流的起点走到终点每经过一个节点就完成一次判断或动作。比如一个典型的促销场景用 ruflo 表达大概是这样的{ flowId: promotion-order, nodes: [ { id: start, type: start, next: [check-user] }, { id: check-user, type: condition, expression: user.level vip || user.isNewUser, trueNext: [apply-vip-discount], falseNext: [apply-normal-price] }, { id: apply-vip-discount, type: action, action: discountByLevel, next: [end] }, { id: apply-normal-price, type: action, action: normalPrice, next: [end] }, { id: end, type: end } ] }配置只描述“去哪些节点、按什么条件跳转”节点真正执行的逻辑仍然在代码里但每个节点是一个独立的小方法可以被复用、被单测、被替换。运营改规则时只需要调整表达式的条件和跳转关系不需要动代码。这基本就是 ruflo 最初的样子后来在真实业务里长了不少肌肉但骨架一直没变。2. ruflo 的核心模型节点、上下文与规则流2.1 Node最小的可执行单元我设计 ruflo 时反复提醒自己一件事越小的引擎核心概念越要克制。所以整个模型只有三个概念Node节点、Context上下文、Flow规则流。Node 是最小执行单元它有唯一的 id、一个执行入口、以及若干个“下一跳”指向。它的 Java 接口我写得非常简单public interface Node { String getId(); String execute(FlowContext ctx); }execute返回的是一个字符串代表下一个要执行的节点 id。如果返回null或空串则表示这条流走到尽头。这里没有trueNext/falseNext这种硬编码概念而是让节点自己决定接下来去哪。这样设计的好处是节点类型不会被写死条件节点可以返回多种路径动作节点也可以动态决定后面的走向灵活性集中在节点内部而不是被框架约束。我把实际用到的节点类型归纳成这么几类节点类型作用典型场景start / end标记流程起点和终点所有流condition解析表达式按结果选择路径用户是否符合某个条件action执行一段业务动作修改上下文计算折扣、下发优惠券switch多分支类似多路条件订单类型分桶loop循环执行子流遍历商品行计算总价script执行一段受限制的表达式脚本临时赋值、日志、数据清洗每种节点都实现 Node 接口框架只认接口不关心节点内部干了什么。这就是组合模式的好处后续想扩展新节点类型只需要加一个类注册一下流程配置里就能直接引用。2.2 Context数据在流里怎么传节点之间靠 Context 传递数据。一开始我图省事直接用了一个MapString, Object后来发现裸 Map 有几个问题第一业务对象和临时变量全混在一起命名很容易撞车第二节点之间能看到彼此所有的变量有些中间结果脏数据会被后面误读第三并行执行的时候多个节点同时写同一个 key数据竞争防不胜防。所以后来我把 Context 包装成了一个带作用域的结构。它看起来还是 Map 的用法但内部划分了三个区域全局变量、流内临时变量、只读快照。全局变量适合放订单、用户这类从头到尾都要用的业务对象临时变量适合放某个中间计算结果离开当前作用域就能被回收只读快照用于挂载外部查询出来的、不允许被节点修改的源数据。public class FlowContext { private final MapString, Object globals new ConcurrentHashMap(); private final MapString, Object locals new ConcurrentHashMap(); private final MapString, Object snapshot new ConcurrentHashMap(); public Object get(String key) { if (locals.containsKey(key)) return locals.get(key); if (globals.containsKey(key)) return globals.get(key); return snapshot.get(key); } public void setGlobal(String key, Object value) { globals.put(key, value); } public void setLocal(String key, Object value) { locals.put(key, value); } public void setSnapshot(String key, Object value) { snapshot.putIfAbsent(key, value); } }一个容易被忽略的设计是动作节点执行完以后产生的中间数据尽量写进 locals而不是 globals。比如某个规则算出了优惠金额如果直接塞进全局下一个节点很容易不小心读到它造成“幽灵数据”。locals 随着流程阶段清理能有效减少这类问题。2.3 FlowDefinition用 JSON 替代代码描述判断有了 Node 接口和 Context还需要一个能描述“流长什么样”的配置结构。我选择了 JSON没有发明自己的 DSL。FlowDefinition 主要包含三部分节点列表、每个节点的连线关系、以及每个节点需要的参数。节点列表就是nodes数组里面是 id、type、参数、连线。连线我埋在一个通用字段里条件节点用它做分支普通节点只用一个 next。参数的表达方式也很统一全部是字符串值节点执行时再按需解析。{ flowId: order-calculate, version: 20250116, nodes: [ { id: gate, type: condition, params: { expression: order.totalAmount 500 }, trueNext: full-reduction, falseNext: normal-price }, { id: full-reduction, type: action, params: { action: calcFullReduction, threshold: 500, reduce: 80 }, next: end }, { id: normal-price, type: action, params: { action: calcNormalPrice }, next: end }, { id: end, type: end } ] }选择 JSON 而不是自研 DSL是因为这个项目从一开始就是给团队用的不是给个人自嗨的。JSON 的好处人人都会写可以被 Git 管理可以被后台系统编辑可以被程序校验。DSL 虽然表达力更强手感更优雅但团队里每个人都要重新学语法调试还要单独写解析器成本一下就上去了。2.4 关于“为什么选 JSON 而不是 DSL”的取舍如果这是一篇纯粹的理想主义技术分享我可能会说 DSL 更好。但在真实项目里我需要考虑的是团队协作成本。DSL 的优雅是给写 DSL 的人看的读的人未必买账。一个运营配置人员可能只改过一个参数为了完成这个改动他需要理解 DSL 的 token、嵌套规则、转义字符——这些学习成本足够劝退。而 JSON 是绝大多数技术人都见过的格式缩进清晰、结构直观、出错时有明确的提示。另一个原因是 JSON 可以无缝对接可视化配置界面。我后续给规则流做的简易后台直接把 JSON 的 key 映射成表单控件条件、分支、参数都从 JSON 里读出来用户在下拉框里选一选就能改配置。如果是自研 DSL还得再写一层编译器和表单之间的转换桥。这一点在规划阶段就让我彻底放弃了自研 DSL 的念头。当然 JSON 也有缺点表达复杂嵌套条件时不够优雅注释用不了字段多了之后有大量重复。我的应对是不在 JSON 里塞超长表达式超过三层的条件一律拆成多个 condition 节点串联。这样配置变得更啰嗦但每个节点足够原子化肉眼就能看清楚每一条判断规则整体上还是划算的。3. 从零实现一个最小可用的 ruflo 内核3.1 四个核心类/接口ruflo 的内核代码很少核心就四个东西Node 接口、FlowContext、FlowDefinition 和 FlowEngine。前面两个已经介绍过FlowEngine 是整个执行器的核心负责加载定义、构建节点映射、按顺序执行节点。public class FlowEngine { private final MapString, Node registry new HashMap(); public void register(Node node) { registry.put(node.getId(), node); } public void execute(FlowDefinition def, FlowContext ctx) { Node currentNode registry.get(def.getStartNodeId()); int guard 0; int maxSteps def.getMaxSteps() 0 ? def.getMaxSteps() : 1000; while (currentNode ! null) { if (guard maxSteps) { throw new IllegalStateException(flow step exceeds limit, maybe endless loop: def.getFlowId()); } String nextId currentNode.execute(ctx); currentNode (nextId null || nextId.isEmpty()) ? null : registry.get(nextId); if (currentNode null nextId ! null !nextId.isEmpty()) { throw new IllegalStateException(target node not found: nextId); } } } }registry是节点 id 到 Node 实例的映射execute是主循环。我没用递归而是 while 循环这是故意为之。递归写法看起来简洁但深链路或者异常跳转会带来栈溢出和理解成本while 循环配合同样结构怎么走都不会爆栈。节点实例用register手动注入而不是靠反射扫描。手动注入的好处是团队里任何人看FlowEngine的组装代码就能知道哪些节点可用不会有“这个节点到底注册了没有”的猜测。3.2 条件表达式怎么安全求值条件节点是规则流里最核心的节点它本质上要回答一个问题这个表达式在当前上下文里成立吗表达式求值有两个选择引入现成表达式引擎或者自己写。我选了前者因为 SpEL 已经足够成熟而且它能限制反射调用。关键点在于不能用默认的StandardEvaluationContext要用SimpleEvaluationContext后者不带反射、不带类型构造器只能调用注册过的白名单方法安全边界清晰很多。public class ConditionNode implements Node { private final ExpressionParser parser new SpelExpressionParser(); private final String trueNext; private final String falseNext; public ConditionNode(String id, String expression, String trueNext, String falseNext) { this.id id; this.expression expression; this.trueNext trueNext; this.falseNext falseNext; } Override public String execute(FlowContext ctx) { EvaluationContext evalCtx new SimpleEvaluationContext.Builder() .withInstanceMethods() .build(); evalCtx.setVariable(ctx, ctx); evalCtx.setVariable(order, ctx.get(order)); evalCtx.setVariable(user, ctx.get(user)); Boolean result parser.parseExpression(expression).getValue(evalCtx, Boolean.class); return Boolean.TRUE.equals(result) ? trueNext : falseNext; } }注意parseExpression这行我把解析结果缓存起来了。SpEL 表达式每次 parse 的成本不低尤其在高频调用场景下反复 parse 会白白浪费 CPU。同一份 FlowDefinition 第一次加载时把表达式编译好后面执行直接复用快得不明显但不积小流无以成江海。至于表达式里能访问什么对象我在创建 EvaluationContext 时显式塞进变量。这比让表达式自己从某个全局容器里捞变量要安全得多表达式只能看见我允许它看见的东西。3.3 执行引擎顺序、分支与合并大多数规则流并不需要复杂图结构顺序加分支就能覆盖八成场景。ruflo 的主循环天然支持顺序和分支因为节点自己返回下一个目标 id顺序就是“每个节点都返回配置里的 next”分支就是 condition 节点根据表达式返回 trueNext 或 falseNext。合并的处理稍微微妙一点。两个分支最终要汇合到同一个节点这在图结构上是典型的多入边节点。在 ruflo 里不需要特殊处理因为每个分支跑完都会返回到同一个聚合节点 id。比如满减分支走完full-reduction普通分支走完normal-price两个节点都把 next 指向end引擎把控制权交给end自然就合并了。// 分支合并不需要额外代码节点把 next 指向同一个节点即可 full-reduction: { ..., next: end } normal-price: { ..., next: end }如果业务需要等待多个并行分支全部完成再继续那就复杂多了得引入 parallel gateway/ForkJoin 之类的结构。我在实际业务里遇到过一次当时直接在节点内部用CompletableFuture并行执行了子流程等所有分支都完成后再返回统一的下一个节点。这是一种偷懒但有效的做法因为并行节点之间共享 Context 的问题已经在框架层解决了业务侧只需要关心任务怎么拆分。3.4 让循环可控while 节点的防死循环设计规则流一旦支持循环就引入了死循环的可能性。很多规则引擎在设计上干脆不提供循环节点逼着用户用递归子流来绕。ruflo 我保留了循环因为真实业务里有遍历商品行、遍历优惠券集合这种需求没有循环的规则流写起来太痛苦了。但循环必须受控。我给 loop 节点设了三个硬性约束最大循环次数、循环计数器、以及上下文中的迭代变量。最大循环次数在 FlowDefinition 里配置默认 1000配置得再大也会被引擎全局的maxSteps拦住。public class LoopNode implements Node { private final String loopVar; private final String collectionExpr; private final int maxLoop; private final String bodyNodeId; Override public String execute(FlowContext ctx) { Collection? items evaluate(collectionExpr, ctx); if (items.size() maxLoop) { throw new IllegalStateException(loop size exceeds maxLoop: items.size()); } ctx.setLocal(loopVar, items.iterator()); ctx.setLocal(loopIdxVar, 0); return bodyNodeId; } }body 节点执行完毕后会返回一个特殊标记__loop_continue__引擎识别到这个标记就回到 loop 节点继续下一次迭代直到迭代器耗尽。这个设计不算优雅但它简单可解释任何一个开发读代码都能想清楚“什么时候进循环、什么时候出循环”。跑几次测试之后我还发现循环节点最容易出错的地方不是死循环而是迭代变量没有清理。上一轮循环的变量残留在 Context 里下一轮如果变量名相同就会读到脏值。所以 loop 节点结束前必须把loopVar和loopIdxVar从 Context 里移除这一点我在代码注释里写了三遍。4. 调试与治理规则流真正难的不是跑通是让人看得懂4.1 执行轨迹把每一步都留下脚印规则流跑通了只是第一步真正麻烦的是线上出问题时怎么定位。在一个几十节点的长流里数据经过哪些节点、哪个环节改变了最终结果如果没有日志排查起来会比以前 if-else 还痛苦。if-else 至少能加断点配置化的规则流连断点都不知道该打在哪个“行号”上。所以 ruflo 在引擎层内置了执行轨迹记录。FlowContext 里有一个 trace 列表每走过一个节点就往里塞一条记录包含节点 id、节点类型、执行耗时、节点读到的关键变量和写出的关键变量。正常状态下这层记录是关闭的线上 CPU 和内存不受影响只有当请求上下文里开启了 debug 标记时才记录。有了这个执行轨迹排查问题的方式从“猜”变成了“回放”。有一次线上用户反馈某个订单没有被算入满减我把它的 flowId、trace 拉出来一看发现它根本没走到满减节点在 condition 节点就被拦住了。为什么被拦住trace 里order.totalAmount显示是 499.99而满减门槛是 500。差 1 分钱这是一个典型的浮点精度问题而不是规则配置问题。如果没有 trace这种问题至少要多花半小时。4.2 配置漂移没有版本管理的规则都是定时炸弹规则流最大的隐患不是技术而是治理。代码有 Git 有 Code Review但配置化的规则太容易被随手改掉。今天运营在后台改一个阈值明天开发直接改 JSON 配置后天发现线上行为不一样了但没人说得清是哪一次改动导致的。我的应对方案是给 FlowDefinition 加版本号并且在应用启动时把版本号打印到日志里。规则上线必须走配置发布流程发布前有 diff 对比发布后有生效时间线上配置永远能对应到某个具体版本。版本管理看起来是规则引擎之外的琐事但它决定了一个规则引擎能不能被团队真正信任。没有版本管理的规则流改多了就是一团乱麻比 if-else 还难救。我把 version 字段放在 JSON 的第一行version: 20250116用日期做版本号简单清晰。每次发布前只需要看 diff就能知道这次改了哪些节点和表达式。4.3 高性能与并发从 ThreadLocal 到线程池的那一脚并发问题是我在这个项目里踩得最深的坑。早期版本里我为了图方便把 FlowContext 放进了 ThreadLocal想着这样节点执行时随时能拿到上下文不用一处处传参数。看起来很美运行起来就出事。线程池里的线程是复用的ThreadLocal 里的数据在线程执行完任务后如果没有手动清理下一个任务就会读到上一个任务的上下文。规则 A 跑到一半线程任务结束但没清理规则 B 被同一个线程执行时ThreadLocal 里还残留着规则 A 的订单数据。那一次事故直接导致用户收到了别人的优惠券运营炸了我也彻底意识到ThreadLocal 是精细管理下的工具不是偷懒的传送门。后来我把 ThreadLocal 全部删除Context 改为方法参数显式传递。每个节点都能清晰地看到上下文从哪来、到哪去线程池虽然还是那个线程池但上下文跟着任务走而不是跟着线程走问题就此消失。并行执行时的数据竞争是另一个坑。两个并行节点同时往 Context 里写同一个 key结果取决于谁先落笔这属于典型的非确定性行为。我在 Context 的set类方法里加了“同 key 重复写且值不同则抛异常”的保护逻辑宁可让流程停下来也不能让它脏着跑下去。规则计算没有幂等脏数据一旦流到下游造成的损失往往比一次运行失败大得多。4.4 常见坑位与避雷对照表把这些坑整理成一张表现象根因避雷方案满减金额差一分钱浮点数直接比较金额用 Decimal 或者转分为整数比较条件判断总是不命中表达式里字符串和数字类型不匹配在解析前做一次类型规范化线上打了错误优惠Context 变量名撞车全局变量统一前缀临时变量必须声明作用域循环卡住 / CPU 飙升循环节点没有最大次数限制配置 maxLoop 加引擎级 maxSteps 双保险线程池任务串数据ThreadLocal 跨任务残留禁用 ThreadLocalContext 显式传递配置有人改过但查不到流程配置没有版本管理每次发布留 diff启动日志打印版本号这表里的每一行我都在真实环境里遇到过至少一次。规则引擎好不好用不只看它能干多少事还要看它能不能被人安全地维护。治理能力是规则的“负向能力”越是在事故多发的时候越能体现价值。5. 在真实业务里长成什么样以及下一步想做什么5.1 营销规则中心的落地结构ruflo 在业务里最终长成了一个“营销规则中心”。配置放在后台管理系统里运营通过表单修改 JSON 配置点击发布后配置进入版本管理表应用侧通过配置中心实时拉取最新版本FlowEngine 在本地解析并执行。整个链路是这样的后台配置表单 - FlowDefinition JSON - 配置中心 - 应用本地缓存 - FlowEngine 执行运营不再需要提工单等排期。满减门槛从 500 改成 300打开后台改一个数发布十分钟后生效。而开发只需要维护节点对应的 action 方法比如calcFullReduction接收金额、门槛、减免额返回计算后的应付金额。规则怎么编排由运营侧通过配置控制业务动作代码由开发控制各司其职。这个结构的核心红利是变更频次分离。业务规则的变动不再触发代码发布风险面大幅缩小。以前改一条促销规则要全量回归整个订单计算链路现在只需要回归涉及到的几个 action 节点测试成本显著下降。5.2 与人工审批流程的对比区别做这个项目的过程中我反复被问到一个问题这跟 Flowable 有什么区别我的标准回答是Flowable 解决的是“人参与到流程中”的审批流转而 ruflo 解决的是“数据在判断节点间流转”的规则计算两者面向的实体完全不同。审批流程里的元素是任务、是审批人、是会签与驳回流程引擎需要管理任务的分配和超时回退。规则流里的元素是判断、是计算、是跳转引擎需要考虑的是表达式的求值和节点的执行顺序。把促销折扣的规则放到工作流引擎里跑或者把审批流转放到规则流引擎里跑两边都会很难受。这提醒我做一个工具前先想清楚它到底要解决哪一类问题。不是所有叫“流程”的东西都可以共用同一个引擎也不是越通用的框架越合适。“刚好够用”才是工程里最稀缺的品质。5.3 后续扩展可视化编排与回归测试ruflo 现在最想补的一块能力是可视化编排。JSON 配置对于开发是直观的对运营依然有门槛。我已经画好了原型的思路把节点画成卡片连线画成箭头条件表达式展示在连线上这样运营能看到一条“规则流”的全貌而不是面对一堆 JSON 字段。可视化之外另一个更关键的基础设施是规则回归测试集。规则流最大的风险是“改了 A 规则影响 B 规则”所以我把历史上有过线上问题的场景全部固化成黄金用例每次有规则变更都自动跑一遍。黄金用例集看起来只是普通测试但它实际承载的是规则引擎最核心的信任建设——让团队相信“改配置是安全的、可验证的”。5.4 一些不太会写进文档的心里话ruflo 并不是一个设计得多么精巧的引擎。它没有惊艳的算法没有复杂的图论结构很多地方甚至可以说是“土办法”。但恰恰是这种土让团队里每个人都能读懂它、能维护它、能在出问题的时候按住它。我现在越来越觉得一个基础组件最核心的成功标准不是“功能强大”而是“团队敢改、敢上线、敢排查”。ruflo 算不上一个完美的作品但它做到了这三点。如果让我重新做一次我可能会把表达式安全边界做得更早、更彻底把上下文作用域设计得更严格但不会改变它的整体骨架。有些东西是在踩坑之后才真正理解的比如规则从代码里剥离出来不是目的让规则的变更变得安全、可控、可追溯才是目的。这一点比引擎本身的实现方式重要得多。