轻量级规则引擎ruflo:用JSON与流程编排告别if-else

轻量级规则引擎ruflo:用JSON与流程编排告别if-else 线上半夜告警运营在群里艾特我说某个促销活动算出来的价格不对。这不是第一次了——自从业务规则越来越多每次改判断条件都像拆炸弹。去年这个时候我动手写了一个叫ruflo的规则流引擎想从根本上解决这件事。这篇文章就把ruflo的设计思路、实现细节和实际落地经验完整讲讲。如果你所在的项目也有一堆到处散落的if-else或者你正在考虑引入规则引擎但被Drools的重量和复杂度劝退那这个项目应该能给你一些参考。ruflo最初只是我团队内部使用的一个小轮子定位非常明确用轻量的方式把条件判断 动作执行 流程编排串起来让规则变更不再依赖发版。整篇文章会按照为什么写怎么设计如何实现实际案例踩坑经验的顺序展开尽量把每个关键决策背后的理由讲清楚而不是只丢一堆代码。1. 业务规则满地跑我为什么决定造ruflo这个轮子1.1 那些年被if-else支配的恐惧先聊聊我所在团队的实际处境。我们做的是一套面向B端商户的优惠折扣系统。表面上核心逻辑很简单满足什么条件就给什么优惠。但真实业务比这句话可怕得多。同一个用户在不同渠道、不同时段、不同商品类目下适用的优惠规则完全不同运营活动三天两头临时加规则比如新客且首次下单且金额大于100元老客且近30天未下单就要唤回风控侧还要接入同一套判断逻辑做异常拦截。规则之间还有优先级、互斥关系不是简单地命中就返回。头几个月大家用if-else硬写写得倒是快。到第三个月问题开始冒头一个判断条件改了另一个地方忘了同步环境开关、配置层层叠加代码仓库里散落着几十个状态分支。最崩溃的一次是凌晨上活动一条if-else分支条件写反了导致一批订单计算出错误价格运营一早上都在处理客诉。这类问题单看任何一个都不难。难在规则太多、变化太快、代码太脆三件事凑到一起让人不敢动、不敢改、不敢上线。这也是所有规则引擎存在的根本理由把决策逻辑从业务代码里解耦出来让变化可控。1.2 Drools为什么没能救我们跟大多数团队一样我第一个想到的方案是Drools。毕竟它业界成熟、功能强大、社区活跃DRL规则语言也算经过大量验证。但真正落地时遇到了几个很现实的障碍第一个障碍是DRL语法的学习成本。让团队里写Java的人临时去学一套新规则语法本身就有门槛写出来的规则质量也参差不齐。加上DRL的语义偏向匹配-执行模式——给定一批事实让引擎去匹配命中的规则——这和我们的业务形态并不完全贴合。第二个障碍是依赖太重。跑一个最简单的Drools示例需要引入KIE全家桶构建KieContainer、KieSession依赖几十MB起步。我们很多服务都是轻量级微服务为了一个规则判断引入这么重的依赖性价比太低。第三个障碍也是最重要的我们的场景本质上是流程而不是规则集合。业务要求先判断用户类型再判断订单条件然后决定走哪个优惠分支这明显是流程编排用规则引擎强行表达会非常别扭。我不是说Drools不好在复杂规则推理场景下它依然是王者。只是在我们这种流程化规则判断场景里它提供的能力是过剩的代价却由每个服务承担。1.3 ruflo想解决的问题清单既然现成工具不顺手就只能自己动手。ruflo从第一天起就给自己定了几条原则规则必须能动态配置后端人员在配置中心改一下就生效不用发版规则表达足够直观用JSON加简单表达式描述不引入难学的DSL支持流程编排规则之间可以按顺序、按分支执行而不是无序碰撞引擎本身足够轻依赖少作为一个SDK嵌入现有Java应用提供清晰的SPI扩展点条件、动作都允许按需自定义。这五条听起来都很朴素但每一条落到实现上都有不少细节。后面我会逐个展开。2. ruflo核心抽象规则、动作与流程怎么拆解2.1 极简模型规则不再是一门语言在设计ruflo时我始终提醒自己一件事不要把规则搞成一种语言。很多规则引擎项目喜欢定义一套自然语言式的DSL让人写如果用户是新用户且订单金额大于100元则打9折这种句子。看起来很美好但DSL的解析、校验、调试都是沉重负担而且灵活性往往不如预期。ruflo回归规则的本质当条件满足时执行动作。在数据层面一条规则就是一个JSON对象{ ruleCode: new_user_discount, name: 新客9折规则, priority: 10, condition: user.isNew true order.amount 100, actions: [ { type: discount, params: { rate: 0.9 } } ] }我把这个结构拆解成四个核心字段来理解condition表达式字符串由引擎内置的解析器求值最终得到一个布尔值actions动作列表条件命中后按顺序执行priority规则优先级数值小的先执行ruleCode规则唯一标识用于日志和链路追踪。这套结构的好处是零学习成本。任何会用JSON的人都能写也容易和Nacos、Apollo这类配置中心对接天然适合动态下发。对比DRL这样自带语法的方案JSON表达虽然少了一些自然语言感但换来了极高的可维护性和极低的接入门槛。维度ruflo JSON规则DRL规则学习成本低会用JSON即可中高需学习自定义语法适用场景流程化判断、轻量决策复杂规则推理、事件流匹配调试体验结构直观日志天然可读需要专门调试工具集成成本SDK嵌入依赖少依赖KIE环境体积大这个取舍在团队实际使用中效果很明显。后端同学第一次接触ruflo基本半小时内就能上手写规则。2.2 条件表达式引擎的设计取舍条件表达式是规则引擎的心脏。我调研了Aviator、MVEL、SpEL这些现成方案最后还是决定维护一个极简表达式解析器。为什么不直接用Aviator因为它的函数库很丰富但语法是自成一派的团队要用得先学。MVEL的语法宽松、写错不易发现。SpEL和Spring深度绑定脱离Spring使用要额外引入依赖。既然ruflo的目标是简单、可预期、零学习成本自己维护一个小型解析器反而是更务实的路径。目前表达式支持的能力包括比较、!、、、、逻辑、||、!算术、-、*、/属性访问user.isNew、order.items[0].price方法调用order.getTotalPrice()、strUtils.contains(order.remark, 秒杀)表达式的变量统一从RuleContext中获取。规则执行前引擎把用户、订单、请求参数等事实对象放入上下文解析器按路径反射取属性。值得强调的是为了性能表达式字符串会在首次加载时编译为AST并缓存后续执行直接遍历AST求值不再做词法/语法分析。2.3 流程编排器的数据传递方式如果只有规则匹配那ruflo和普通规则引擎没有区别。真正让它贴近业务的是流程编排能力。在ruflo里一组规则组织成一个规则流RuleFlow。流内的规则按顺序执行但每条规则可以通过执行结果指令控制流程走向next继续执行下一条规则break结束整个规则流返回当前结果jump跳到指定ruleCode的规则继续执行。数据在流程中的传递统一通过RuleContext完成。它本质上是一个带命名空间约定的Mapinput入参区保存外部传入的请求上下文output出参区动作执行后写入结果最终返回调用方variables变量区存储中间变量比如累计优惠金额、积分值、加价金额等。这样做的好处是流程内不需要预先声明一套参数模型任何规则、动作都能往上下文里写数据下一条规则再拿出来用。配合每个动作的params配置可以实现非常灵活的中间结果传递。这种设计天然适合规则编排场景因为规则之间的数据流动本来就是动态的硬编码一套类型定义反而会锁死扩展性。3. 引擎主流程实现与表达式求值细节3.1 从规则集到执行计划的编译过程规则文件从配置中心拉下来之后不是直接用而是要经过一个编译过程生成执行计划RulePlan。具体来说分五步把JSON字符串反序列化为RuleSet对象遍历每条规则的condition字符串调用表达式编译器生成AST节点树遍历actions根据type反射找到对应的动作处理器类将规则按priority排序后封装为RuleNode挂到RuleFlow的执行链上编译好的RulePlan放入本地缓存key为规则集版本号。这个编译过程只做一次之后每次请求直接执行已经编译好的执行计划避免重复解析JSON和表达式。规则变更时配置中心的推送回调触发缓存更新重新走一次编译流程。下面是一个简化版的表达式AST结构示意注意编译后的节点是不可变对象天然支持并发访问public abstract class AstNode { public abstract Object eval(RuleContext context); } public class BinaryOpNode extends AstNode { private final String operator; private final AstNode left; private final AstNode right; public Object eval(RuleContext context) { Object l left.eval(context); Object r right.eval(context); return OpEvaluator.compare(operator, l, r); } } public class PropertyNode extends AstNode { private final String propertyPath; public Object eval(RuleContext context) { return context.resolveProperty(propertyPath); } }这样做的好处很直接线上规则的变更可以做到秒级生效同时不会因为频繁反序列化和语法解析拖慢主链路性能。3.2 条件求值器的SPI设计条件求值器我没有搞成高耦合的策略模式而是用一个简单的SPI接口public interface ConditionEvaluator { boolean evaluate(String expression, RuleContext context); boolean support(String expression); }默认实现是表达式解析器但用户可以在自己的应用里实现这个接口把条件判断委托给Groovy脚本、QLExpress或者一个远程决策服务。support方法用于判断当前表达式是否为该求值器能够处理的格式。这个设计相当于是给引擎留了一个后门。有些业务的规则极其复杂用简单的、||根本写不出来需要脚本支持。这种场景下直接在规则里写script:groovy:...形式的表达式由自定义求值器拦截处理完全不影响其他规则。我在实际项目中就遇到过需要调用一个很复杂的定价函数的情况。如果为此扩展表达式语法工作量非常大走自定义求值器写一个类就搞定了。这也是SPI设计最直接的回报。3.3 动作执行与上下文传递的实现动作是规则条件命中后真正干活的环节。ruflo预置了几个常用动作处理器比如log打印日志、setVariable把指定值写入上下文、mapResult把指定字段写入output区、forward跳转到指定规则。实际业务里的动作大多通过SPI扩展。动作处理器的定义也很简单public interface ActionHandler { void execute(RuleContext context, MapString, Object params); String type(); }context是当前规则流的上下文params来自规则JSON里的params字段。动作执行异常不会让整个流程崩溃而是会被引擎捕获记录到执行结果里并中断当前规则流。你也可以通过配置把行为改成忽略错误继续执行这个开关在调试联调阶段特别有用。每个ActionHandler在引擎启动时注册到ActionRegistry规则编译阶段根据type解析到具体的处理器实例。如果某个type找不到对应的处理器编译阶段就会报错这能在规则发布前暴露问题而不是等到运行期才炸。4. 实战一个促销价格计算的规则流场景4.1 业务需求描述与规则拆分讲一个我们实际落地过的最典型的场景促销价格计算。需求是这样用户在结算页请求算价系统需要根据一系列条件计算最终应付金额。条件包括用户是新客还是老客订单金额是否超过门槛是否处于某个活动周期内是否命中特定商品类目是否触发风控拦截。如果不做拆分这些条件在Java代码里会变成一大坨嵌套if-else每次改动都心惊胆战。用ruflo拆分成规则流之后逻辑看起来清爽很多而且每个条件都是独立的一条规则改一个不影响其他。4.2 使用ruflo的规则定义方式假设我们有一条核心规则流price_calc_flow里面的规则大致如下{ flowCode: price_calc_flow, version: 20240518, rules: [ { ruleCode: risk_check, condition: riskService.isBlocked(user.id) true, actions: [ { type: mapResult, params: { success: false, message: 订单存在风险 } } ], next: break }, { ruleCode: new_user_discount, condition: user.isNew true order.amount 100, actions: [ { type: setVariable, params: { key: discountRate, value: 0.9 } } ] }, { ruleCode: category_exclude, condition: catalog.isExcluded(order.items) true, actions: [ { type: setVariable, params: { key: discountRate, value: 1.0 } } ] }, { ruleCode: calc_final_price, condition: true, actions: [ { type: calculatePrice, params: { discountKey: discountRate } } ] } ] }这里有几个细节值得说明risk_check如果命中会把结果标记为失败并break后面的规则不再执行这是最典型的风控前置拦截场景new_user_discount和category_exclude会向variables中写入discountRate后面的calc_final_price直接读取相当于规则之间通过上下文共享临时变量每条规则之间不需要显式声明依赖关系引擎按数组顺序执行数据通过上下文传递所有规则都通过配置中心管理团队在日常迭代中甚至不需要熟悉整个引擎源码。4.3 运行过程与结果验证当计算请求进来时调用方只需几行代码RuleEngine engine RuleEngineFactory.getEngine(); RuleContext context new RuleContext(); context.setInput(user, user); context.setInput(order, order); context.setInput(catalog, catalogService); RuleResult result engine.execute(price_calc_flow, context); if (result.isSuccess()) { BigDecimal finalPrice result.getOutput(finalPrice); // 返回给前端 }我把这个流程在本地跑了一遍用三组测试数据验证测试场景输入条件预期结果实际结果新客正常优惠新客、订单金额150元、非排除类目9折通过老客无优惠老客、订单金额80元原价通过风控拦截命中风控名单请求失败返回风险提示通过三条路径的日志输出都很清晰每条规则的命中情况和变量变化都记录在执行结果中。这个可追溯性也是规则引擎对比if-else的一个隐形优势——if-else代码执行完就没了而规则流天然有执行轨迹排查问题快很多。这套东西上线后最大的变化是改规则不再需要排队等发版。运营提需求后端在配置中心改JSON点发布几秒钟线上生效。省下的时间不止是发版时间还有每次发版前后的回归测试成本。5. 线上运行一年后性能优化与踩坑记录5.1 热点规则缓存与表达式预编译规则流引擎上线初期我最大的担忧是性能。毕竟多加了一层表达式解析和上下文传递整体链路比直接写Java代码多了一些开销。好在两件事救了回来。第一是表达式的AST预编译前面说过规则集编译完成后AST会缓存运行时只做求值不做解析。第二是热点规则的本地缓存通过版本号加规则集Code的双Key设计配置中心推送新版本时才重建缓存平时每次请求直接命中。在4核8G的容器上我们用压测工具模拟了每秒2000次的算价请求单次规则流执行的平均耗时稳定在0.8ms左右P99约2ms和直接写Java代码相比差距非常小。对一个业务规则引擎来说这个性能基本够用了。如果你的业务有更高的性能要求可以考虑两层缓存本地Cache作为L1Redis作为L2规则集版本号作为失效依据。不过目前我们的体量还没到需要上Redis缓存的程度。5.2 最容易踩的坑条件短路与变量缺失项目跑了一段时间后我们陆续踩过一些坑最典型的有三个。第一个是条件短路问题。表达式引擎实现了和||的短路求值这本来是好事但也埋了一个隐患如果前面的条件是一个不存在的变量而它被短路掉了可能整个规则都不会暴露问题。比如user.isNew true user.vipLevel 3当user为null时第一个条件已经确定结果为false后面的user.vipLevel根本不会被访问看起来规则正常。但一旦业务调整了条件的顺序立刻就会暴露空指针。为了避免这种情况我在规则校验阶段加了一个变量预检查解析AST时统计所有引用到的变量路径在规则加载时用测试数据跑一遍提前发现字段不存在的问题。这个检查在编译期做不会影响运行期性能。第二个是类型比较问题。JSON里的100默认会解析成Integer而订单金额字段可能是BigDecimal或Long。order.amount 100看起来没问题但比较两个不同类型时表达式引擎如果处理不当要么抛类型转换异常要么得出错误结果。我的解决方案是在比较运算时做统一的数值归一化所有数字先转成BigDecimal再比较private static BigDecimal toBigDecimal(Object value) { if (value instanceof BigDecimal) { return (BigDecimal) value; } if (value instanceof Long) { return BigDecimal.valueOf((Long) value); } if (value instanceof Integer) { return BigDecimal.valueOf((Integer) value); } if (value instanceof Double) { return BigDecimal.valueOf((Double) value); } throw new ExpressionEvalException(Unsupported number type: value.getClass()); }第三个是循环跳转问题。有了jump指令如果配置不当可能形成A跳到B、B又跳回A的死循环。我在实现里加了一个最大执行步数限制默认是1000步超过就抛异常中断流程。这个限制也顺带防住了某些误配置导致的无限循环问题。5.3 线程安全与扩展性边界还有一个容易被忽略的点是线程安全。规则流引擎在绝大多数应用里是单实例、多线程并发使用的。这就要求编译后的RulePlan、AST节点、预置的动作处理器都必须是不可变或只读的每次请求创建的RuleContext才是可变的。我在设计时严格遵守了这个原则所有表达式AST节点在执行过程中不修改状态动作处理器虽然是单例但内部不持有任何请求级别的数据。举一个具体例子setVariable这个内置动作它的execute方法只做两件事从params里取值然后写到传入的RuleContext里。它自己没有任何成员变量所以多个线程同时调用也没有状态冲突。所有自定义动作处理器都要遵循这个约定这也是我在团队代码评审时重点盯的一个点。扩展性方面ruflo目前支持三个SPI扩展点自定义条件求值器、自定义动作处理器、自定义变量获取器。这三个点覆盖了我在实际业务中遇到的大部分扩展需求。如果未来碰到需要分布式规则编排的场景可能需要在引擎之上再加一层分布式协调但那是另一个话题了。最后说一个运维小技巧规则配置里一定带上版本号和生效时间出问题的时候回滚会快很多。我们一开始没太在意后来在一次误配置导致线上故障时深有体会——有了版本号和生效时间改配置、发现问题、回滚整个流程能在五分钟内完成。这个习惯我一直保持到现在。