我们搞后端和业务系统的,十有八九都被“规则散落一地”这事恶心过。今天聊的ruflo,是我最近在内部搭的一个轻量规则引擎,名字取自rule flow,核心就是让“一条业务规则”从代码里抽出来,变成能独立维护、独立测试、独立发布的东西。它能解决什么问题?最直观的:你把几百行 if-else 拍平,把“订单金额大于 1000 且用户等级为 VIP”这类条件从 Java 代码里挪到一个 DSL 配置文件里,业务同学改规则时不用再拉上开发排期。
这套东西适合谁?如果你手里是一个长期迭代的业务系统,被各种状态流转、审批流、计费规则缠得喘不过气,那 ruflo 这种“规则引擎 + 流程编排”的思路可以参考。不想引一堆重框架,就想要一个自己控得住、能看懂、能快速改的规则引擎,那下面这些设计思路和踩坑记录,应该能帮你少走不少弯路。
1. 项目背景与核心设计思路
1.1 为什么我决定不依赖现成规则引擎
一开始我也考虑过 Drools、Easy Rules,甚至工作流引擎 Flowable。但最后发现,针对我们团队的实际情况,这些重武器有点“大炮打蚊子”。原因很实际:第一,我们的规则主要集中在订单状态流转、优惠计算、风控预检这几个场景,规则维度有限,不需要复杂到专家系统;第二,团队里多数人没接触过 Drools 的 DRL 语法,学习成本高,出了问题不好排查;第三,业务方提的需求经常是改阈值、加个判断条件,我希望改动粒度能做到“只改配置,不重新发版”。
所以 ruflo 的设计目标从一开始就很明确:轻量、可读、可发布。代码量控制在一个中等规模项目内,DSL 贴近业务语言,规则配置存储到数据库或 Git,支持实时刷新。
我先把整体架构画在脑子里,分成了四块:规则描述层(DSL + 配置)、规则编译层(解析 DSL 生成执行树)、规则执行层(执行引擎 + 上下文管理)、规则管理端(配置展示、模拟测试)。这个分层方式,让后来的扩展和排查都轻松很多。
1.2 ruflo 的规则抽象:从 if-else 到数据驱动
我在 ruflo 里把一条规则抽象成三个组成部分:条件(Condition)、动作(Action)、优先级(Priority)。同时引入“规则组(RuleGroup)”的概念,一组规则按优先级顺序执行,命中的规则决定最终结果。
规则组: 订单优惠计算 规则1: 如果 订单金额 >= 1000 且 用户等级 == VIP,则 折扣 = 0.85 规则2: 如果 订单金额 >= 500,则 折扣 = 0.90 规则3: 默认 折扣 = 1.0这样的抽象业务同学能看懂,开发也能快速定位问题。代码里只需要写一个RuleEvaluator,把规则组加载进来,遍历执行即可。真正复杂的是“条件”的表达方式和“动作”的扩展性,下面两节细讲。
2. 核心细节解析与实操要点
2.1 DSL 设计:让规则可读、可写、可校验
DSL 是 ruflo 的门面,设计得好,业务同学可以自己上手;设计得烂,就是另一个只有程序员才能维护的黑盒。我参考了 JSON 和 YAML 的优缺点,最后选了 YAML,因为注释和缩进结构对非开发更友好。
一个规则组配置长这样:
ruleGroupId: order_discount name: 订单优惠计算 version: 3 rules: - ruleId: vip_discount name: VIP专属折扣 priority: 1 when: and: - field: orderAmount operator: ge value: 1000 - field: userLevel operator: eq value: VIP then: setField: discountRate value: 0.85 - ruleId: normal_discount name: 普通用户满减 priority: 2 when: and: - field: orderAmount operator: ge value: 500 then: setField: discountRate value: 0.90 - ruleId: default_discount name: 默认无折扣 priority: 99 then: setField: discountRate value: 1.0when部分用树形结构表达条件,支and、or、not逻辑组合;叶子节点是三要素:field、operator、value。operator支持eq、ne、gt、ge、lt、le、in、contains这些常用操作符。then部分目前主要是setField,即对上下文中的某个字段赋值。
这个 DSL 的校验逻辑也蛮重要。我在编译阶段做两层检查:第一层是 schema 校验,用的 YAML 解析器的字段校验,保证结构完整;第二层是语义校验,检查字段名在上下文中是否存在、操作符是否支持、值类型是否匹配,避免规则写错了导致运行时空指针。
2.2 条件表达式求值:背后的 AST 与执行器
条件表达式不会直接用 YAML 嵌套判断,那样每执行一次都要遍历嵌套 Map,性能差且代码难维护。ruflo 会在规则加载时把 YAML 的when部分解析成一棵AST(抽象语法树),每个节点是一个ConditionNode,接口如下:
public interface ConditionNode { boolean evaluate(EvalContext context); }AndNode、OrNode、NotNode分别对应逻辑组合,LeafConditionNode则负责字段比较。这种设计的好处是:加载一次,后续执行直接遍历树,不需要反复解析;同时,也方便单测每个节点。
LeafConditionNode里最核心的是类型转换。从 YAML 读到的value默认是字符串,但字段可能是数值、日期,所以我实现了一个TypeConverter,根据上下文字段的类型自动把配置值转成对应类型。日期统一处理成LocalDateTime,支持yyyy-MM-dd HH:mm:ss和yyyy-MM-dd两种格式。
这里有个实际踩坑:用Double.parseDouble转换金额时,如果配置里写的是1000.0,而上下文里的 BigDecimal,两个值用equals比较会失败,因为BigDecimal(1000.0)和BigDecimal(1000)的 scale 不一样。所以数值比较统一用compareTo,不能用equals。
2.3 执行引擎:上下文、规则链与短路逻辑
执行引擎是 ruflo 的心脏。每次业务调用,先构建一个EvalContext,里面有三个 Map:inputData(入参)、outputData(结果)、internalData(内部状态)。规则链遍历过程中,既可以从inputData读字段,也可以把中间结果写进outputData,供后续规则引用。
核心执行代码简化后如下:
public void execute(String groupId, EvalContext context) { RuleGroup group = ruleGroupRegistry.get(groupId); if (group == null) { throw new RuleNotFoundException("规则组不存在: " + groupId); } for (Rule rule : group.getSortedRules()) { if (rule.getWhen() == null || rule.getWhen().evaluate(context)) { rule.getThen().execute(context); if (rule.isTerminal()) { break; } } } }这段逻辑里有一个terminal标志,被标记为terminal的规则命中后,后续规则不再执行。这个设计解决了一个实际问题:默认折扣规则优先级最低,但为了确保它兜底,我们会把terminal设成 false,让它覆盖前面规则设置的值;但有时候我们希望“命中某个条件后直接返回,不再看其他规则”,就把那条规则标记为 terminal。
我在规则组里还加了一个strategy字段,支持first_match和all_match两种策略。first_match对应短路,匹配第一条就停止;all_match会执行所有命中规则,适合优惠叠加之类的场景。这个开关看着小,却让规则组表达能力提升了一大截。
3. 实操过程与核心环节实现
3.1 流程编排:从“规则”到“流程”的演进
光有规则组还不够,订单这种业务天然是流程:创建订单 -> 风控预检 -> 优惠计算 -> 库存锁定 -> 支付。我在 ruflo 的第二版里加了轻量流程编排,节点类型包括:规则节点(RuleNode)、脚本节点(ScriptNode)、条件分支节点(ExclusiveGateway)、结束节点(EndNode)。
一个流程用 YAML 描述节点和连线:
flowId: order_create_flow name: 订单创建主流程 version: 5 nodes: - nodeId: start type: start next: risk_check - nodeId: risk_check type: rule ruleGroupId: risk_check_rules next: discount - nodeId: discount type: rule ruleGroupId: order_discount_rules next: gateway_check - nodeId: gateway_check type: exclusiveGateway branches: - condition: and: - field: riskPass operator: eq value: "false" next: reject - condition: and: - field: riskPass operator: eq value: "true" next: end - nodeId: reject type: script script: | context.setOutput("status", "REJECTED"); next: end - nodeId: end type: end这一版让我体会到了规则引擎和流程引擎的边界:规则引擎关注“条件映射到结果”,流程引擎关注“状态之间的流转关系”。它们用同一套 DSL 表达,但流程节点多了next和branches两个字段,让执行引擎变成了一个有向图遍历。
3.2 条件分支节点的实现细节
exclusiveGateway节点是流程控制的核心,需要从branches中按顺序找到第一个条件为 true 的分支,然后跳到对应节点。这里有一个性能细节:分支条件的 AST 不用每次都重建,节点加载时就把条件字符串解析成了ConditionNode。
但切到流程编排后,我发现一个 bug:有些分支条件在评估时会读字段,而字段在流程中间还没被赋值(比如riskPass在前置脚本里才写入),导致空指针。我的处理方式是,EvalContext.getField在字段不存在时返回一个NullValue单例,而不是 Java 的null,然后所有条件操作符对NullValue都有定义好的行为:eq和ne可以正确判断,gt、lt这些数值操作返回 false。这样避免了到处判空,也让语义更明确。
3.3 规则热更新与版本管理
规则要“只改配置,不重新发版”,热更新是必备能力。我实现了两种加载方式:本地文件加载和数据库加载。本地文件模式适合开发和测试;生产环境用数据库,规则组和流程配置存 MySQL,启动时加载到本地缓存,再开启一个定时任务每 30 秒检查一次版本号,有变化就重新加载。
版本管理我用的是增量更新而非全量替换。每条规则和流程都有一个version字段,缓存里保留最近 N 个版本,方便回滚。当新版本加载失败时,保持旧版本继续服务,并记录告警日志。这比直接覆盖缓存安全得多。后来我在生产环境遇到一次规则配置格式错误,就是因为这个设计,线上流量完全没有受影响。
配置发布这里有一个团队协作的坑:业务同学直接改数据库配置,改坏了一句 YAML,整个规则组都挂了。所以我在规则管理端加了“校验后发布”的流程:先预解析、预执行模拟用例,通过后才写入正式表。这个校验动作本质上就是一次完整的加载和试跑,成本很低,但能挡住绝大多数低级错误。
3.4 规则执行埋点与监控
规则引擎是业务逻辑的密集区,出了问题必须能快速定位是哪条规则、哪个条件导致的。我在执行链路上埋了三种数据:执行轨迹、耗时明细、命中日志。
执行轨迹是一个栈结构,记录每个节点进入和退出的时间戳,以及当前规则组的 ID 和版本号。耗时明细用来定位性能瓶颈,有一次我发现某个规则组平均耗时 200ms,通过明细定位到是脚本节点里做了一个耗时 180ms 的 RPC 调用。命中日志则记录了每条被评估规则的ruleId、条件结果、执行结果,按 traceId 串起来,方便排查业务问题。
监控指标全部打到 Prometheus,规则组维度有:执行次数、平均耗时、命中率、异常次数。命中率是最值得关注的指标,如果某条规则命中率突然飙升,大概率是前面的规则配置失效了,条件没匹配上,后续规则兜底了。这类问题靠日志不好发现,但看命中率曲线一眼就能看出来。
4. 常见问题与排查技巧实录
4.1 条件字段不正确:开发与业务之间的“翻译”误差
最常见的问题不是代码 bug,而是“字段名对不上”。业务同学在配置里写orderAmount,但代码上下文里字段叫amount,导致规则一直不生效。我的解决方案是:在管理端提供字段字典,展示当前上下文有哪些字段、含义、示例值,配置的时候可以下拉选择,避免手写。并且在规则加载时做字段引用检测,凡是引用了不存在的字段,直接报警。
后来我发现一个更隐蔽的坑:字段名存在,但类型不一样。比如上下文里userLevel是Integer(1,2,3),配置里写VIP,eq操作符按字符串比较永远为 false。我在类型转换失败时抛异常,但更好的做法是在配置端就提示这个字段期望的类型。这个提示功能加完之后,配置出错率下降了七成。
4.2 规则引擎常见故障速查表
我把实际运维过程中遇到的典型问题整理成一个表,方便大家对照排查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 规则完全不生效 | 规则组 ID 配置错误,或缓存未刷新 | 先查缓存中是否存在该规则组,再查版本号是否更新 |
| 条件一直为 false | 字段名不对或类型不匹配 | 开启规则轨迹日志,查看条件叶子节点的实际值 |
| 多个规则同时命中导致结果异常 | 优先级设置不合理,或策略选错 | 检查priority顺序,确认first_match/all_match |
| 执行耗时突然飙升 | 脚本节点里做了远程调用或大数据量循环 | 看耗时明细,定位到具体节点 |
| 新版本配置导致线上异常 | 配置校验绕过,或版本回滚不及时 | 确保发布前走“校验后发布”流程,保留上一版本 |
| 并发毛刺导致规则组加载失败 | 数据库连接池或缓存并发问题 | 检查加载逻辑是否加锁,确保失败不影响旧版本 |
4.3 我还踩过的一个脚本沙箱的坑
脚本节点是灵活性最高的部分,我用的是 Java 内置的ScriptEngine跑 JavaScript,最初没做任何沙箱限制。结果某次测试,一个脚本里写了死循环,直接打满一个 CPU 核心。虽然不至于拖垮整个应用,但生产环境这种事必须杜绝。
后来我加了两个限制:第一,脚本运行时间超时强制中断,通过一个自定义的Runnable包装,超过 300ms 就抛异常;第二,脚本里禁止访问系统类,白名单机制只允许调用context对象的有限方法。规则引擎的“灵活性”必须建立在安全可控的边界上,否则它就是隐患。
4.4 从技术到协作:规则引擎改变了团队工作方式
最后说点感受。ruflo 上线三个月后,最直观的变化不是代码量减少了多少,而是需求流转方式变了。原来改一个优惠阈值,开发改代码、测试回归、发版,最短半天;现在业务在管理端把阈值从 500 改成 300,测试在预发环境跑两个用例,确认无误后直接发布,整个过程不到十分钟。
但这也带来一个新问题:规则越来越多、越来越复杂后,没人能完全说清某个规则为什么存在。所以我后来在规则配置里强制加了owner(负责人) 和remark(备注) 两个字段,说明这条规则是哪个需求、什么时候加的、目的什么。这不算技术问题,但我想提醒准备做规则引擎的朋友:规则的治理和规则的执行同等重要。引擎本身解决的是“怎么跑”,真正的长期价值要靠“怎么管”来保障。