看见Lemma这个项目标题时,第一个疑问通常是:业务规则用 if-else 直接写不就行了,为什么还要专门发明一门声明式语言?这个问题的答案,恰好是理解 Lemma、也理解所有声明式业务规则语言的关键。业务规则用命令式代码写一两次很容易,写成几百上千行之后,规则和流程代码搅在一起,评审、复用、调整和测试都变得非常困难。Lemma 的定位,就是提供一种更接近业务语义的声明式语言,让“什么条件下得出什么结论”这件事可以独立于业务代码存在。
需要先说明一个边界:Lemma 项目本身的官方语法、版本和接入方式要以项目文档为准。下面为了解释这类语言的设计思路,会用一种接近 Lemma 思路的极简规则文本作为示例。它不是 Lemma 的官方语法,但足够说明“声明式规则语言要解决什么问题、需要哪些组成部分、怎么落到项目里”。搜索 Lemma 时还容易碰到数学分析里的离散 Gronwall 引理,名字也叫 Lemma,两者完全不同。本文只讨论业务规则语言。
1. 先理解业务规则为什么要“声明式”
1.1 命令式规则代码为什么难维护
在普通 Java 项目中,规则最常见的写法是散落在 Service 方法里的一组 if-else。
public DiscountResult calculateDiscount(Order order) { double discount = 0D; if (order.getCustomer().getAge() < 18) { discount = 0.2D; } if (order.getCustomer().getAge() >= 60) { discount = 0.15D; } if (order.getTotalAmount() > 5000) { discount = Math.max(discount, 0.25D); } if (!"NORMAL".equals(order.getStatus())) { discount = 0D; } return new DiscountResult(discount); }这段代码看起来不算复杂,但它已经埋了好几个问题。第一,规则之间是竞争关系还是叠加关系,只能靠逐行读代码判断。if (age >= 60)和if (totalAmount > 5000)同时命中时,discount被后面的Math.max覆盖,新来的同事很难一眼看出这是“取最大值”还是“后写覆盖先写”。第二,规则和业务处理逻辑耦合在同一个方法里,业务人员评审规则时,必须懂代码。第三,规则一旦增多,删除一条规则往往要通读整个方法,因为删除一个 if 分支可能会改变其他分支的行为。
这类问题的本质是:命令式代码把“规则是什么”和“规则怎么执行”混在了一起。开发者读代码时既要理解业务语义,又要理解 Java 的执行顺序、作用域和赋值逻辑。
1.2 声明式规则的表层和本质
声明式规则语言做的事情,是把“规则内容”从“规则执行过程”中分离出来。业务上只声明:当某个条件成立时,某个结果应该被计算出来。至于条件怎么求值、结果怎么写入,由规则引擎统一处理。
一个声明式规则的最简单形态是:
when customer.age < 18 then discount = 0.2; when customer.age >= 60 then discount = 0.15;这组文本描述的就是规则本身,不包含遍历、赋值顺序、方法调用这些执行细节。阅读者不需要关心 Java 的if关键字,只需要理解业务条件。
声明式语言和命令式代码的核心差异可以从几个维度看:
| 对比维度 | 命令式代码 | 声明式规则 |
|---|---|---|
| 表达重点 | 怎么做:按什么顺序执行 | 是什么:在什么条件下成立 |
| 业务可读性 | 依赖开发者编码习惯 | 接近业务描述语言 |
| 修改成本 | 改代码、改测试、重新部署 | 改规则文本,校验后发布 |
| 执行顺序 | 由代码行顺序决定 | 由语言语义和规则引擎决定 |
| 测试方式 | 单元测试覆盖分支 | 规则输入输出样例验证 |
| 与业务代码耦合 | 强耦合 | 弱耦合,规则独立存储 |
需要注意,声明式不代表没有执行顺序,而是执行顺序由“语言定义”负责,而不是由“每个调用方随手写的代码顺序”负责。比如上面两条规则,同时命中时到底谁覆盖谁,必须由规则引擎给出明确语义,否则声明式同样会陷入混乱。
1.3 Lemma 这类语言想解决的核心问题
用一句话概括:Lemma 这类语言的目的是把“决策逻辑”从“应用代码”里抽出来,让规则成为可以单独编写、评审、测试和发布的数据。
这种设计带来几个直接收益。第一,规则变更不需要发版。传统 Java 项目修改一条折扣规则,通常要经历改代码、跑测试、构建、发布整个流程。规则外置后,只需要加载新规则文本。第二,规则可以交给业务方评审。虽然大多数团队不会让业务人员直接提交规则,但规则文本脱离 Java 语法后,业务人员至少能看懂,技术评审和业务评审可以共用一份材料。第三,规则可以被统一加固。解析错误、字段缺失、条件不匹配、规则冲突,都可以集中在规则引擎层处理,而不是散落在各个 Service 里。
这里的代价也很明显:规则引擎本身需要设计语法、写解析器、处理异常,还要考虑加载和缓存。如果项目里只有三五条固定规则,引入规则语言是过度设计。一旦规则数量上到几十条、变更频率明显高于代码发版频率,声明式规则语言的价值就会体现出来。
2. 设计一个最小规则语言:核心元素先定下来
2.1 最小规则集合:输入、条件、动作和优先级
任何规则语言都要先回答四个问题:输入数据是什么,怎么判断条件成立,成立后做什么,多个规则同时命中时怎么处理。
以电商折扣场景为例,输入数据是一个订单上下文,至少包含顾客年龄、订单金额和订单状态。条件是用比较运算符组成的布尔表达式,比如customer.age < 18。动作是给某个输出字段赋值,比如discount = 0.2。优先级用于解决多条规则同时命中时的冲突。
设计最小 DSL 时,只需要保留最少的语法元素:
- 关键字:
when、then - 标识符:字段名,如
customer.age、discount - 数字:整数或小数
- 比较运算符:
<、<=、>、>= - 赋值运算符:
= - 结束符:分号
这组元素已经可以描述大量基于数值比较的规则。它不包含字符串操作、函数调用、逻辑与或等复杂特性,实际项目中如果需要,再逐步扩展。
2.2 一个可读规则文本示例
下面是一个极简规则文本,文件命名为discount.rule:
when customer.age < 18 then discount = 0.2; when customer.age >= 60 then discount = 0.15; when order.totalAmount >= 5000 then discount = 0.25;这三行规则表达的业务含义是:未成年顾客折扣 0.2,老年顾客折扣 0.15,订单金额超过 5000 的折扣 0.25。阅读这段文本的人不需要知道 Java 语法,也不需要知道规则执行时会走什么循环。
它同时暴露了一个问题:如果顾客年龄 16 且订单金额 6000,前两条规则里第一条命中、第三条也命中,最终 discount 到底是多少?这取决于执行语义。这个最小 DSL 如果按文本顺序执行,后命中的规则会覆盖先前结果,那么最终结果是 0.25。如果业务期望“取最大值”,就需要在执行器里明确规则合并策略。
2.3 冲突策略必须先定义
声明式规则语言最容易踩的坑就是冲突策略不明确。不同业务场景对“多个规则同时命中”的处理方式完全不同:
| 策略 | 语义 | 适用场景 |
|---|---|---|
| 后写覆盖先写 | 按规则顺序执行,后赋值的覆盖前值 | 简单状态机、默认值链 |
| 最高优先级胜出 | 按优先级字段排序,只执行最高优先级规则 | 优惠券互斥、套餐优先 |
| 聚合计算 | 对多规则结果做 max、min、sum | 折扣取最大、积分叠加 |
| 全部执行 | 无冲突,规则产生独立结果 | 标签推荐、命中诊断 |
真实项目里,建议在语法中显式加入priority字段,而不是依赖文本顺序。文本顺序太难维护,插入一行规则就可能改变整体行为。下面第 3 节的最小实现先按“文本顺序执行”演示原理,第 6 节会说明生产环境如何引入优先级。
3. 用一个 Java 最小实现跑通解析和执行
3.1 环境准备:JDK 17 和 Maven
这个最小实现只使用 Java 标准库,不需要 Spring 框架,也不需要第三方依赖。环境建议如下:
| 组件 | 版本建议 | 说明 |
|---|---|---|
| JDK | 17 及以上 | 使用 record、switch 表达式、文本块 |
| Maven | 3.8 及以上 | 项目构建工具 |
| IDE | IntelliJ IDEA 或 Eclipse | 调试解析过程更方便 |
原始材料没有给出 Lemma 项目要求的 Java 版本,所以这里选 JDK 17 是一种稳妥实践。实际接入时要先确认目标运行环境的 JDK 版本,避免把代码拿到 JDK 8 环境后编译失败。
Maven 工程的pom.xml只需要最基本的配置:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>lemma-mini</artifactId> <version>1.0.0</version> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> </properties> </project>不需要额外引库,核心原因是要演示规则引擎的解析与执行原理。实际生产项目如果要支持复杂条件、函数、规则集管理,再考虑 Drools、Easy Rules 这类成熟框架。
3.2 项目结构
工程结构保持最小:
lemma-mini ├── pom.xml └── src/main/java/com/example/lemma ├── Lexer.java ├── Token.java ├── TokenType.java ├── Parser.java ├── Rule.java ├── Condition.java ├── Action.java ├── RuleParseException.java ├── RuleEngine.java └── Main.java这个结构对应了最基础的编译前端:词法分析、语法分析、规则模型和执行器。词法分析负责把规则文本拆成 Token,语法分析负责把 Token 组装成规则对象,执行器负责对输入数据求值。
3.3 Token 和词法分析器
词法分析是解析规则文本的第一步,它的作用是把字符串拆成一个个有意义的单词,比如customer.age、<、18、then。
先定义 Token 类型:
enum TokenType { IDENT, // 标识符,如 customer.age NUMBER, // 数字,如 18 LT, LE, GT, GE, // 比较运算符 ASSIGN, // 赋值 = WHEN, // 关键字 when THEN, // 关键字 then SEMI, // 分号 EOF // 结束标记 }再定义 Token 数据结构,这里用 record:
record Token(TokenType type, String text) { }词法分析器核心代码如下:
class Lexer { private final String src; private int pos; Lexer(String src) { this.src = src; } List<Token> tokenize() { List<Token> tokens = new ArrayList<>(); while (pos < src.length()) { char c = src.charAt(pos); if (Character.isWhitespace(c)) { pos++; continue; } if (c == '<') { if (matchNext('=')) { tokens.add(new Token(TokenType.LE, "<=")); } else { tokens.add(new Token(TokenType.LT, "<")); } continue; } if (c == '>') { if (matchNext('=')) { tokens.add(new Token(TokenType.GE, ">=")); } else { tokens.add(new Token(TokenType.GT, ">")); } continue; } if (c == '=') { tokens.add(new Token(TokenType.ASSIGN, "=")); pos++; continue; } if (c == ';') { tokens.add(new Token(TokenType.SEMI, ";")); pos++; continue; } if (Character.isDigit(c)) { int start = pos; while (pos < src.length() && (Character.isDigit(src.charAt(pos)) || src.charAt(pos) == '.')) { pos++; } tokens.add(new Token(TokenType.NUMBER, src.substring(start, pos))); continue; } if (Character.isLetter(c) || c == '_' || c == '.') { int start = pos; while (pos < src.length()) { char ch = src.charAt(pos); if (Character.isLetterOrDigit(ch) || ch == '_' || ch == '.') { pos++; } else { break; } } String word = src.substring(start, pos); switch (word) { case "when" -> tokens.add(new Token(TokenType.WHEN, word)); case "then" -> tokens.add(new Token(TokenType.THEN, word)); default -> tokens.add(new Token(TokenType.IDENT, word)); } continue; } throw new RuleParseException("无法识别的字符: " + c); } tokens.add(new Token(TokenType.EOF, "")); return tokens; } private boolean matchNext(char expected) { if (pos + 1 < src.length() && src.charAt(pos + 1) == expected) { pos += 2; return true; } pos++; return false; } }这里有一个细节:标识符允许包含点号,所以customer.age会被识别成一个完整 Token,而不是customer、.、age三个 Token。这样处理让后续解析更简单,但也意味着规则语言里不能随便使用点号,点号已经成为字段路径的一部分。
3.4 规则模型和语法分析器
规则模型用三个 record 表达:
record Condition(String left, TokenType op, String right) { } record Action(String variable, String value) { } record Rule(Condition condition, Action action) { }Condition表示条件表达式,left是字段路径,op是比较运算符,right是数字或另一个字段。Action表示动作,variable是要赋值的字段,value是赋值内容。Rule把条件和动作组合起来。
语法分析器的职责是检查 Token 序列是否符合预期结构:
class Parser { private final List<Token> tokens; private int idx; Parser(List<Token> tokens) { this.tokens = tokens; } List<Rule> parseRules() { List<Rule> rules = new ArrayList<>(); while (peek().type() != TokenType.EOF) { rules.add(parseRule()); } return rules; } private Rule parseRule() { expect(TokenType.WHEN); condition = parseCondition(); expect(TokenType.THEN); action = parseAction(); expect(TokenType.SEMI); return new Rule(condition, action); } private Condition parseCondition() { Token left = expect(TokenType.IDENT); Token op = next(); if (op.type() != TokenType.LT && op.type() != TokenType.LE && op.type() != TokenType.GT && op.type() != TokenType.GE) { throw new RuleParseException("条件只支持 < <= > >= 比较操作符,实际是: " + op.text()); } Token right = next(); if (right.type() != TokenType.IDENT && right.type() != TokenType.NUMBER) { throw new RuleParseException("条件右值必须是字段或数字,实际是: " + right.text()); } return new Condition(left.text(), op.type(), right.text()); } private Action parseAction() { Token variable = expect(TokenType.IDENT); expect(TokenType.ASSIGN); Token value = next(); if (value.type() != TokenType.IDENT && value.type() != TokenType.NUMBER) { throw new RuleParseException("动作值必须是字段或数字,实际是: " + value.text()); } return new Action(variable.text(), value.text()); } private Token expect(TokenType type) { Token token = next(); if (token.type() != type) { throw new RuleParseException("期望 " + type + ",实际是: " + token.text()); } return token; } private Token peek() { return tokens.get(idx); } private Token next() { return tokens.get(idx++); } }这段代码体现了规则语言的最小文法:
rule := 'when' condition 'then' action ';' condition := IDENT op (IDENT | NUMBER) action := IDENT '=' (IDENT | NUMBER)如果规则文本不符合这个文法,会抛出RuleParseException,调用方可以捕获这个异常并给出可读的规则错误行,而不是让全服务启动失败。
3.5 执行器:对输入数据求值
执行器负责把规则对象应用到输入上下文上。为了方便演示,这里把输入和输出都放在一个Map<String, Object>里,字段路径直接作为 key。
class RuleEngine { Map<String, Object> execute(String rulesText, Map<String, Object> context) { List<Token> tokens = new Lexer(rulesText).tokenize(); List<Rule> rules = new Parser(tokens).parseRules(); Map<String, Object> result = new HashMap<>(context); for (Rule rule : rules) { if (Boolean.TRUE.equals(evalCondition(rule.condition(), result))) { Object value = toValue(rule.action().value(), result); result.put(rule.action().variable(), value); System.out.println("命中规则: " + rule.condition().left() + " " + rule.condition().op() + " " + rule.condition().right()); } } return result; } private boolean evalCondition(Condition cond, Map<String, Object> context) { Object leftVal = context.get(cond.left()); Object rightVal = isNumber(cond.right()) ? Double.parseDouble(cond.right()) : context.get(cond.right()); if (leftVal == null || rightVal == null) { throw new IllegalArgumentException("条件字段缺失: " + cond.left() + " 或 " + cond.right()); } double left = ((Number) leftVal).doubleValue(); double right = ((Number) rightVal).doubleValue(); return switch (cond.op()) { case LT -> left < right; case LE -> left <= right; case GT -> left > right; case GE -> left >= right; default -> throw new IllegalArgumentException("不支持的运算符: " + cond.op()); }; } private Object toValue(String text, Map<String, Object> context) { if (isNumber(text)) { return Double.parseDouble(text); } Object value = context.get(text); if (value == null) { throw new IllegalArgumentException("字段缺失: " + text); } return value; } private boolean isNumber(String text) { try { Double.parseDouble(text); return true; } catch (NumberFormatException e) { return false; } } }执行时,每条规则先对条件求值,条件成立才执行动作。动作赋值写入 result map,后续规则再次判断条件时,读取的是已经更新过的 result。这就是“规则副作用影响后续规则”的语义,与 2.2 小节说的顺序执行策略一致。
3.6 运行验证
在Main.java中写一个最小闭环:
public class Main { public static void main(String[] args) { String rules = """ when customer.age < 18 then discount = 0.2; when customer.age >= 60 then discount = 0.15; when order.totalAmount >= 5000 then discount = 0.25; """; Map<String, Object> context = new HashMap<>(); context.put("customer.age", 16); context.put("order.totalAmount", 6000); Map<String, Object> result = new RuleEngine().execute(rules, context); System.out.println("计算结果 discount = " + result.get("discount")); } }运行后,预期输出是:
命中规则: customer.age < 18 命中规则: order.totalAmount >= 5000 计算结果 discount = 0.25这里验证了一个已经被说过的规则语义:年龄 16 和金额 6000 同时命中两条规则,按文本顺序执行,最后的discount是 0.25。如果业务期望未成年折扣和金额折扣取最大值,这个结果就是正确的;如果业务期望两条规则叠加,结果就错了。验证阶段要做的,就是确认“规则语言定义的行为”和“业务真实期望”一致。
4. 把规则引擎接入 Spring Boot 项目:规则文件外部化
4.1 为什么在 Spring Boot 里还要单独做规则服务
最小实现跑通后,还需要考虑真实项目里的接入方式。最常见的选择是把规则文件放在 resources 目录下,由 Spring Boot 启动时加载。这样可以避免规则文本散落在 Java 代码里,也让规则变更只依赖文件发布,而不改 Java 类。
在 Spring Boot 项目中,与规则引擎相关的职责有三个:加载规则文件、解析规则、对外提供执行入口。下面用一个RuleService来组织这些职责。
先写配置文件application.yml:
lemma: rules-path: classpath:rules/discount.rule再创建RuleService:
@Service public class RuleService { private final List<Rule> rules; public RuleService(@Value("${lemma.rules-path}") String rulePath) throws IOException { this.rules = loadRules(rulePath); } private List<Rule> loadRules(String rulePath) throws IOException { Resource resource = new ClassPathResource(rulePath.replace("classpath:", "")); String text = new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); List<Token> tokens = new Lexer(text).tokenize(); return new Parser(tokens).parseRules(); } public Map<String, Object> evaluate(Map<String, Object> context) { Map<String, Object> result = new HashMap<>(context); RuleEngine engine = new RuleEngine(); for (Rule rule : rules) { if (engine.evalCondition(rule.condition(), result)) { result.put(rule.action().variable(), engine.toValue(rule.action().value(), result)); } } return result; } }这里loadRules使用ClassPathResource而不是new File。原因在 Spring Boot 打成 jar 包后,src/main/resources下的文件会被打进 jar 内,new File("rules/discount.rule")无法直接读取 jar 内部的资源,而ClassPathResource可以通过类路径访问。这是规则文件接入阶段非常典型的一个坑。
为了让它能编译,需要把RuleEngine里的私有方法调整为包内可访问,或者把evalCondition和toValue改成 public。生产代码也可以把“解析”和“执行”拆成两个独立组件,避免每次加载都重复构造 lexer。
4.2 业务调用示例
在一个订单计价服务中使用RuleService:
@Service public class OrderService { private final RuleService ruleService; public OrderService(RuleService ruleService) { this.ruleService = ruleService; } public double calcDiscount(Order order) { Map<String, Object> context = new HashMap<>(); context.put("customer.age", order.getCustomer().getAge()); context.put("order.totalAmount", order.getTotalAmount()); context.put("order.status", order.getStatus()); Map<String, Object> result = ruleService.evaluate(context); return (Double) result.getOrDefault("discount", 0D); } }业务代码里只做输入上下文组装和结果读取,规则内容全部在discount.rule文件里。后续如果新增一条规则,只需要修改规则文件,不需要动OrderService。
4.3 规则文件命名和加载方式需要注意的细节
规则文件放在哪个路径,会影响运维发布方式。开发环境可以直接放在src/main/resources/rules/下,打包进应用。生产环境如果需要热更新,通常会改成从数据库、配置中心或挂载卷读取,规则文件路径就变成一个配置项而不是写死的路径。
参数说明如下:
| 配置项 | 含义 | 推荐值 | 配置错误的表现 |
|---|---|---|---|
lemma.rules-path | 规则文件路径 | classpath:rules/discount.rule | 启动时抛FileNotFoundException |
| 规则文件编码 | 文件读取使用的字符集 | UTF-8 | 中文注释乱码或解析错位 |
lemma.cache-enabled | 是否缓存解析后的规则 AST | true | 关闭后每次执行都重新解析,性能下降 |
| 规则字段命名 | 输入 context 的 key 规则 | 统一使用点号路径 | 大小写不一致导致条件字段缺失 |
字段命名最容易出问题。context.put("customer.age", ...)和规则文件里的customer.age必须完全一致,包括点号位置和大小写。一个比较稳的做法是定义常量或统一的 context 组装工具,避免在 Service 里手工写字符串 key。
5. 规则不生效时,按这条链路排查
5.1 第一层:解析阶段报错
规则不生效最早出现的问题往往不是执行结果不对,而是应用启动时解析规则文本失败。典型的日志是:
Exception in thread "main" com.example.lemma.RuleParseException: 条件只支持 < <= > >= 比较操作符,实际是: =这个现象说明规则文本里写了不支持的运算符。比如想表达age == 18,但最小 DSL 里没有定义==,词法分析器会把=解析成赋值的ASSIGN,语法分析随后报错。
排查路径:
- 打印完整规则文本,确认不是文件编码导致的不可见字符。
- 使用 Lexer 单独分词,看看每个单词被识别成什么 Token。
- 对照规则文法检查关键字和运算符顺序。
- 解析错误一般发生在启动阶段,修复后重新启动。
注意:规则引擎的解析错误应该单独捕获并转成可读的规则错误,不要直接用原始异常把整个服务启动失败。生产系统至少要记录出错规则的行号和文本片段。
5.2 第二层:条件字段映射不到输入
规则文本解析成功,但执行时抛字段缺失或条件字段缺失,通常是 context 组装和规则字段名不一致。
常见原因是大小写差异,比如规则里写customer.Age,context 里放的是customer.age。另一个原因是字段路径被误拆,比如规则写order.totalAmount,但词法分析器允许点号在标识符中,所以order.totalAmount是一个 key;如果 context 里只放了totalAmount,自然匹配不上。
排查方式是在执行前打印 context 的全部 key,和规则文本字段做一次 diff。生产环境建议在规则编译阶段就校验字段引用,规则文本运行前拿到字段清单,提前发现缺失。
5.3 第三层:规则执行顺序与优先级不明确
执行结果不是业务预期时,最多的情况出在“多条规则同时命中、结果不知道怎么合并”。第 3 节的最小实现是顺序执行、后写覆盖先写,所以文本顺序会影响最终结果。
排查顺序:
- 列出所有满足当前输入条件的规则。
- 确认每条规则命中的输出字段是不是同一个。
- 确认业务期望是取最大、最小、叠加还是互斥。
- 如果规则引擎不承担合并策略,业务代码里就要显式处理。
如果规则量继续增加,建议给规则增加priority字段,在执行器里先按优先级排序,再决定是否继续执行后续规则。这个改动比要求所有规则编写者理解“文本顺序”要可靠。不要用“调整规则文件顺序”来修复结果错误,那只是换了一种同样脆弱的写法。
5.4 第四层:规则文件没有被加载
启动成功、执行也成功,但规则没有产生任何效果,要怀疑规则文件没有被正确加载。常见表现是改了discount.rule,但服务运行结果没有变化。
检查项:
- 规则文件是否还在 classpath 下,
target/classes/rules/discount.rule是否存在。 - 修改规则文件后是否重新构建了 jar 或 classes。
- 使用的路径是
classpath:还是普通文件系统路径。 - 多个环境是否使用不同规则文件,配置项是否被覆盖。
- 缓存是否使旧规则常驻内存。
生产环境最容易遇到的是缓存问题。规则解析结果被缓存后,即使规则文件更新,内存里的 AST 不会自动刷新。解决方式是引入一个简单的版本号机制,规则文件变更时触发缓存重建。
5.5 排查清单
| 现象 | 可能原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 启动时解析异常 | 规则文本语法错误 | 打印 Token 序列 | 修复规则文法,补充解析错误行号 |
| 执行时字段缺失 | context key 与规则字段不一致 | 打印 context 所有 key | 统一 key 常量,编译期校验字段 |
| 结果不稳定 | 规则顺序或合并策略不一致 | 列出命中规则 | 引入 priority,明确合并策略 |
| 规则变更不生效 | 缓存或文件未重新加载 | 检查缓存版本和 classpath 文件 | 加版本号,启动时打印加载状态 |
| 数字比较结果错误 | 浮点数直接比较 | 打印参与比较的数值 | 明确小数取值范围,必要时使用 BigDecimal |
6. 从演示走向生产:声明式规则引擎的落地建议
6.1 不要在服务代码里拼规则文本
最小实现为了演示方便,把规则文本写在 Java 字符串里。真实项目不要这样做。规则文本一旦进入 Java 代码,就失去了“外部化”的意义,改规则仍然要走发版流程。
生产环境至少要让规则文件位于应用外部:
- 使用挂载卷或配置中心存放规则文件。
- 启动时检查规则文件是否存在,解析失败要快速失败。
- 每次修改规则文件,记录版本号和修改人。
- 重大规则变更前,用历史输入输出做回归验证。
如果团队已经引入了配置中心,规则文本可以放入配置中心。配置中心的好处是变更可追溯、可以按环境隔离,也方便灰度。但要注意,规则文本通常比普通配置长,必须确保配置中心的长度限制和发布流程能覆盖。
6.2 给每条规则增加元数据
第 2 节里的最小 DSL 没有版本、生效时间和责任人信息。生产环境建议在规则模型里补充:
{ "ruleId": "R001", "priority": 10, "effectiveFrom": "2025-01-01", "owner": "marketing-team", "condition": "customer.age < 18", "action": "discount = 0.2" }用 JSON 而不是裸文本管理元数据,更便于和配置中心、数据库对接。规则引擎解析时,可以先读取 JSON 结构,再把condition字段交给词法分析器解析。这样既能保留声明式规则文本的可读性,又能支持优先级、时效性、责任归属这些工程属性。
6.3 决策日志要记录输入、规则版本和输出
规则引擎最大的优势是决策逻辑集中,最大的风险也是“规则看不懂为什么生效”。生产环境必须打印决策日志,至少包含:
- 当前规则文件版本。
- 每次执行时的完整输入 context。
- 命中的规则 ID 和文本。
- 最终输出。
- 异常时的规则片段和错误信息。
示例日志格式:
decision | ruleVersion=v1.2 | ruleId=R003 | input={customer.age=16, order.totalAmount=6000} | output={discount=0.25}这个日志在业务排查时非常关键。运营问“为什么这个订单打了八五折而不是七五折”,如果规则日志记录了命中链路,可以很快定位到是哪条规则、什么条件、什么优先级造成的结果。
6.4 性能:缓存 AST,避免每次请求都解析
规则文本解析是相对昂贵的操作。第 3 节的execute方法每次调用都会执行Lexer.tokenize和Parser.parseRules,在演示代码里没问题,在高频请求里会造成大量 CPU 浪费。
生产建议:
- 服务启动时解析一次,把
List<Rule>缓存在内存中。 - 规则变更时重新解析并更新缓存,而不是每次调用都解析。
- 如果规则数量极大,超过几千条,条件匹配可以预先建立索引,比如按条件左值分组。
- 不要把规则文件的 IO 放在请求链路上,文件读取只发生在加载或刷新时。
如果规则集超过一个 JVM 可以轻松承载的范围,或者需要可视化编辑、多人协作、规则测试回放,这时应该评估成熟规则引擎。Drools 是功能完整但学习成本高的方案,Easy Rules 是轻量的 Java 规则引擎。Lemma 这类声明式语言的优势在于语法简洁、轻量、语义清晰,适合规则数量不夸张但变更频繁的中小型业务场景。选择时不要只看功能列表,要评估团队是否有能力维护规则语言本身的解析、测试和发布链路。
6.5 发布前检查清单
任何规则变更上线前,都可以使用下面的清单:
| 检查项 | 说明 |
|---|---|
| 规则语法合法 | 使用解析器预检,不能等到应用启动报错 |
| 字段引用完整 | context key 和规则字段一一核对 |
| 冲突策略明确 | 同时命中的规则谁生效,文本里或代码里有明确依据 |
| 历史回归通过 | 用过去 N 组典型输入输出跑一遍,确认结果没被无意改变 |
| 决策日志覆盖 | 新规则能记录命中状态和最终输出 |
| 回滚方案就绪 | 规则版本可以快速回退,规则文件有备份 |
| 缓存刷新确认 | 发布规则后,确认内存中的规则 AST 已更新 |
| 责任人可追溯 | 每条新增规则都知道是谁在什么时间改的 |
6.6 给新手的学习路径
如果想深入声明式业务规则语言,建议按这个顺序练习:
- 先不要把规则引擎想得太复杂,用 if-else 写一套业务规则,体会命令式实现的痛点。
- 按照本文的最小 DSL,自己实现一遍 Lexer 和 Parser,掌握 Token 化、文法、AST 的基本概念。
- 扩展语法,加入
==、!=、&&、||和priority字段。 - 接入 Spring Boot,让规则从外部文件或数据库加载。
- 加入决策日志、版本管理和缓存刷新。
- 对比 Easy Rules、Drools 的设计,找一款可视化规则平台看它的规则编辑界面,理解语言设计如何影响产品体验。
声明式规则语言值得认真学一遍,核心并不是用了多深奥的编译技术,而是把“决策表达成数据”的思维。掌握了这一点,再去看 Lemma、Drools 或其他规则引擎,都不会觉得它们神秘。实际项目里最该记住的仍然是最朴素的原则:规则要可读、可验证、可追溯,让业务人员和技术人员面对同一份规则文本时,能讨论同一个问题。