我很清楚那种感觉:需求评审会上产品说“这个周末再加一个会员折扣”,你回到工位打开下单结算那块代码,看到被几拨人改过、嵌套了十几层 if else 的优惠计算函数,瞬间头皮发麻。改动一处分分钟引入回归,加一条判断就得把整条链路在脑子里跑一遍;不加又不行,结果只能硬着头皮在底部再接一层。如果你也经历过这种“if 链越长,手越抖”的阶段,那规则执行器这个概念值得花十分钟认真了解一下。
规则执行器不是什么新名词、新框架,说白了就是把“先判断、后执行”的逻辑从代码里抽出来,变成一条条可以独立维护、单独调整优先级、甚至支持热更新的规则数据。它不再让业务条件散落在各个函数里,而是集中在一张“规则表”中,由一个小巧的执行器统一匹配、统一执行。这篇文章写给正在为大量条件分支头疼的后端、前端开发者,也写给想找一个轻量方案替代复杂规则引擎的团队。我会用它解决什么问题、底层思路是什么、代码怎么写、落地要注意哪些坑,一次讲清楚。
1. 大量if判断的痛点:变更成本是怎么失控的
1.1 一段越来越不敢改的优惠代码
先还原一个典型场景。某电商后台的订单结算模块,一开始只有两个规则:满 200 减 30,新客首单减 10。代码很简单,两个 if 就完事。后来运营说会员要额外打 95 折,好,再嵌套一个条件;再后来某些品类不参与活动,又要加判断;再后来同一个订单里如果有特价商品,整单不享受常规优惠,于是代码变成了一个金字塔结构:
function calcTotal(user, order) { let total = order.subtotal; if (order.subtotal >= 200) { total -= 30; if (user.isMember) { total *= 0.95; if (user.isNewUser && user.orderCount === 0) { total -= 10; } } } else { if (user.isNewUser && user.orderCount === 0) { total -= 10; } } if (order.items.some(item => item.special)) { total = order.subtotal; } return total; }这段代码还没到很夸张的程度,但你已经能闻到危险的味道了:优惠的叠加顺序完全靠代码嵌套层级表达,新客和满减的优先级不直观,特价商品逻辑一加,前面所有分支都变成了废逻辑。等运营再提一个“周末满 100 减 20、但要排除数码类商品”的需求,你打算怎么插?插在哪个嵌套深度?会不会影响会员折扣?这种问题每隔两周就会来一次。
1.2 可读性差只是表象,真正的问题是变更成本
很多人把这种代码的问题归纳为“可读性差”,我觉得只说对了一小半。可读性差是表面,真正的成本在变更上。每次改规则,你都要重新把所有分支在脑子里模拟一遍执行过程,确认新规则和旧规则之间的优先级、互斥关系、叠加关系,然后还要想想别的地方有没有类似的另一份判断逻辑。
更麻烦的是条件与行为耦合。同样的判断,比如“这个用户是不是会员”,会在下单、支付、营销通知、售后多个模块里各写一遍。今天会员口径从“积分大于 1000”改成“连续包月用户”,你得全局搜索每一处。这种隐性重复,比代码丑更消耗团队精力。
还有一个藏在代码里的问题:优先级是被隐含在书写顺序里的。代码从上到下执行,谁先谁后就是你人工排列的,时间一长没人说得清为什么满减要在新客前面。如果哪天有人把两个子块换了一下位置,线上金额立刻变得不可解释。
1.3 规则执行器解决的三个核心问题
规则执行器正是冲着这几个问题去的。第一,它把条件和行为拆开,规则变成数据,任何一个入口都可以复用同一套规则。第二,优先级被显式化,每条规则自带顺序权重,不再依赖代码位置的巧合。第三,新增规则的成本从“读懂整条老链路”降为“新增一条规则数据”,老逻辑不用动。
我见过很多团队把“规则引擎”想得很大,其实中小型项目根本用不着上重型方案。你需要的可能只是一段几十行的执行器,把规则表管起来,就能让代码库的气味发生质变。后面会给出完整实现。
2. 规则执行器的核心思想:把判断变成可配置的数据
2.1 业务逻辑本质上是决策表
想通这个问题,规则执行器就理解了八成。商场的促销活动墙上通常贴着一张表:哪个时间段、什么会员等级、满多少、减多少、能不能叠加。客户看的是这张表,收银员核对的是这张表,财务审计的也是这张表。业务规则本质上就是一张多行多列的决策表,而不是嵌在收银系统代码里的 if。
把这张表挪到代码里,就是一组规则对象;把“从上往下逐行查看”这个动作实现出来,就是规则执行器。规则表可以被写死在代码里,也可以来自配置文件、数据库、远程配置中心。一旦规则变成了数据,你就能享受数据的三大福利:可查询、可追溯、可动态修改。
有一些人第一次见到规则执行器会问:不就是把 if 换成了 foreach 吗?这么说不完全错,但漏掉了最关键的变化。if 版本里,条件和条件是强耦合的,嵌套关系肉眼可见;规则版本里,一条规则就是一行,与其他规则没有结构关系,改其中一条完全不需要看别人脸色。这就是“数据化”带来的解耦。
2.2 规则的三种抽象形态怎么选
一条规则至少要有名字、条件、动作、优先级四要素。条件怎么写,决定了整个方案的上限,常见三种形态。
函数式条件最直接,when就是一个返回布尔值的函数,适合规则固定、主要给开发维护的场景,也是我接下来代码示例里用的形态。对象式条件把条件描述成结构化数据,例如{ field: 'order.subtotal', op: '>=', value: 200 },能够存数据库、能够被后台配置,但需要自己实现字段取值和比较器。表达式式条件用字符串表达,比如"order.subtotal >= 200 && user.isMember",灵活度更高,可是引入了表达式解析和安全性问题,通常只有规则需要频繁由非技术角色维护时才值得用。
我的建议很明确:第一步永远用函数式,先把规则执行器的骨架和收益跑出来;等确实出现了“运营每月都要加规则”的硬需求,再平滑迁移到对象式或表达式式。为了一个还没出现的使用场景去造一套表达式解析器,是我见过最常见的过度设计。
2.3 自研轻量执行器还是引入重型引擎
这个选择题,我倾向于在多数项目里先自研。重型规则引擎功能确实强,支持复杂推理、规则脚本、会话状态等,但换来的学习成本、依赖体积、调试难度也实实在在。很多项目规则量只有几十条,计算逻辑完全在业务代码里能够表达,这时候用重型引擎就像用航母运快递。
| 对比维度 | 重型规则引擎 | 自研轻量执行器 |
|---|---|---|
| 学习成本 | 高,团队需要系统学习 | 低,几十行代码能读懂 |
| 配置化能力 | 强,适合业务人员直接操作 | 弱,通常需要开发协助 |
| 调试排障 | 中间环节多,不好跟 | 透明,执行轨迹一目了然 |
| 依赖与体积 | 重,引入一整套运行时 | 零依赖 |
| 适用规模 | 上百条规则、复杂推理 | 几十条规则、中等复杂业务 |
什么情况下该上重型引擎?规则数量超过百条、规则之间有多级推理、需要非技术角色频繁增改规则且系统风险要求特别高。只有这些信号都出现了,再考虑迁移不迟。轻量执行器不是玩具,它是让大部分业务摆脱 if 地狱的高性价比答案。
3. 手写一个轻量级规则执行器:从0到1的完整代码
3.1 规则长什么样:条件与动作的建模
规则定义我建议使用统一的接口,方便后面扩展。核心字段就是名字、优先级、条件、动作,再加一个可选的“命中后是否继续执行”开关:
const discountRules = [ { name: '新人首单立减10', priority: 100, when: (ctx) => ctx.user.isNewUser && ctx.user.orderCount === 0, then: (ctx) => { ctx.result.notes.push('新人首单立减10'); ctx.order.total -= 10; } }, { name: '满200减30', priority: 90, when: (ctx) => ctx.order.subtotal >= 200, then: (ctx) => { ctx.result.notes.push('满200减30'); ctx.order.total -= 30; } } ];ctx是贯穿整次计算的上下文对象,里面放了用户信息、订单信息,以及一个用于记录执行轨迹的result对象。为什么要把所有数据塞进一个上下文,而不是给when传一堆参数?因为规则数量一旦多起来,参数签名变化会牵连所有规则;而上下文对象是稳定的容器,加字段不影响老规则。这个设计决策很便宜,但能让规则维护省心非常多。
3.2 执行器的匹配流程与优先级排序
执行器本身很短,核心就是构造时排序、执行时遍历。排序决定了规则的确定性,遍历时记录命中轨迹并判断是否中断:
class RuleExecutor { constructor(rules) { this.rules = [...rules].sort((a, b) => b.priority - a.priority); } execute(ctx) { ctx.result = { notes: [] }; for (const rule of this.rules) { if (ctx.result.stop) break; if (rule.when(ctx)) { ctx.result.notes.push(`命中: ${rule.name}`); rule.then(ctx); } } return ctx; } }这个执行器有几点值得展开说。
优先级数值大的先执行,这是我习惯的约定,因为高优先级往往意味着“强约束”或“特殊场景”,放在前面更自然。但这只是一个约定,你完全可以用小数值优先,关键是全团队统一并在注释里写明白。
默认情况下一条规则命中后不会中断,后续规则继续匹配。这对应“优惠叠加”的业务语义。如果想实现“命中即止”,规则里设置ctx.result.stop = true即可。中断逻辑放在执行器里的好处是规则代码不用关心循环控制,只需要表达“我不想继续了”。
3.3 一个真实感的订单优惠场景完整演示
用一个常见电商场景把上面的东西串起来。假设订单里有两件商品,数码类 120 元,服饰类 80 元,小计 200 元。用户是新注册用户、也是会员,历史订单数为 0。促销规则是:新客首单减 10、满 200 减 30、会员再打 95 折、特价商品订单不参与任何优惠。
const rules = [ { name: '特价商品订单不叠加优惠', priority: 1000, when: (ctx) => ctx.order.items.some(item => item.special), then: (ctx) => { ctx.order.total = ctx.order.subtotal; ctx.result.notes.push('特价商品不参与优惠'); ctx.result.stop = true; } }, { name: '新人首单立减10', priority: 100, when: (ctx) => ctx.user.isNewUser && ctx.user.orderCount === 0, then: (ctx) => { ctx.result.notes.push('新人首单立减10'); ctx.order.total -= 10; } }, { name: '满200减30', priority: 90, when: (ctx) => ctx.order.subtotal >= 200, then: (ctx) => { ctx.result.notes.push('满200减30'); ctx.order.total -= 30; } }, { name: '会员95折', priority: 80, when: (ctx) => ctx.user.isMember, then: (ctx) => { ctx.result.notes.push('会员95折'); ctx.order.total = Math.round(ctx.order.total * 0.95 * 100) / 100; } } ]; const executor = new RuleExecutor(rules); const ctx = executor.execute({ user: { isNewUser: true, isMember: true, orderCount: 0 }, order: { subtotal: 200, total: 200, items: [ { name: '耳机', category: 'digital', price: 120 }, { name: 'T恤', category: 'clothing', price: 80 } ] } }); console.log(ctx.order.total); // 152 console.log(ctx.result.notes);执行顺序是这样的:特价规则判断订单中没有特价商品,不命中;新人规则命中,订单从 200 减到 190;满减规则判断的是subtotal而不是实时total,200 >= 200 成立,减 30 到 160;会员规则命中,95 折后落在 152。
这里有一个非常值得玩味的细节:满减条件我故意用了subtotal,而不是total。如果条件写成ctx.order.total >= 200,那新人立减之后 total 变成 190,满减就不会触发,最终价格变成 180.5。同样的规则、同样的数据,因为条件里引用的是“实时余额”还是“原始金额”,结果天差地别。这就是规则设计里最隐蔽的坑之一:条件与动作之间的数据依赖。规则执行器本身不会帮你判断这个设计对不对,但它给了你一个显式的上下文,让你更容易发现和定义这种依赖。
3.4 命中即停与叠加执行的两种模式
上面的例子同时出现了两种模式:特价规则是“命中即停”,新人、满减、会员是“叠加执行”。如果一个规则执行器只支持一种模式,场景适应性会很差。
我见过有些团队把“命中即停”做成执行器的一个全局开关,比如execute(ctx, { stopOnMatch: true })。这能满足一部分需求,但不够灵活。同一张规则表里往往既有排斥性规则,又有叠加性规则,把它们强行分成两个执行器会破坏统一部署。用规则内部设置stop标志,让每条规则自己决定行为,是最灵活的方案。
在这种设计下,规则表里所有规则共享同一个上下文和同样的执行流程,规则之间通过优先级和 stop 标志协调。代价是,规则多了之后,仅靠肉眼不容易立刻看出哪几条会撞车。这个问题我会在第五部分专门说排查手段,现在先记住:可观测性要在一开始就埋进执行器,也就是result.notes这些执行轨迹,千万别等出事了才想着加日志。
4. 落地经验:不改架构的前提下接进现有系统
4.1 规则表放哪里:代码、配置中心还是数据库
轻量执行器接进老项目的第一道选择题是规则表存在哪里。最简单的是直接写在代码里,随版本发布,适合规则稳定、变更频率低的团队。稍微进阶一点,把规则表放到配置中心,用 JSON 文件描述,启动时拉取到内存。这样改规则不需要发版,部署风险明显下降,我的大多数项目推荐从这一步开始。再复杂一些,规则表放进数据库,配合一个简单的管理后台,让运营自己维护。这个阶段收益变大,成本和风险也同步上升:数据要校验、发布要审批、实时生效要给灰度。
提供一个折中经验:初期把规则做成代码里的常量数组,但提前设计成“能够被配置替换”的结构。也就是说,规则对象的字段保持 JSON 友好,别在代码里写死闭包依赖外部变量。这样后续要从代码切到配置中心,迁移成本就是一段序列化和反序列化的逻辑,而不是重写规则。
4.2 热更新规则要注意的三个坑
很多团队被“热更新不需要发版”吸引,认可配置中心方案,但落地时掉进坑里。
第一个坑是规则更新没有版本概念。配置一改,全量用户立刻受影响,如果新规则算错了,用户看到的价格已经错了,你只能靠人工恢复。我的做法是给规则表加一个version字段,加载时记录版本号,发现问题可以一键回退到上一个版本。配合审计日志,每一次变更是谁在什么时间改了哪条规则,全部可查。
第二个坑是规则和业务代码的兼容性。规则表是数据,但规则动作仍可能是代码函数。如果你的配置中心只存了条件字段,动作还是要走代码分发,那你改配置只能改“什么时候触发”,不能改“触发后做什么”。这未必是坏事,但需要让团队明确边界,免得有人以为配置一个动作名称就能执行任意逻辑。
第三个坑是加载时机。启动加载一次之后,必须要有显式的刷新入口,否则配置中心改了,线上内存还是旧规则。更稳的做法是用发布订阅模式监听配置变更,收到变更先校验、再加载、打一条日志。别省这一步,我见过线上跑了两天旧规则、业务方来问为什么价格不对的惨案。
4.3 规则多了之后怎么做性能优化
几十条规则逐个遍历,单次耗时可以忽略,根本不用优化。真正影响性能的是两件事:上下文对象构造太慢,表达式解析太慢。
线程内的请求数据本来就存在,把它组装成ctx时尽量减少不必要的深拷贝和复杂计算。比如订单 OCR、库存查询这类重操作,绝对不能在每条规则的when里重复执行,应该放进上下文里提前算好并缓存。否则规则条数一多,每条规则都触发一次昂贵计算,QPS 一上来就会出问题。
表达式字符串如果使用不当,也是性能杀手。有些实现会在每条规则每次执行时重新parse甚至eval表达式,这等于把编译开销平摊到每次请求上。优化方案是“解析一次、执行多次”:规则加载阶段把字符串表达式编译成函数,缓存到内存里;需要动态拼字段的表达式,也尽量用白名单约束操作符,避免直接 eval 带来的安全隐患。对这些细节,我的原则是:先按最简单的函数式写,等 Profile 数据显示规则执行占了可观比例,再动手优化。
5. 常见问题与排查技巧实录
5.1 两条规则同时命中导致结果不对怎么定位
规则执行器用得多了,最常遇到的现象是:新加一条规则后,某些订单金额不对,而且看起来不是简单多减一笔。定位这类问题的第一手法是看执行轨迹。
我在执行器里把每次命中都写进了ctx.result.notes,出问题时直接把 notes 抛出来:命中了哪几条、顺序是什么、每步之后的金额变化,一目了然。如果你用的是自己那套执行器,这个日志能力一定要加上。没有轨迹,你就只能靠打断点慢慢走,规则少还行,规则一多就非常痛苦。
第二个手法是画一张“规则矩阵”。把所有规则两两组合跑一遍,看结果是否符合业务预期。这个工作适合做成自动化测试,后面会讲。我实际排查过一个案例,两条规则单独都对,合在一起错,原因是第一条命中后把total改动了,第二条的when判断依赖的正是这个被改过的total。最后通过在记录里增加每一步的快照,才快速确认了数据依赖链。
5.2 条件不生效又不报错的隐蔽原因
有一种最让人抓狂的情况:规则明明写在表里,执行器也遍历到了,但就是不命中,也没有报错。原因往往是字段名写错或者条件表达式返回了预期之外的值。
比如ctx.order.subtoal这种拼写错误,得到的值是undefined。如果when里写的是ctx.order.subtoal >= 200,结果永远是false,规则静默跳过。更隐蔽的是把条件写成了!ctx.user.isMember,当字段确实不存在时,undefined取反会得到true,规则莫名其妙地永远命中。这两种情况都不会有异常提示。
我的排查办法是给执行器加一个“规则诊断模式”:调试时把每条规则的when结果打出来,同时给上下文里用到的关键字段打一遍快照。上线前再做一遍规则字段的静态校验,对照预期 schema 检查字段是否存在。这比等线上出问题再猜要快得多。
5.3 给规则表做数据驱动测试,防回归
规则执行器最容易被低估的优势是测试变得极其方便。因为规则是数据,你可以准备一批“输入数据 + 期望结果”,然后用循环执行测试:
const cases = [ { desc: '普通订单满200减30', user: { isNewUser: false, isMember: false }, order: { subtotal: 200, items: [] }, expected: { total: 170, notesCount: 1 } }, { desc: '新客订单叠加会员折扣', user: { isNewUser: true, isMember: true, orderCount: 0 }, order: { subtotal: 200, items: [] }, expected: { total: 152, notesCount: 3 } } ]; testCases.forEach((tc) => { const ctx = executor.execute(tc.user, tc.order); assert.equal(ctx.order.total, tc.expected.total, tc.desc); });这种数据驱动测试的好处是,每新增一条规则,测试集里加一行用例,跑一遍就能知道旧规则有没有被影响。我在项目里把规则相关的测试做成了 CI 的一部分,运营每次提交规则配置都会触发回归,线上事故率降了一大截。如果你不想等配置平台,单纯把规则数组导出 JSON 做一层快照测试,也能获得很高的安全感。
5.4 比规则执行器更重要的编码习惯
最后说一个经验之谈:规则执行器能帮你把 if 地狱整理成规则表,但它解决不了所有问题。它更倾向于“已经知道有多少种情况,只是希望管理起来更清爽”的场景。如果你的需求本身就是混乱的、边界一直在变,那先花时间做需求梳理,别急着写规则。
还有一点是关于代码审查的。规则执行器刚落地时,团队里每个人都能很快学会“加一条规则”,这会导致规则数量快速增长。三个月后回头看,规则表里可能躺着好几条互相矛盾的废弃规则。所以一定要在规则对象里加status和effectiveTime这类生命周期字段,并定期清理。没有清理机制的规则表,最终会变成另一种形态的混沌。
我在实际项目里落地这套轻量规则执行器后,最大的感受是接到“加个规则”的需求时心态完全变了,不用再把整条 if 链从头到尾读一遍,不用纠结一个括号放到哪里才是正确的。改一条规则、跑一遍用例,事情就结束了。你可以从最小的场景开始,把一两个最痛苦的判断换成规则表,跑通后再慢慢扩展,别一上来就想着做出一个通用规则平台。这个分寸,比写执行器本身更值得拿捏。