从 if-else 到声明式规则引擎:手写一个 Lemma 风格 DSL
在公司里做业务需求的时候,不知道你有没有遇到过这种场景:产品经理提了一条规则,例如“新用户首单满 100 减 20,但只能使用一次”,开发同学在 Service 层写了一大段 if-else;过了一周规则改成“满 150 减 30,老用户也能参与”,于是又开始改代码、发版、回归测试。规则稍微复杂一点,逻辑分支就像滚雪球一样膨胀,最后没人敢动那一段代码。
这类问题的本质是:业务规则被硬编码在了命令式语言里。程序语言擅长描述“怎么算”,但业务人员真正关心的是“算什么”和“什么时候生效”。当规则频繁变化时,我们希望规则本身可以独立于主流程存在,用一种更接近自然语言的、声明式的方式去描述,然后由引擎去解释和执行。这也就是Lemma这类声明式业务规则语言(declarative language for business rules)出现的理由。
本文会从概念入手,讲清楚 Lemma 和传统规则引擎的差异,然后带大家亲手实现一个最小可运行的声明式规则语言 Demo。不需要多高深的理论,跟着步骤走,你也能在自己的项目里落地一套轻量的规则语言。
1. 背景与核心概念
1.1 什么是 Lemma
简单说,Lemma 是一门面向业务规则的声明式语言。这里的“声明式”指的是:你只需要描述规则是什么,而不用关心规则引擎底层是怎么遍历、怎么匹配、怎么执行的。
举个例子,传统命令式代码是这样的:
if (order.getAmount() >= 100 && user.isNew()) { order.setDiscount(20); }这段代码没有错,但它把规则和业务逻辑混在了一起。如果规则数量上升到几十条、上百条,整个方法会变得难以阅读和维护。
用声明式规则语言表达同样的逻辑,大致是这样的风格:
rule "新用户首单满减" when user.new == true order.amount >= 100 then order.discount = 20 end你一眼就能看出这条规则在说什么:新用户、订单满 100,减 20。规则本身就是业务人员可以读懂的描述,而不是一堆嵌套的分支判断。
Lemma 的定位就是提供这样一套标准化的规则表达方式,同时内置执行引擎,让规则可以被加载、解析、匹配并执行。它把“业务规则的编写”和“业务规则的执行”解耦,从而提升业务迭代速度。
1.2 它解决什么问题
先看几个典型的业务痛点:
第一个痛点:规则散落在代码里
同一个业务规则可能分布在 Controller、Service、甚至 SQL 查询里。规则一旦变化,就需要同时修改多处代码,很容易漏改。
第二个痛点:规则变化频繁
营销活动、风控策略、审批流程,这些业务场景的规则变化非常快。如果每次都走需求评审、开发、测试、发版的完整流程,业务效率会非常低。有了声明式规则语言,规则可以配置化,甚至允许业务人员直接维护。
第三个痛点:规则之间互相影响
现实中的规则很少是孤立的。例如“新人优惠”和“满减活动”可能同时生效,也可能互相排斥。用 if-else 表达这类约束,需要非常小心地控制优先级和流程分支,而声明式规则引擎通常内置了冲突处理、优先级排序、规则关联等能力。
1.3 和规则引擎、DSL 的关系
很多人会问:Lemma 和 Drools、Easy Rules 这类规则引擎有什么不同?
从定位上看,规则引擎是一个更宽泛的概念,它负责规则的管理和执行;Lemma 则侧重于“语言”本身的设计。实际上,一个完整的规则系统由两部分组成:
- DSL(领域特定语言):例如 Lemma 语言,用来描述规则。
- 规则引擎:解析 DSL 并执行规则,例如 Drools、Easy Rules,或者你自己实现的轻量引擎。
所以你可以把 Lemma 理解为一种“规则描述语言”,它需要配合执行环境一起使用。也有一些语言试图把两者统一,比如 Prolog 其实就是一种可以运行在逻辑编程环境中的声明式语言。Lemma 的侧重点是面向业务规则,它不像 Prolog 那样强调逻辑推理,而是更关注规则的条件匹配和动作执行。
1.4 为什么需要掌握这种思想
再退一步想,即使你最后没有用 Lemma,掌握声明式规则表达的思想也很有价值。它能帮助你:
- 在设计业务系统时,把易变的规则从稳定代码中分离出来。
- 用更少的代码承载更多的业务变化,减少发版频率。
- 让业务规则变得可读、可审计、可测试。
所以,这篇教程不仅会介绍 Lemma 是什么,更重要的是带读者理解声明式规则语言的设计思路和落地方法。
2. 环境准备与项目结构
为了后面能动手实践,我们先准备好开发环境。本节的示例会采用 Java + Spring Boot 实现一个最小的 Lemma 风格规则引擎,你可以把它理解成一个“玩具版本”,但足够演示声明式规则的核心思想。
2.1 运行环境
本文示例基于以下环境:
- JDK 1.8 或以上版本
- Maven 3.6 或以上版本
- Spring Boot 2.7.x(低版本兼容性更好,高版本同样适用)
- IDE 推荐 IntelliJ IDEA
如果你的环境版本不一样,也不用担心,本文的核心代码不依赖太多第三方库,你可以在任意 Java 环境中运行。
2.2 项目依赖
首先创建一个 Spring Boot 项目,或者直接创建普通的 Maven 项目也可以。为了演示方便,我们引入最少的依赖:
<dependencies> <!-- Web 依赖,方便用接口测试 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 用 fastjson 做 JSON 解析,非必须,实际可按需引入 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency> </dependencies>注意,这里使用 fastjson 只是为了简化 JSON 对象处理,实际项目中你也可以用 Jackson 或 Gson。核心逻辑不依赖具体 JSON 库。
2.3 示例项目结构
我们在项目中计划实现一个非常精简的声明式规则引擎,命名为lemma-demo。目录结构如下:
lemma-demo ├── pom.xml └── src/main/java/com/example/lemma ├── LemmaDemoApplication.java ├── model │ ├── Order.java │ └── User.java ├── rules │ ├── Rule.java │ ├── RuleCondition.java │ ├── RuleAction.java │ └── RuleParser.java ├── engine │ ├── RuleEngine.java │ └── RuleContext.java └── controller └── RuleController.java这个结构比较典型:
model包存放业务模型,也就是规则要操作的对象。rules包定义规则的数据结构和解析器。engine包实现规则匹配和执行的引擎。controller包提供 REST 接口,方便测试。
后面我们会逐步填充每一个文件。
3. 声明式规则语言的核心设计拆解
在写代码之前,先想清楚一个问题:一个最小可用的声明式规则语言,需要具备哪些能力?
3.1 规则的基本结构
任何规则语言,哪怕再复杂,最终都逃不开这样一个结构:
rule [规则名] when [条件列表] then [动作列表] end条件列表和动作列表是规则的核心。
条件描述的是“什么情况下这条规则生效”。动作描述的是“生效之后要做什么”。为了不让引擎过于复杂,我们可以先把条件限定为最简单的表达式形式:
字段名 比较符 值例如:
user.new == true order.amount >= 100动作也可以先限制为“赋值”操作:
order.discount = 20这种设计虽然简单,但已经可以表达相当多的业务规则了。
3.2 比较符与逻辑组合
有了基本结构,下一步要支持哪些比较符?
==:等于!=:不等于>:大于>=:大于等于<:小于<=:小于等于
这些比较符覆盖了绝大多数业务场景。
在表达更复杂的规则时,可能还需要and、or逻辑组合。在最小实现中,我们可以约定:同一规则中的多个条件之间,默认是“并且”的关系。这样可以用最简单的数据结构表达。
如果一个规则需要“或者”关系,可以拆分成两条规则,或者后续扩展条件类型。在最小 Demo 阶段,保持简单更重要。
3.3 规则解析器的职责
规则解析器负责把一段字符串形式的规则文本,转换为内存中的Rule对象。解析器是声明式规则语言的关键部分,因为业务人员写的是文本,引擎执行的是对象。
一个比较清晰的规则文本格式,可以参考下面这个例子:
rule "新人满减" when user.new == true order.amount >= 100 then order.discount = 20 end解析器读取这段文本后,需要:
- 识别规则名。
- 提取 when 块中的条件行。
- 提取 then 块中的动作行。
- 分别封装成
RuleCondition和RuleAction列表。
3.4 引擎的执行流程
引擎的执行流程比较固定,可以拆成三步:
- 加载规则:把规则文本解析成
Rule对象列表。 - 匹配规则:针对传入的规则上下文(包含订单、用户等业务对象),逐条判断条件是否成立。
- 执行动作:对匹配成功的规则,执行动作列表中的赋值操作。
打个比方,这就像是在做一次过滤:规则库里有几十条规则,引擎拿到一个具体业务场景后,筛选出其中所有“当前场景适用”的规则,然后逐一执行它们的结果。
理解了这些核心概念后,下面就可以动手写代码了。
4. 完整实战:实现一个最小的 Lemma 风格规则引擎
这一节我们来实现一个可运行的 Demo。虽然代码量不大,但已经能够完整体现“声明式规则语言”从文本到执行的完整链路。
4.1 定义业务模型
我们先创建两个业务模型类:用户和订单。
文件路径:src/main/java/com/example/lemma/model/User.java
package com.example.lemma.model; public class User { private String name; private boolean newUser; private int level; public User(String name, boolean newUser, int level) { this.name = name; this.newUser = newUser; this.level = level; } public String getName() { return name; } public void setName(String name) { this.name = name; } public boolean isNewUser() { return newUser; } public void setNewUser(boolean newUser) { this.newUser = newUser; } public int getLevel() { return level; } public void setLevel(int level) { this.level = level; } }这里要注意,属性名我们叫newUser,因为规则文本中我们约定用user.newUser来引用。你也可以叫new,但new是 Java 关键字,Java Bean 的 getter 命名会冲突,所以这里用newUser更稳妥。
文件路径:src/main/java/com/example/lemma/model/Order.java
package com.example.lemma.model; import java.math.BigDecimal; public class Order { private String orderNo; private BigDecimal amount; private BigDecimal discount = BigDecimal.ZERO; public Order(String orderNo, BigDecimal amount) { this.orderNo = orderNo; this.amount = amount; } public String getOrderNo() { return orderNo; } public void setOrderNo(String orderNo) { this.orderNo = orderNo; } public BigDecimal getAmount() { return amount; } public void setAmount(BigDecimal amount) { this.amount = amount; } public BigDecimal getDiscount() { return discount; } public void setDiscount(BigDecimal discount) { this.discount = discount; } }这两个模型足够用了。实际项目中你会有更复杂的属性,但基本原则是一样的。
4.2 实现规则结构与解析器
接下来定义规则的数据结构。
文件路径:src/main/java/com/example/lemma/rules/RuleCondition.java
package com.example.lemma.rules; public class RuleCondition { private String field; private String operator; private String value; public RuleCondition(String field, String operator, String value) { this.field = field; this.operator = operator; this.value = value; } public String getField() { return field; } public String getOperator() { return operator; } public String getValue() { return value; } }文件路径:src/main/java/com/example/lemma/rules/RuleAction.java
package com.example.lemma.rules; public class RuleAction { private String field; private String value; public RuleAction(String field, String value) { this.field = field; this.value = value; } public String getField() { return field; } public String getValue() { return value; } }这两个类非常简单,本质上就是两个数据容器,解析器把规则文本填充进去,引擎再读取它们。
文件路径:src/main/java/com/example/lemma/rules/Rule.java
package com.example.lemma.rules; import java.util.ArrayList; import java.util.List; public class Rule { private String name; private List<RuleCondition> conditions = new ArrayList<>(); private List<RuleAction> actions = new ArrayList<>(); public String getName() { return name; } public void setName(String name) { this.name = name; } public List<RuleCondition> getConditions() { return conditions; } public void setConditions(List<RuleCondition> conditions) { this.conditions = conditions; } public List<RuleAction> getActions() { return actions; } public void setActions(List<RuleAction> actions) { this.actions = actions; } }有了这几个类之后,解析器就可以开工了。
文件路径:src/main/java/com/example/lemma/rules/RuleParser.java
package com.example.lemma.rules; import java.util.ArrayList; import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; public class RuleParser { private static final Pattern FIELD_OP_VALUE = Pattern.compile( "^(\\w+\\.\\w+)\\s*(==|!=|>=|<=|>|<)\\s*(.+)$" ); public List<Rule> parse(String ruleText) { List<Rule> rules = new ArrayList<>(); // 按 rule 关键字切分 String[] sections = ruleText.split("(?=rule\\s+\")"); for (String section : sections) { try { Rule rule = parseRule(section); if (rule != null) { rules.add(rule); } } catch (IllegalArgumentException e) { System.err.println("解析规则失败:" + section); e.printStackTrace(); } } return rules; } private Rule parseRule(String section) { if (section == null || section.trim().isEmpty()) { return null; } Rule rule = new Rule(); // 解析规则名 Matcher nameMatcher = Pattern.compile("rule\\s+\"([^\"]+)\"").matcher(section); if (!nameMatcher.find()) { throw new IllegalArgumentException("未找到规则名"); } rule.setName(nameMatcher.group(1)); // 分离 when 和 then 块 String whenBlock = extractBlock(section, "when"); String thenBlock = extractBlock(section, "then"); // 解析条件 String[] conditionLines = whenBlock.split("\n"); for (String line : conditionLines) { String conditionText = line.trim(); if (conditionText.isEmpty()) { continue; } Matcher matcher = FIELD_OP_VALUE.matcher(conditionText); if (matcher.find()) { rule.getConditions().add(new RuleCondition( matcher.group(1), matcher.group(2), matcher.group(3).trim() )); } else { throw new IllegalArgumentException("无法解析条件: " + conditionText); } } // 解析动作 String[] actionLines = thenBlock.split("\n"); for (String line : actionLines) { String actionText = line.trim(); if (actionText.isEmpty()) { continue; } // 动作格式为:字段 = 值 String[] parts = actionText.split("="); if (parts.length == 2) { rule.getActions().add(new RuleAction( parts[0].trim(), parts[1].trim() )); } else { throw new IllegalArgumentException("无法解析动作: " + actionText); } } return rule; } private String extractBlock(String section, String keyword) { int startIndex = section.indexOf(keyword); if (startIndex < 0) { throw new IllegalArgumentException("缺少 " + keyword + " 块"); } int contentStart = section.indexOf("\n", startIndex); if (contentStart < 0) { throw new IllegalArgumentException(keyword + " 块缺少内容"); } int endIndex; if ("when".equals(keyword)) { endIndex = section.indexOf("then", contentStart); } else { endIndex = section.indexOf("end", contentStart); } if (endIndex < 0) { throw new IllegalArgumentException(keyword + " 块缺少结束标记"); } return section.substring(contentStart, endIndex); } }这个解析器做了一些简化处理:
- 用正则表达式匹配
字段 操作符 值格式。 - 按
when和then关键字切分文本块。 - 条件行和动作行均按换行符切分。
在实际项目中,你可能需要更鲁棒的解析方案,比如使用 ANTLR 定义词法和语法,但作为教学示例,这个解析器已经足够展示核心思路。
4.3 实现规则上下文与引擎
解析器把文本转换为Rule对象之后,引擎需要能够读取上下文中对象的具体属性值。这里我们借助反射来实现属性的读取和赋值。
文件路径:src/main/java/com/example/lemma/engine/RuleContext.java
package com.example.lemma.engine; import java.util.HashMap; import java.util.Map; public class RuleContext { private Map<String, Object> variables = new HashMap<>(); public void setVariable(String name, Object value) { variables.put(name, value); } public Object getVariable(String name) { return variables.get(name); } }这是最简单的上下文实现:用一个 Map 存放变量引用。user和order这样的对象会注册进去。
文件路径:src/main/java/com/example/lemma/engine/RuleEngine.java
package com.example.lemma.engine; import com.example.lemma.rules.Rule; import com.example.lemma.rules.RuleAction; import com.example.lemma.rules.RuleCondition; import java.lang.reflect.Field; import java.math.BigDecimal; import java.util.List; public class RuleEngine { public void execute(List<Rule> rules, RuleContext context) { for (Rule rule : rules) { if (match(rule, context)) { apply(rule, context); } } } private boolean match(Rule rule, RuleContext context) { for (RuleCondition condition : rule.getConditions()) { if (!matchesCondition(condition, context)) { return false; } } return true; } private boolean matchesCondition(RuleCondition condition, RuleContext context) { String field = condition.getField(); Object actualValue = resolveField(field, context); if (actualValue == null) { return false; } String operator = condition.getOperator(); String expectedValueStr = condition.getValue(); // 特殊处理 Boolean 比较 if (actualValue instanceof Boolean) { boolean actual = (Boolean) actualValue; boolean expected = Boolean.parseBoolean(expectedValueStr); if ("==".equals(operator)) { return actual == expected; } else if ("!=".equals(operator)) { return actual != expected; } return false; } // 数字比较 if (actualValue instanceof Number) { BigDecimal actual = new BigDecimal(actualValue.toString()); BigDecimal expected = new BigDecimal(expectedValueStr); int compareResult = actual.compareTo(expected); switch (operator) { case "==": return compareResult == 0; case "!=": return compareResult != 0; case ">": return compareResult > 0; case ">=": return compareResult >= 0; case "<": return compareResult < 0; case "<=": return compareResult <= 0; default: return false; } } // 字符串比较 if (actualValue instanceof String) { int compareResult = actualValue.toString().compareTo(expectedValueStr); switch (operator) { case "==": return compareResult == 0; case "!=": return compareResult != 0; case ">": return compareResult > 0; case ">=": return compareResult >= 0; case "<": return compareResult < 0; case "<=": return compareResult <= 0; default: return false; } } return false; } private void apply(Rule rule, RuleContext context) { for (RuleAction action : rule.getActions()) { String field = action.getField(); String value = action.getValue(); setFieldValue(field, value, context); } } private Object resolveField(String field, RuleContext context) { String[] parts = field.split("\\."); if (parts.length != 2) { throw new IllegalArgumentException("字段格式应该为 变量.属性: " + field); } Object target = context.getVariable(parts[0]); if (target == null) { throw new IllegalArgumentException("上下文中未找到变量: " + parts[0]); } try { Field f = target.getClass().getDeclaredField(parts[1]); f.setAccessible(true); return f.get(target); } catch (NoSuchFieldException | IllegalAccessException e) { throw new IllegalArgumentException("无法读取属性: " + field, e); } } private void setFieldValue(String field, String value, RuleContext context) { String[] parts = field.split("\\."); if (parts.length != 2) { throw new IllegalArgumentException("字段格式应该为 变量.属性: " + field); } Object target = context.getVariable(parts[0]); if (target == null) { throw new IllegalArgumentException("上下文中未找到变量: " + parts[0]); } try { Field f = target.getClass().getDeclaredField(parts[1]); f.setAccessible(true); Class<?> fieldType = f.getType(); if (fieldType == BigDecimal.class) { f.set(target, new BigDecimal(value)); } else if (fieldType == boolean.class || fieldType == Boolean.class) { f.set(target, Boolean.parseBoolean(value)); } else if (fieldType == int.class || fieldType == Integer.class) { f.set(target, Integer.parseInt(value)); } else if (fieldType == String.class) { f.set(target, value); } else { throw new IllegalArgumentException("暂不支持的类型: " + fieldType.getName()); } } catch (NoSuchFieldException | IllegalAccessException e) { throw new IllegalArgumentException("无法设置属性: " + field, e); } } }引擎的核心逻辑集中在matchesCondition和apply两个方法中:
matchesCondition:根据操作符类型,对布尔、数字、字符串分别做比较。apply:通过反射把规则中声明的值赋给目标字段。
要注意,BigDecimal的比较不能用equals,因为它是按精度比较的,1.0和1.00的equals结果是false。这里用compareTo更合理。
4.4 编写启动类与测试接口
把以上组件串起来之后,我们写一个启动类和一个测试接口。
文件路径:src/main/java/com/example/lemma/LemmaDemoApplication.java
package com.example.lemma; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class LemmaDemoApplication { public static void main(String[] args) { SpringApplication.run(LemmaDemoApplication.class, args); } }文件路径:src/main/java/com/example/lemma/controller/RuleController.java
package com.example.lemma.controller; import com.example.lemma.engine.RuleContext; import com.example.lemma.engine.RuleEngine; import com.example.lemma.model.Order; import com.example.lemma.model.User; import com.example.lemma.rules.Rule; import com.example.lemma.rules.RuleParser; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.util.List; import java.util.Map; @RestController public class RuleController { private final RuleParser parser = new RuleParser(); private final RuleEngine engine = new RuleEngine(); @PostMapping("/execute") public Map<String, Object> execute(@RequestBody Map<String, Object> request) { String ruleText = (String) request.get("ruleText"); Boolean userNew = (Boolean) request.get("userNew"); Integer userLevel = (Integer) request.get("userLevel"); BigDecimal orderAmount = new BigDecimal(request.get("orderAmount").toString()); // 构造业务对象 User user = new User("test", userNew != null && userNew, userLevel == null ? 0 : userLevel); Order order = new Order("NO001", orderAmount); // 准备上下文 RuleContext context = new RuleContext(); context.setVariable("user", user); context.setVariable("order", order); // 解析并执行规则 List<Rule> rules = parser.parse(ruleText); engine.execute(rules, context); return Map.of( "orderNo", order.getOrderNo(), "discount", order.getDiscount(), "matchedRules", rules.size() ); } }这里用了一个非常简单的POST接口来做验证。实际项目中,规则文本通常存储在数据库或配置中心,而不是请求参数里。
4.5 运行与验证
启动 Spring Boot 应用后,我们可以用curl或 Postman 发送请求来验证。
测试用例一:新用户,订单金额 120,期望命中规则。
curl -X POST http://localhost:8080/execute \ -H "Content-Type: application/json" \ -d '{ "ruleText": "rule \"新人满减\"\nwhen\n user.newUser == true\n order.amount >= 100\nthen\n order.discount = 20\nend", "userNew": true, "userLevel": 0, "orderAmount": 120 }'预期结果:
{ "orderNo": "NO001", "discount": 20, "matchedRules": 1 }测试用例二:非新用户,订单金额 120,不命中规则。
curl -X POST http://localhost:8080/execute \ -H "Content-Type: application/json" \ -d '{ "ruleText": "rule \"新人满减\"\nwhen\n user.newUser == true\n order.amount >= 100\nthen\n order.discount = 20\nend", "userNew": false, "userLevel": 1, "orderAmount": 120 }'预期结果:
{ "orderNo": "NO001", "discount": 0, "matchedRules": 0 }到这里,一个最小的声明式规则引擎就跑通了。你可能已经注意到,整个执行链路非常清晰:规则文本 -> 解析器 -> Rule 对象 -> 引擎匹配 -> 动作执行。
5. 用规则语言表达真实业务场景
光有 Demo 还不够,我们来模拟几个更贴近线上的业务场景,验证这种规则表达方式的灵活性。
5.1 场景一:会员等级折扣
假设系统有一条规则:只有 3 级及以上的会员,在下单金额超过 500 元时,可以享受 50 元优惠。
规则文本可以写成:
rule "高等级会员满减" when user.level >= 3 order.amount >= 500 then order.discount = 50 end如果后续活动改成“等级降到 2 级”,只需要修改规则文本,不需要改代码。
5.2 场景二:非新用户不参与首单优惠
再来看一个稍微复杂一点的场景:如果系统同时有“新人优惠”和“会员折扣”两条规则,就涉及一个优先级问题。比如我们希望“新人优惠”只对首单用户生效,会员折扣不叠加。
一种最简单的处理方式是把这些规则拆开写:
rule "首单优惠" when user.newUser == true order.amount >= 100 then order.discount = 20 end rule "会员折扣" when user.newUser == false user.level >= 3 order.amount >= 500 then order.discount = 50 end这样写的好处是,每条规则都比较短,业务人员也容易理解“什么情况下走哪个优惠”。如果你需要更复杂的互斥、优先级处理,就需要在引擎层加入规则冲突处理策略,例如salience优先级字段。这在 Drools 等成熟规则引擎中很常见,但对最小实现来说,可以先不涉及。
5.3 场景三:风控规则
风控场景也非常适合声明式规则语言。例如:
rule "高频下单拦截" when user.level <= 0 order.amount > 10000 then order.discount = 0 end这里的动作可能不是给折扣,而是设置一个风控标记。你可以把动作扩展成任意字段赋值,比如order.riskFlag = 1。
从这些例子可以看出,声明式规则语言的表达能力取决于你定义的字段、操作符和动作。字段越贴合业务,规则就越容易读。
6. 常见问题与排查思路
在实际动手实现或使用声明式规则语言时,有几个问题经常出现。这里整理一份问题排查清单。
6.1 常见问题汇总
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 规则解析失败 | 规则文本中when或then缺少换行 | 检查规则文本格式,确认关键字后面跟的是换行符 |
| 条件永远不匹配 | 属性名拼写错误,或反射读取不到属性 | 检查字段中的变量名和属性名是否一致 |
| 比较结果不对 | 数字类型混用,浮点数用equals比较 | 优先用BigDecimal.compareTo比较数字 |
| 动作赋值不生效 | 字段名写错,或类型转换失败 | 检查动作行中的字段是否存在,类型是否受支持 |
| 多条规则互相覆盖 | 规则执行顺序混乱 | 设计优先级或冲突处理策略 |
| 规则数量多了以后性能差 | 每条规则都走反射遍历 | 缓存反射结果,引入索引或先粗糙筛选候选规则 |
6.2 属性读取不到怎么办
如果你执行规则时遇到无法读取属性异常,可以按以下步骤排查:
- 确认规则文本中的变量名是
user、order这样的注册名。 - 确认变量名后的属性名是 Java 类中真实存在的字段名。
- 检查该字段是否是
private,引擎中已经调用了setAccessible(true),如果不是这个原因,检查字段是否被static修饰。 - 确认字段名没有拼写错误,比如
newUser不要写成newuser。
6.3 规则优先级不明确
最小实现是“按规则集合的顺序依次执行”,这在实际项目中可能不够用。通常的解决方案有两种:
- 给
Rule增加一个priority字段,在执行前按优先级排序。 - 匹配成功后立即中断,只执行第一条匹配的规则。
具体选择哪种方案,取决于业务需求。比如营销场景通常需要优惠叠加,就应该全部执行;而风控场景通常只摘取最高优先级的结果,就应该中断。
6.4 如何保证规则的可测试性
规则代码分散在 If-Else 中时,很难单独测试。有了声明式规则语言后,你可以把规则文本和测试数据放到一起,做表驱动测试:
String ruleText = loadRuleFromFile("new-user-discount.rule"); List<Rule> rules = parser.parse(ruleText); RuleContext context = new RuleContext(); context.setVariable("user", new User("u1", true, 0)); context.setVariable("order", new Order("o1", new BigDecimal("150"))); engine.execute(rules, context); assertEquals(0, new BigDecimal("20").compareTo(order.getDiscount()));这种方法比在业务代码里模拟各种分支条件要清晰得多。
6.5 一个容易被忽略的坑
当规则文本中有多个规则时,字符串切分和块解析的逻辑容易出错。建议在实际实现中,对多个规则之间使用明确的空行分隔,并在解析器中增加跳过空行的逻辑。本文的例子已经做了基本处理,但如果你复制代码去扩展,要注意when和then块之间、then和end之间不要漏掉换行。
7. 最佳实践与工程建议
到这里,我们已经完成了一个可以运行的 Demo。但真实项目中,要把一个声明式规则语言用好,还需要注意很多工程细节。
7.1 规则文本如何管理
绝不要把规则文本直接硬编码在 Java 代码里。推荐的做法是:
- 把规则文本存储在配置文件中,适合改动频率很低的规则。
- 把规则文本存储在数据库中,适合可以后台动态维护的规则。
- 引入配置中心(例如 Apollo、Nacos)管理规则,适合需要灰度发布和快速回滚的场景。
规则的版本管理同样重要。每次修改规则都应有记录,方便在出问题时回溯到上一个版本。
7.2 规则引擎的性能优化
反射是方便,但性能一般。如果你在一个高并发接口里每次都解析规则、反射读取属性,肯定会有性能问题。可以做以下优化:
- 规则预热:系统启动时解析全部规则并缓存,不要在请求时解析。
- 反射缓存:把字段的
Field对象缓存起来,避免每次获取。 - 规则索引:如果规则很多,可以按用户级别、订单金额等维度建立简单索引,减少匹配时的无效遍历。
- 批量执行:一次性传入多个对象批量执行规则,减少上下文创建开销。
7.3 规则的可观测性
规则执行的过程必须留下日志,至少记录以下信息:
- 当前请求的上下文 ID。
- 命中了哪些规则。
- 执行了哪些动作。
- 规则执行前后,关键业务字段的值变化。
有了这些日志,才能真正定位“为什么这个订单没有打折”这类问题。如果规则引擎执行结果异常,也要提供规则回放的能力。
7.4 安全与授权边界
如果你的规则允许业务人员在线编辑,就必须考虑安全边界:
- 规则解析器不能支持任意代码执行,否则就是暴露了一个远程代码执行漏洞。
- 对可用的字段、操作符、动作类型要有白名单限制。
- 规则变更需要审批流程,避免误操作影响线上业务。
- 生产环境使用规则动态更新时,必须先在测试环境验证。
这些安全边界,在 Drools、Easy Rules 等成熟框架中通常有对应的配置。自己实现时一定不能忽略。
7.5 避免过度设计
最后一条建议是:不要为了用规则引擎而用规则引擎。如果你的业务规则很少、变化很慢、逻辑简单,那 if-else 可能是更合适的选择。引入声明式规则语言会增加解析、调试、维护的成本。只有当规则数量多、变化频繁、需要业务人员参与维护时,这套方案的收益才会显现。
8. 总结与后续学习路线
本文围绕 Lemma 这个“声明式业务规则语言”的概念展开,从一个具体场景出发,理解了声明式规则表达相较于命令式 if-else 的优势,然后完整实现了一个最小的 Lemma 风格规则引擎。这个引擎虽然简单,但已经包含了规则解析、条件匹配、动作执行的全链路。
你可以顺着以下方向继续深入:
- 学习成熟的规则引擎,例如Drools,看它如何处理规则冲突、优先级、规则流等复杂问题。
- 研究ANTLR,用形式化的语法定义来表达更复杂的规则文法,替代手写正则解析。
- 结合Apollo 或 Nacos 配置中心,把规则文本做成动态配置,实现规则热更新。
- 了解业务规则管理系统(BRMS),看一个完整的规则管理后台应该包含哪些功能,例如版本控制、测试沙箱、审批流。
规则引擎这条路,知道的人很多,真正动手实现过的人不多。如果你已经跟着本文把 Demo 跑通,再去看 Drools 的文档时会轻松很多。
如果这篇文章对你有帮助,可以收藏备用。后续我会继续分享规则引擎在真实项目中的落地经验,包括规则热更新、规则测试框架、规则和 DDD 领域模型的结合方式等,欢迎关注交流。
