声明式规则语言 Lemma 实战:让风控规则不再写满 if-else

发布时间:2026/8/29 11:47:52
声明式规则语言 Lemma 实战:让风控规则不再写满 if-else 之前在业务系统里处理风控、优惠、运费这类逻辑时最常见的问题不是功能做不出来而是规则一变后端就要跟着加班。一个金额阈值从 10000 改成 8000需要改代码、走测试、重新发版规则多了以后Service 层全是if-else业务人员看不懂新接手的人不敢动。后来接触到声明式业务规则语言 Lemma思路一下子清晰了很多把“规则是什么”单独描述出来让引擎去匹配和执行。本文会围绕 Lemma 这类声明式规则语言梳理核心概念、规则语法、运行环境、完整实战案例和工程落地中的高频问题适合后端开发、规则引擎入门者以及正在为“业务规则频繁变更”头疼的同学。1. 为什么业务规则需要一个“专用语言”1.1 业务逻辑散落在代码里的痛点先看一个很常见的场景订单风控系统里判断订单是否进入人工审核代码可能是这样的if (order.getAmount() 10000 NORMAL.equals(order.getUserLevel())) { order.setRiskLevel(HIGH); order.setManualReview(true); } if (order.getAmount() 5000 NORMAL.equals(order.getUserLevel()) order.getHistoryOrderCount() 10) { order.setRiskLevel(MEDIUM); }单看这段代码逻辑不算复杂。问题是一个真实的业务系统里不会只有两三条例外规则。优惠券规则满减、折扣、叠加、排除。运费规则地区、重量、会员等级、活动期间免运费。风控规则金额、频次、设备指纹、历史订单。审核规则金额区间、用户类型、资料完整度。当这些规则全部用if-else写在 Java 方法里时至少会有几个问题可读性差每个条件的含义需要从代码中反推。变更成本高业务改一个阈值就要走一轮代码发布。业务人员无法参与产品、运营提出“金额大于 10000 的普通用户要审核”但无法直接验证规则是否正确。规则之间容易互相覆盖多个if同时命中时后写的逻辑可能悄悄覆盖先写的逻辑排查起来非常麻烦。而声明式规则语言正是为了解决这类问题出现的。1.2 声明式语言和命令式代码的差异命令式代码强调的是“怎么一步步做”。比如“先取出订单金额再判断是否大于 10000如果满足并且用户等级是普通那么设置风险等级为高”。声明式规则语言强调的是“规则本身是什么”。同样一条规则用声明式写法大致是这样的when order.amount 10000 and order.userLevel NORMAL then order.riskLevel HIGH emit(manualReview, true) end这里没有循环、没有赋值语句的顺序控制只有“条件和结果”。具体怎么匹配、怎么执行由规则引擎去决定。声明式语言有一个很典型的例子SQL。你写SELECT * FROM user WHERE age 18不需要关心数据库到底走索引还是全表扫描数据库会通过执行计划来完成。业务规则语言也是同理你描述清楚“什么条件下做什么事”执行引擎负责把规则跑起来。1.3 Lemma 的定位业务规则语言与执行引擎Lemma 是近期在程序员社区受到关注的一个声明式业务规则语言项目。它和 Drools、Easy Rules 这类规则引擎解决的是同一类问题但更强调“语言”本身的设计规则文件可读、可评审、可独立维护尽量减少宿主代码里的规则逻辑。要注意的是声明式规则语言通常要配套一个执行引擎。语言负责表达引擎负责解析、匹配和执行。在实际项目中你可以选择成熟的规则引擎也可以像后面章节那样实现一个简单但完整的规则解析执行 Demo来理解底层原理。这里也顺便说明一点检索 Lemma 时经常看到“离散的 Gronwall lemma”那是数学分析中 Gronwall 不等式的离散形式和本文介绍的业务规则语言没有任何关系只是名字相近不要混淆。2. 声明式业务规则的核心概念2.1 事实Facts是规则的输入在规则引擎中输入数据通常被叫做“事实”。比如订单对象、用户对象、商品对象都可以作为事实传入规则引擎。规则语言中引用字段时通常会写成“对象名.字段名”的形式order.amount 10000 order.userLevel NORMAL user.age 18这种写法比 Java 反射式的getAmount()更直观业务人员也更容易理解。事实对象可以是 Java Bean、Map也可以是其他语言中的结构体具体取决于引擎实现。2.2 规则是“条件 动作”一条规则的核心结构是条件Condition一个布尔表达式只有为真时规则才会被触发。动作Action规则触发后要执行的效果比如修改事实状态、产生一条告警、输出一个结果。用伪代码表示rule 规则名称 when 条件表达式 then 动作 end条件可以由多个子条件通过and、or、not组合而来。动作既可以是简单赋值也可以是调用外部服务、发送消息等具体能力取决于引擎暴露的支持范围。2.3 规则引擎负责匹配与调度有了规则和事实接下来就是引擎的工作把事实和所有规则做匹配选出命中的规则然后按照优先级执行。设计比较良好的规则引擎会考虑几个问题匹配效率规则很多时如何快速找到命中的规则。冲突消解多条规则同时命中时先执行哪一条。动作副作用规则执行后事实状态变化是否影响后续规则。像 Drools 使用的 Rete 算法就是为了提升规则匹配效率而设计的。初学者不需要一开始就啃 Rete但需要理解声明式规则语言解决的是“可读性和可维护性”而引擎解决的是“执行效率和确定性”。2.4 规则语言与流程引擎的边界规则语言适合描述“策略判断”比如这个订单属于高风险还是中风险这个用户能不能领取优惠券这个请求能不能通过规则语言不适合描述“长流程编排”比如先创建订单再扣库存再发短信超时后自动取消。这种状态流转更适合交给工作流引擎例如 Flowable、Camunda。业务规则是流程中的一个个判断节点而流程引擎负责把节点串起来。实际系统里两者经常配合使用但不要用规则语言去硬写整个业务生命周期。3. 环境准备与示例项目结构3.1 运行环境要求由于 Lemma 这类声明式规则语言在不同仓库、不同版本中的 API 差异可能较大本文不绑定某个具体的发布版本重点演示规则语言思想与工程落地姿势。实际引入时以你选定的版本和官方文档为准。本文示例基于以下环境操作系统Windows / Linux / macOS 均可JDK11 或更高版本构建工具Maven 3.6 或 Gradle 7IDEIntelliJ IDEA 或 Eclipse规则文件独立文本文件后缀使用.lemma如果你暂时不想搭建 Java 项目只想感受声明式规则的写法直接用文本编辑器创建规则文件也能完成前半部分的阅读。3.2 示例项目目录结构建议把规则文件与代码分开管理目录结构可以参考下面这样order-risk-demo ├── pom.xml └── src ├── main │ ├── java │ │ └── com │ │ └── example │ │ └── rule │ │ └── SimpleRuleEngine.java │ └── resources │ └── rules │ ├── order-high-risk.lemma │ └── order-medium-risk.lemma └── test └── java规则文件放在resources/rules目录下可以避免规则与 Java 代码混在一起发布时也方便按目录进行权限管理。3.3 规则文件放哪里如果是单机项目规则文件放在resources目录即可。如果是微服务架构更推荐把规则文件内容放到配置中心比如 Apollo、Nacos让规则变更可以动态生效。规则文本本质上是一种配置遵循“配置和代码分离”的原则会带来更大的灵活性。4. 一条业务规则是怎么写成声明的4.1 规则文件的基本结构下面是一个完整的规则文件示例rule order.high.risk when order.amount 10000 and order.userLevel NORMAL then order.riskLevel HIGH emit(manualReview, true) end这里需要说明Lemma 作为一个独立的声明式规则语言项目具体语法以官方文档为准。上面这种写法是声明式规则语言中很常见的一种风格用于帮助你建立规则文件结构的心智模型。规则文件的组成rule关键字声明一条规则后面引号中写规则名称。名称建议全局唯一便于日志追踪和排查。when关键字后面写触发条件。then关键字后面写规则动作。end关键字表示规则结束。4.2 条件表达式的写法条件表达式中最常见的写法是比较order.amount 10000 order.userLevel NORMAL order.historyOrderCount 10多个子条件可以用逻辑关键字组合order.amount 5000 and order.userLevel NORMAL and order.historyOrderCount 10也可以使用 ororder.paymentMethod COD or order.amount 50支持的操作符一般包括、!、、、、以及in、contains这类集合判断。具体到 Lemma 项目需要看它的语法文档。写作条件表达式时有一个建议不要把条件写得过于复杂。如果一条规则的when块超过 10 行通常说明这条规则承担了太多职责可以考虑拆成多条规则。4.3 then 动作写什么动作是规则命中后的副作用。在声明式规则语言中常见的动作类型有修改事实对象的字段如order.riskLevel HIGH。产生一个输出结果如emit(manualReview, true)。调用外部服务如notify(risk-center)但这取决于引擎是否开放了这种能力。动作设计有一条核心原则规则不要直接修改数据库不要直接调用外部接口除非规则引擎明确支持这类副作用操作。更推荐的做法是规则只负责计算出“结论”例如风险等级、命中标记、结果列表然后由外层 Java 代码根据结论去执行真正有副作用的行为。4.4 规则优先级与执行顺序当多条规则同时命中时需要决定执行顺序。常见做法是给规则设置一个priority或salience属性rule order.high.risk priority 1 when ... end数字越小优先级越高或者数字越大优先级越高取决于引擎设计。本文示例采用“数字越小优先级越高”的约定。为什么要关注优先级因为规则之间往往会互相影响。比如高风险规则先执行把riskLevel设置为HIGH那么后续中风险规则即使命中也不应该把风险等级覆盖回MEDIUM。这种“先命中先生效”的行为需要通过优先级和动作逻辑共同约束。5. 完整实战订单风控规则落地5.1 场景与需求假设我们有一个订单风控场景需要根据订单金额、用户等级和历史订单次数来判断风险等级。业务需求订单金额大于 10000且用户等级为普通NORMAL属于高风险需要人工审核。订单金额大于 5000且用户等级为普通且历史订单次数不超过 10属于中风险。高风险优先于中风险如果已经命中高风险不再设置中风险。这个场景非常适合用声明式规则来表达。5.2 定义订单数据模型先用 Java 定义一个简单的Order类作为规则引擎中的事实对象。// 文件路径src/main/java/com/example/rule/SimpleRuleEngine.java // Order 类定义在 SimpleRuleEngine 内部作为演示用数据结构 static class Order { private String orderId; private double amount; private String userLevel; private int historyOrderCount; private String riskLevel; private boolean manualReview; public Order(String orderId, double amount, String userLevel, int historyOrderCount) { this.orderId orderId; this.amount amount; this.userLevel userLevel; this.historyOrderCount historyOrderCount; } public String getRiskLevel() { return riskLevel; } public void setRiskLevel(String riskLevel) { this.riskLevel riskLevel; } public boolean isManualReview() { return manualReview; } public void setManualReview(boolean manualReview) { this.manualReview manualReview; } }这里只是为了演示把Order作为内部类写在引擎里。实际项目中Order应该是独立的实体类放在model或entity包下。5.3 编写规则文件创建两条规则文件。文件src/main/resources/rules/order-high-risk.lemmarule order.high.risk when order.amount 10000 and order.userLevel NORMAL then order.riskLevel HIGH emit(manualReview, true) end文件src/main/resources/rules/order-medium-risk.lemmarule order.medium.risk when order.amount 5000 and order.userLevel NORMAL and order.historyOrderCount 10 then order.riskLevel MEDIUM end两条规则都放在resources/rules目录下。这样做的意义在于将来如果阈值变化只需要修改规则文件而不用改 Java 代码。5.4 用 Java 加载并执行规则接下来编写一个极简规则引擎演示从规则文件读取条件、执行条件匹配、输出结果的整体过程。// 文件路径src/main/java/com/example/rule/SimpleRuleEngine.java import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; public class SimpleRuleEngine { public static void main(String[] args) throws Exception { // 1. 读取规则文件 String highRule Files.readString(Paths.get(src/main/resources/rules/order-high-risk.lemma)); String mediumRule Files.readString(Paths.get(src/main/resources/rules/order-medium-risk.lemma)); // 2. 构造规则对象数字越小优先级越高 ListRule rules new ArrayList(); rules.add(new Rule(order.high.risk, extractCondition(highRule), 1)); rules.add(new Rule(order.medium.risk, extractCondition(mediumRule), 2)); rules.sort(Comparator.comparingInt(r - r.priority)); // 3. 构造订单事实 Order order new Order(ORD001, 15000, NORMAL, 3); // 4. 逐个执行规则 for (Rule rule : rules) { boolean matched matches(rule.condition, order); System.out.println(规则 rule.name 命中 matched); if (matched) { applyAction(rule.name, order); } } // 5. 输出结果 System.out.println(最终风险等级 order.getRiskLevel()); System.out.println(是否人工审核 order.isManualReview()); } // 从规则文件中提取 when 和 then 之间的条件部分 static String extractCondition(String ruleText) { String body ruleText.split(when)[1].split(then)[0]; return body.replace(\n, ).trim(); } // 模拟规则动作真实引擎中由引擎解析 then 块执行 static void applyAction(String ruleName, Order order) { if (ruleName.contains(high)) { order.setRiskLevel(HIGH); order.setManualReview(true); } else if (ruleName.contains(medium)) { if (order.getRiskLevel() null) { order.setRiskLevel(MEDIUM); } } } // 判断一条条件表达式是否匹配 public static boolean matches(String condition, Object fact) throws Exception { if (condition null || condition.trim().isEmpty()) { return true; } String[] clauses condition.split( and ); for (String clause : clauses) { String expr clause.trim(); if (!matchSingle(expr, fact)) { return false; } } return true; } private static boolean matchSingle(String expr, Object fact) throws Exception { Pattern pattern Pattern.compile(^(\\w\\.\\w)\\s*(|!||||)\\s*(.)$); Matcher matcher pattern.matcher(expr); if (!matcher.matches()) { throw new IllegalArgumentException(不支持的条件表达式: expr); } String fieldPath matcher.group(1); String op matcher.group(2); String expected matcher.group(3).trim(); Object actual getFieldValue(fact, fieldPath); int cmp compareToExpected(actual, expected); switch (op) { case : return cmp 0; case !: return cmp ! 0; case : return cmp 0; case : return cmp 0; case : return cmp 0; case : return cmp 0; default: throw new IllegalArgumentException(不支持的操作符: op); } } // 通过反射获取字段值条件中写的是 order.amount从第二段开始解析字段 private static Object getFieldValue(Object fact, String fieldPath) throws Exception { Object current fact; String[] names fieldPath.split(\\.); for (int i 1; i names.length; i) { Field field current.getClass().getDeclaredField(names[i]); field.setAccessible(true); current field.get(current); } return current; } // 比较实际值和期望值 private static int compareToExpected(Object actual, String expected) { if (actual instanceof Number) { double left ((Number) actual).doubleValue(); double right Double.parseDouble(expected); return Double.compare(left, right); } String left String.valueOf(actual null ? : actual); String right expected.replace(\, ); return left.compareTo(right); } static class Rule { String name; String condition; int priority; Rule(String name, String condition, int priority) { this.name name; this.condition condition; this.priority priority; } } static class Order { private String orderId; private double amount; private String userLevel; private int historyOrderCount; private String riskLevel; private boolean manualReview; public Order(String orderId, double amount, String userLevel, int historyOrderCount) { this.orderId orderId; this.amount amount; this.userLevel userLevel; this.historyOrderCount historyOrderCount; } public String getRiskLevel() { return riskLevel; } public void setRiskLevel(String riskLevel) { this.riskLevel riskLevel; } public boolean isManualReview() { return manualReview; } public void setManualReview(boolean manualReview) { this.manualReview manualReview; } } }5.5 运行结果说明使用上面的规则文件和订单数据运行后预期输出如下规则 order.high.risk 命中true 规则 order.medium.risk 命中true 最终风险等级HIGH 是否人工审核true分析一下执行过程订单金额为 15000大于 10000且用户等级为 NORMAL所以高风险规则命中设置风险等级为 HIGH并标记需要人工审核。中风险规则也命中因为 15000 大于 5000NORMAL且历史订单次数 3 小于等于 10。在applyAction中由于riskLevel已经是 HIGH中风险规则不会覆盖风险等级。如果把订单金额改为 6000历史订单次数仍为 3那么高风险规则不命中中风险规则命中最终风险等级为 MEDIUM。你可以实际运行修改参数验证一下。这里需要强调示例中的SimpleRuleEngine只是一个极简演示核心目的是帮你理解规则文件如何被加载、条件如何被解析、动作如何被触发。真实规则引擎的处理要复杂得多包括条件索引、更丰富的操作符、嵌套对象、集合判断等。后面工程化落地时优先选用成熟的规则引擎。6. 常见问题与排查思路6.1 规则不触发这是最常见的现象规则文件写好了数据也传入了但规则就是没有执行。问题现象常见原因解决思路规则不触发条件字段名拼写错误检查规则中的字段名与事实对象字段是否一致规则不触发类型比较不一致数字字段不要加引号字符串比较要加引号规则不触发规则文件没有被加载确认规则文件路径和加载逻辑是否正确规则不触发条件之间的 and/or 逻辑写错拆分为多个单条件规则逐步验证排查时建议先输出规则文件中提取出来的条件字符串用肉眼核对一遍。还可以在规则动作里加日志确认规则是否进入执行阶段。6.2 规则误触发规则误触发通常意味着条件写得太宽松。比如原本想判断“用户等级为 NORMAL”但代码里写成了order.userLevel ! BLACK这种写法会把大量非黑名单用户都包含进来导致误判。解决思路尝试用“正向条件”代替“反向条件”。例如明确写出order.userLevel NORMAL而不是排除某个值。正向条件可读性更好也更容易写测试用例。6.3 规则执行顺序不符合预期多条规则同时命中时如果没有明确的优先级执行顺序可能取决于规则加载顺序导致结果不确定。解决思路为每条规则显式设置priority。在规则动作中做“是否已赋值”的判断避免后执行的低优先级规则覆盖前序结果。保持规则之间尽量独立不要依赖执行顺序。6.4 中文乱码与编码问题规则文件如果包含中文注释或中文字符串可能因为文件编码不一致导致乱码。问题现象常见原因解决思路规则文件中文显示乱码文件编码不是 UTF-8统一使用 UTF-8 保存文件中文字符串比较失败编码不一致导致字符串不等检查 IDE 和运行环境的默认编码建议所有规则文件统一使用 UTF-8 编码并在构建工具中显式设置文件编码。6.5 规则多了之后性能下降当规则数量达到上百条、上千条时逐个判断所有规则可能导致性能问题。解决思路对规则做分组先通过粗粒度条件过滤掉明显不相关的规则。使用成熟的规则引擎利用 Rete 算法提高匹配效率。避免在规则条件中执行耗时操作如远程调用、数据库查询。给关键业务设置规则执行超时和熔断机制。6.6 规则版本回滚困难规则上线后如果发现问题需要快速回滚。解决思路规则文件要纳入版本管理保留历史版本。在配置中心发布规则时保存上一次可用的版本支持一键回滚。给规则增加版本号字段日志中记录规则版本便于定位问题。7. 最佳实践与工程建议7.1 规则命名与文件组织规则名称建议采用“领域.对象.行为”的格式例如order.amount.high-risk order.user.level-check promotion.coupon.deny这样做的好处是日志里看到规则名就能知道它属于哪个业务域、针对哪个对象、做了什么判断。文件组织上可以按业务域分目录rules ├── order │ ├── order-amount.lemma │ └── order-user.lemma ├── promotion │ └── coupon.lemma └── risk └── manual-review.lemma7.2 规则测试要像代码一样严格很多人觉得规则文件