资讯动态

告别if else地狱:手写轻量级规则执行器实战

发布时间:2026/10/16 9:04:22 来源:尧图企业网站定制
我很清楚那种感觉需求评审会上产品说“这个周末再加一个会员折扣”你回到工位打开下单结算那块代码看到被几拨人改过、嵌套了十几层 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而不是实时total200 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 链从头到尾读一遍不用纠结一个括号放到哪里才是正确的。改一条规则、跑一遍用例事情就结束了。你可以从最小的场景开始把一两个最痛苦的判断换成规则表跑通后再慢慢扩展别一上来就想着做出一个通用规则平台。这个分寸比写执行器本身更值得拿捏。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑