声明式业务规则:用DSL告别if-else,让规则可审阅可组合

声明式业务规则:用DSL告别if-else,让规则可审阅可组合
如果你维护过一个带优惠、风控或审核功能的业务系统大概率经历过这个场面产品经理说“这次只改一条规则”你打开代码仓库发现这条“规则”藏在四层调用链里后面还跟着两个互相矛盾的 else 分支。你加日志、走灰度、小心翼翼地发版结果线上还是出现了一笔不该出现的折扣。这个问题的根源往往不是需求复杂而是业务规则的表达方式选错了。最近技术社区上出现了一个叫 Lemma 的项目定位是一句很直接的话“A declarative language for business rules”——一种用于业务规则的声明式语言。初看像又造了一个规则引擎但它的项目名值得琢磨Lemma 在数学里是“引理”是被证明之后可以反复复用、用来构建更大定理的辅助命题。把“业务规则”和“引理”放在一起本身就暗示了一种设计取向规则不应该是一段只可意会的代码而应该是一种可以被声明、被校验、被组合的领域语言。我的一个明确判断是Lemma 这类方案真正的价值不在于发明一种新语法让代码看起来更简洁而在于把业务规则的表达方式从“命令式函数”转移到“可审阅、可验证、可组合的声明式结构”。谁先理解这一点谁就能在业务系统设计中少踩一半的坑。这篇文章会从声明式语言的底层逻辑讲起对比传统 Java 代码组织规则的问题再用一个“最小优惠规则系统”演示声明式规则的落地方式最后给出验证、排查和工程化建议。就算你最终不用 Lemma这套思路也值得放进自己的工具箱。1. 这篇文章真正要解决的问题业务规则这个词听起来很好理解但一旦落到工程里它就是无数后端团队的隐形噩梦。一个电商订单系统刚上线时if (vip total 100) { discount 0.9; }写在 Service 里完全没问题。半年之后规则变成“会员满 100 打 9 折但生鲜类商品不打折新用户首单再减 20但和会员折扣不能叠加大促期间全场 8 折但最终优惠金额不能超过 50 元”。这时候你有没有想过几个问题这条“规则”到底在代码里被哪几个方法重复实现过多个规则都命中时谁先执行、谁覆盖谁产品经理拿到手的是“代码逻辑”还是他们能看懂的业务描述哪条规则在什么时间被谁改过有没有审计记录规则和普通业务代码有一个本质区别普通业务代码描述“系统如何操作”它面向机器规则描述“系统应该如何决策”它最终要服务于业务语义。规则天然需要频繁变化、需要被非技术背景的人确认、需要和别的规则发生叠加关系。当规则被写进函数体里它只能被程序员阅读无法被业务方确认也无法被审计更无法回溯“这条规则在上个月是否存在过”。所以这篇文章要解决的问题很明确如何用声明式语言重新组织业务规则让规则变得可读、可验证、可组合、可控变更。与此同时我们也会说实话——声明式方案不是银弹它把复杂度从“代码阅读”转移到了“引擎实现、冲突检测、解释器”上这种复杂度值得了解。适合读这篇文章的读者包括后端开发正在被一堆 if-else 和状态机折磨架构师正在评估要不要引入规则引擎技术负责人希望把“规则变更”从发版流程中解放出来对 DSL 设计和业务建模感兴趣的开发者。2. 声明式语言与业务规则的底层逻辑2.1 什么是声明式语言编程语言大体可以分成两类命令式和声明式。命令式语言描述“怎么做”强调执行步骤声明式语言描述“要什么”强调最终期望的状态。最典型的声明式语言是 SQLSELECT user_id, order_amount FROM orders WHERE status paid你不需要关心数据库是走索引还是全表扫描也不需要考虑先过滤哪张表。你只声明“我要已支付的订单”执行引擎负责把结果算出来。HTML 也是一种声明式语言你声明页面上应该有标题、段落、按钮浏览器负责把它渲染出来。2.2 为什么业务规则特别适合声明式表达一条业务规则通常可以拆成两个部分条件condition和动作action。条件描述“什么情况下”动作描述“做什么”。这种结构天然就是声明式的。举个例子当用户是会员并且订单实付金额不小于 100 元时折扣率设为 0.9。这句话用命令式代码写出来可能会有临时变量、分支嵌套、return 语句但用声明式的规则结构写出来就是when ... then ...条件是什么、结论是什么一眼就能看懂。对比一下两种方式对比维度命令式代码声明式规则表达重点怎么做执行步骤要什么条件与结论阅读主体程序员程序员 业务方修改成本改代码、跑测试、发版改规则文件、走审核、动态加载复用方式方法调用、继承、接口规则组合、优先级、条件引用审计追溯依赖 Git 历史和代码注释规则本身携带 ID、版本、描述一个容易误解的点是声明式语言不是“不用写代码”而是把一部分执行细节交给了引擎。你不需要在规则文件里写for循环、if判断的边界和return引擎会帮你做条件匹配。换句话说你的关注点从“机器怎么执行”变成了“规则到底是什么”。2.3 这类语言属于 DSLLemma 这类“面向业务规则的声明式语言”本质上是 DSLDomain-Specific Language领域特定语言。DSL 的核心价值不是炫技而是让领域概念成为语法本身。业务规则 DSL 的直接价值是业务方看到规则文件能点头说“对这就是我们想要的意思”而不是看到代码之后疑惑地问“这段逻辑是不是错了”。但也要有心理预期声明式规则语言不会消灭复杂性。它把“阅读代码的复杂度”转移到了“引擎执行的复杂度”。如果引擎不支持冲突检测、优先级排序、动态加载那规则文件多了一样会变成一锅粥。所以评价一个声明式规则语言不能只看语法好不好看还要看它的执行模型、组合机制和工程配套。3. Lemma 的“引理”定位规则与规则的证明关系这里说回到 Lemma 这个名字。数学里的引理是指“已经被证明为真的辅助命题”。一个引理单独成立但更重要是它可以被其他定理反复引用。比如在动力系统稳定性分析中离散形式的 Gronwall 引理用来刻画误差在迭代过程中的增长上界。这类引理有一个共同点证明一次反复引用组合成大定理。把这种心智模型迁移到业务规则上你会得到一个新的视角一条业务规则不应该只是一个孤立的 if 分支它应该是可以被“证明”测试、被复用组合、被检查冲突检测的基础结论。比如“会员满 100 打 9 折”这条规则会被“新老客户优惠叠加”这一条规则引用也会被“全场促销上限 50 元”规则约束。规则之间不是一维的先后顺序而是一张互相支撑、互相约束的网络。用数学的引理来类比非常贴切。从项目定位来看Lemma 想做的正是把业务规则变成这种“声明式的、可组合的引理”。它把业务规则的组合、验证、复用放在了语言设计的核心位置而不只是提供一个“把 if-else 换成配置文件”的壳子。需要说明的是这里更多是从项目命名和定位推导出的理解Lemma 具体支持哪些语法、哪些校验能力、是不是已经提供优先级和冲突检测请以它的仓库文档和源码为准。但即使不带任何滤镜去看“让业务规则成为领域引理”这个方向也值得技术团队认真参考。4. 企业业务规则实践的四种演进阶段4.1 阶段一散落的 if-else最容易想到的方式是直接在业务代码里写条件判断。// 文件路径src/main/java/com/example/order/OrderService.java public void applyDiscount(Order order) { User user order.getUser(); if (user.isVip() order.getTotalAmount() 100) { if (!fresh.equals(order.getCategory())) { order.setDiscountRate(0.9); } else { order.setDiscountRate(1.0); } } else if (user.isNew()) { order.setDiscountRate(0.95); } }这种写法在规则只有两三条时完全没问题但规则一多问题立刻暴露规则之间没有边界后面的else if很容易覆盖前面的判断业务方看不到实际生效的逻辑新同事改规则时只能靠猜没有规则 ID线上出了问题也没法定位是哪一条规则导致的。4.2 阶段二策略模式与规则类为了治理混乱团队通常会把规则抽象成策略类。public interface DiscountRule { boolean match(Order order); double apply(Order order); }然后为每一条规则写实现类再通过一个编排类按顺序执行。这个方式的优点是结构清晰缺点是规则仍然是“命令式代码”规则的先后优先级仍然靠ListDiscountRule的插入顺序控制。一旦规则数量到几十条这个优先级列表本身就非常隐蔽改错一个位置就是线上事故。4.3 阶段三重量级规则引擎再进一步团队可能会引入规则引擎把规则拆成“条件 动作”交给引擎执行。这类引擎功能强大但学习成本不低调试和性能调优也比较考验团队能力。很多团队用上规则引擎之后发现最花时间的不是写规则而是排查“引擎为什么没有按预期执行”。4.4 阶段四声明式业务规则 DSL第四种演进方向就是把规则本身当作一种可声明的数据资源。我们先用一个通用的规则文件示意// 文件路径rule-demo/rules.json { rules: [ { ruleId: R1001, name: vip_order_discount, priority: 10, when: { all: [ user.isVip true, order.totalAmount 100 ] }, then: { set: order.discountRate 0.9 } }, { ruleId: R1002, name: new_user_default_discount, priority: 20, when: { all: [ user.isNew true ] }, then: { set: order.discountRate 0.95 } } ] }和 Java 代码不同的是这里的条件完全是显式声明没有分支嵌套、没有方法调用顺序。谁先执行、谁后执行由priority字段控制。规则变成了一等公民可以被质检工具检查、被版本控制系统管理、被业务方审阅。这四种演进不是严格替代关系。一个复杂的系统里大概率是几个阶段并存。但“规则本身成为可管理的资产”这一理念是声明式 DSL 真正想解决的问题。5. 用声明式语言设计一个最小优惠规则系统5.1 设计目标与环境准备为了让上面的思路落地我设计了一个最小系统目标只有一个读取rules.json中的声明式规则传入订单事实输出决策结果。它不依赖任何第三方库用到 Python 标准库里的json和sys。环境要求Python 3.9 及以上Windows/macOS/Linux 均可不需要安装额外依赖如果要在 Java 服务里做同类实现思路完全一样规则文件用 JSON 声明引擎负责加载和匹配。需要强调下面这段是通用演示不是 Lemma 的官方用法。Lemma 的安装和环境要求以项目官方 README 为准本文演示的是声明式规则系统的核心思路。5.2 目录结构rule-demo/ ├── rules.json # 声明式规则文件 ├── engine.py # 规则引擎实现 ├── main.py # 运行入口 └── test_rules.py # 规则验证用例5.3 规则文件我已经在上面给出了rules.json里面定义了三条规则。这里再补充说明几点ruleId是规则的全局唯一标识务必保证稳定方便后续做日志和审计priority数值越小优先级越高引擎会按这个值排序when里的all表示多个条件必须同时满足then的set表示命中后给事实对象设置某个字段。规则文件本身就是给业务方看的“文档”这是声明式 DSL 的主要收益之一。5.4 引擎实现新建engine.py# 文件路径rule-demo/engine.py import json def _attr(fact, path): 从嵌套字典中按点路径取值例如 user.isVip - fact[user][isVip] cur fact for part in path.split(.): if not isinstance(cur, dict) or part not in cur: return None cur cur[part] return cur def _coerce(value): 把字符串形式的右值转换为布尔、浮点数或字符串 text value.strip() if text true: return True if text false: return False try: return float(text) except ValueError: return text.strip() def _match_condition(expr, fact): 匹配单条条件表达式支持 和 两种常见比较 for op in (, ): if op in expr: left, right expr.split(op, 1) left_val _attr(fact, left.strip()) right_val _coerce(right) if op : return left_val is not None and left_val right_val return left_val right_val return False class RuleEngine: def __init__(self): self.rules [] def load(self, path): with open(path, r, encodingutf-8) as f: data json.load(f) self.rules sorted(data[rules], keylambda r: r.get(priority, 100)) self._validate() print(f已加载 {len(self.rules)} 条规则) def _validate(self): ids [r[ruleId] for r in self.rules] if len(ids) ! len(set(ids)): raise ValueError(规则 ID 存在重复请检查规则文件) def decide(self, fact): decision {} for rule in self.rules: when rule.get(when, {}) matched all( _match_condition(expr, fact) for expr in when.get(all, []) ) if matched: then rule.get(then, {}) if set in then: field, value then[set].split() field field.strip() decision[field] _coerce(value) print( f[{rule[ruleId]}] {rule[name]} 命中 f设置 {field} {decision[field]} ) return decision这段代码的核心逻辑_attr用点路径从嵌套事实里取值实现user.isVip这类表达式_coerce把规则文件里的true、0.9等字符串转换为 Python 类型load读取 JSON 后按priority排序同时做规则 ID 去重校验decide遍历规则命中后执行then的赋值动作。5.5 运行入口新建main.py# 文件路径rule-demo/main.py from engine import RuleEngine def main(): engine RuleEngine() engine.load(rules.json) fact { user: {isVip: True, isNew: False}, order: {totalAmount: 200, discountRate: 1.0} } result engine.decide(fact) print(决策结果:, result) if __name__ __main__: main()运行cd rule-demo python main.py预期输出已加载 2 条规则 [R1001] vip_order_discount 命中设置 order.discountRate 0.9 决策结果: {order.discountRate: 0.9}如果用户是isNew: True且不是 VIP那么R1001不满足user.isVip true会走R1002的默认新客折扣输出0.95。这个例子虽然简单但已经具备声明式规则的三个关键特征条件与动作分离、规则可组合、优先级显式化。5.6 更复杂场景的扩展思路真实项目里then不只有set一个动作还可能有append_log记录决策日志call_service触发外部审批流halt命中后停止后续规则。condition支持not、any组合。这些扩展会增加引擎的复杂度但规则文件的表达方式仍然保持在声明层面业务方依然可以读懂“在什么条件下做什么”。6. 如何验证规则正确性而不是只验证结果规则系统最怕的不是“报错”而是“静默地不生效”。Java 业务代码跑错了会抛异常规则没命中可能只是没有日志然后线上就出现了一笔诡异的订单。所以验证规则系统需要比验证普通方法更高一层的视角。6.1 三个层次的验证思路第一层单条规则的单元测试。给定一个事实输入断言决策输出。这一层主要验证规则本身写对了。第二层规则组合测试。构造同时命中多条规则的事实验证优先级、覆盖关系是否符合预期。第三层回归回放。用历史生产数据在测试环境运行新规则版本对比决策结果分布检查有没有出现异常比例的变化。6.2 用 pytest 写一个最小验证用例针对上面的引擎在test_rules.py中写# 文件路径rule-demo/test_rules.py from engine import RuleEngine def build_engine(): engine RuleEngine() engine.load(rules.json) return engine def test_vip_over_100_should_discount(): engine build_engine() fact { user: {isVip: True, isNew: False}, order: {totalAmount: 200, discountRate: 1.0} } result engine.decide(fact) assert result[order.discountRate] 0.9 def test_vip_under_100_should_not_discount(): engine build_engine() fact { user: {isVip: True, isNew: False}, order: {totalAmount: 50, discountRate: 1.0} } result engine.decide(fact) assert order.discountRate not in result运行测试pip install pytest python -m pytest test_rules.py -v预期输出里会看到两个测试全部通过。如果某个用例失败第一步不是检查断言而是打开决策日志看规则是否命中、命中的是哪一条。规则系统的调试核心是“可观测性”不是“调试器”。这个测试示例虽然小但背后的原则很重要规则必须被当作代码一样对待纳入 CI规则变更必须配套测试变更。只有测试先行声明式规则才能真正成为可迭代的领域资产。7. 常见问题与排查思路使用声明式业务规则语言后团队遇到的问题会从“代码结构混乱”转变成“规则引擎为什么没按预期执行”。下面是一些典型问题问题现象可能原因排查方式解决方案规则没有生效条件表达式取值路径写错打开决策日志检查条件字段是否从事实中取到值统一事实模型的字段命名增加字段路径校验两条规则都命中结果不符合预期优先级排序不明确查看规则加载后的排序结果确认priority值显式声明优先级避免依赖文件顺序规则文件里 ID 重复多人协作时复制粘贴导致增加启动时规则 ID 去重校验在引擎load阶段抛异常并中断启动改动规则后老功能挂了规则覆盖了历史场景用历史样本数据跑回归回放把规则变更纳入 CI配套回归测试线上出现大量非预期折扣规则条件过宽误命中查看决策日志中的命中规则和字段值收紧条件增加字段枚举校验规则文件包含非法表达式语法错误查看引擎启动时的解析异常接入规则语法检查工具提交前做校验一个值得养成的习惯是每次规则变更都写清楚“变更前行为”和“变更后行为”并且附上对应的测试用例。很多线上规则事故都是因为没有这一条记录。8. 最佳实践与工程建议8.1 把规则文件当作代码管理规则文件不要直接放在运维服务器上随意修改要放进 Git走和代码一样的评审流程。每条规则都要有版本记录规则变更要有对应的 Commit 说明。这样才能回答“这条规则是谁在什么时候改的为什么改”。8.2 规则 ID、命名与描述规范规则 ID 一旦发布就不要修改建议命名语义化例如DISCOUNT_VIP_OVER_100同时写清楚业务描述。规则文件本身就是领域文档好的命名比注释更有效。8.3 规则保持原子化一条规则只做一件事组合逻辑交给优先级和条件编排。不要写一条几百行的超级规则否则又回到了命令式代码的坑里。优先采用“条件 动作”的最小结构复杂的组合用规则间的优先级和冲突检测解决。8.4 让决策可观测规则引擎的决策过程一定要有日志哪些规则被评估、哪些命中、命中的条件字段值是什么、最终输出了什么。这不仅是排查问题的依据也是合规审计的关键。生产环境建议为每次请求生成决策 trace ID把决策日志和业务日志关联起来。8.5 安全边界与最小权限如果规则可以动态热加载就需要严格权限控制。生产环境的规则编辑应该只开放给授权人员所有变更必须走审批流程并且具备回滚能力。任何情况下都不要给普通操作人员开放任意规则编辑权限。涉及数据库写入、外部服务调用的规则动作要格外谨慎规则引擎从来不应该是绕过安全评审的通道。8.6 性能与灰度规则数量增多后注意评估引擎的匹配性能。可以通过规则索引、条件预过滤、决策结果缓存来优化。规则上线前先灰度观察命中分布和业务指标再逐步放量。尤其是涉及优惠、风控、定价的高敏感场景灰度不是可选项而是必选项。8.7 管住抽象欲望不是所有业务逻辑都适合做成声明式规则。适合的是“变化频繁、和产品语义强相关、需要被业务方审阅”的决策逻辑例如促销、折扣、风控阈值、审核规则。不适合的是底层算法、事务、数据一致性等强流程逻辑。过度抽象会把简单的系统变成一座需要“语言专家”才能维护的巴别塔。9. 总结与后续学习方向回到开头那个场景产品经理说“本次只改一条规则”而你需要发版、灰度、回滚演练那是因为规则被写死在了代码里。Lemma 所代表的方向是用声明式语言把业务规则变成显式的领域资产让规则能审阅、能测试、能组合、能审计。这比“又造了一个语言”更重要。如果你打算继续深入建议按下面的路径实践第一步找一个自己项目里的高频规则场景例如优惠计算、风控检查、工单流转尝试用 JSON 或 YAML 写出 5 到 10 条声明式规则。第二步仿照本文的最小引擎实现条件匹配、优先级排序、日志输出跑通一个端到端示例。第三步研究成熟的规则引擎和 DSL 设计例如 Drools、决策表、事件规则对比它们各自解决什么问题、引入什么成本。第四步在团队内部做一个小的技术验证重点比较改造前后“规则变更周期”“线上问题定位时长”“业务方参与度”三个指标。声明式业务规则不是灵丹妙药但它提供了一个很有价值的思维转换规则是资产不是代码的附属品。希望这篇文章能帮你把这个视角带进自己的项目里。建议收藏备用下次被复杂规则折磨时可以翻出来对照着做一次梳理。

最新新闻

日新闻

周新闻

月新闻