公司动态

告别if-else:用声明式规则语言构建轻量级业务规则引擎

📅 2026/8/29 14:23:51
告别if-else:用声明式规则语言构建轻量级业务规则引擎
看见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.rulewhen 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 框架也不需要第三方依赖。环境建议如下组件版本建议说明JDK17 及以上使用 record、switch 表达式、文本块Maven3.8 及以上项目构建工具IDEIntelliJ IDEA 或 Eclipse调试解析过程更方便原始材料没有给出 Lemma 项目要求的 Java 版本所以这里选 JDK 17 是一种稳妥实践。实际接入时要先确认目标运行环境的 JDK 版本避免把代码拿到 JDK 8 环境后编译失败。Maven 工程的pom.xml只需要最基本的配置project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdlemma-mini/artifactId version1.0.0/version properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/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 数据结构这里用 recordrecord Token(TokenType type, String text) { }词法分析器核心代码如下class Lexer { private final String src; private int pos; Lexer(String src) { this.src src; } ListToken tokenize() { ListToken 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 ListToken tokens; private int idx; Parser(ListToken tokens) { this.tokens tokens; } ListRule parseRules() { ListRule 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 执行器对输入数据求值执行器负责把规则对象应用到输入上下文上。为了方便演示这里把输入和输出都放在一个MapString, Object里字段路径直接作为 key。class RuleEngine { MapString, Object execute(String rulesText, MapString, Object context) { ListToken tokens new Lexer(rulesText).tokenize(); ListRule rules new Parser(tokens).parseRules(); MapString, 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, MapString, 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, MapString, 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; ; MapString, Object context new HashMap(); context.put(customer.age, 16); context.put(order.totalAmount, 6000); MapString, 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.ymllemma: rules-path: classpath:rules/discount.rule再创建RuleServiceService public class RuleService { private final ListRule rules; public RuleService(Value(${lemma.rules-path}) String rulePath) throws IOException { this.rules loadRules(rulePath); } private ListRule loadRules(String rulePath) throws IOException { Resource resource new ClassPathResource(rulePath.replace(classpath:, )); String text new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8); ListToken tokens new Lexer(text).tokenize(); return new Parser(tokens).parseRules(); } public MapString, Object evaluate(MapString, Object context) { MapString, 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 业务调用示例在一个订单计价服务中使用RuleServiceService public class OrderService { private final RuleService ruleService; public OrderService(RuleService ruleService) { this.ruleService ruleService; } public double calcDiscount(Order order) { MapString, Object context new HashMap(); context.put(customer.age, order.getCustomer().getAge()); context.put(order.totalAmount, order.getTotalAmount()); context.put(order.status, order.getStatus()); MapString, 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是否缓存解析后的规则 ASTtrue关闭后每次执行都重新解析性能下降规则字段命名输入 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.Agecontext 里放的是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 文件加版本号启动时打印加载状态数字比较结果错误浮点数直接比较打印参与比较的数值明确小数取值范围必要时使用 BigDecimal6. 从演示走向生产声明式规则引擎的落地建议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 | ruleVersionv1.2 | ruleIdR003 | input{customer.age16, order.totalAmount6000} | output{discount0.25}这个日志在业务排查时非常关键。运营问“为什么这个订单打了八五折而不是七五折”如果规则日志记录了命中链路可以很快定位到是哪条规则、什么条件、什么优先级造成的结果。6.4 性能缓存 AST避免每次请求都解析规则文本解析是相对昂贵的操作。第 3 节的execute方法每次调用都会执行Lexer.tokenize和Parser.parseRules在演示代码里没问题在高频请求里会造成大量 CPU 浪费。生产建议服务启动时解析一次把ListRule缓存在内存中。规则变更时重新解析并更新缓存而不是每次调用都解析。如果规则数量极大超过几千条条件匹配可以预先建立索引比如按条件左值分组。不要把规则文件的 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 或其他规则引擎都不会觉得它们神秘。实际项目里最该记住的仍然是最朴素的原则规则要可读、可验证、可追溯让业务人员和技术人员面对同一份规则文本时能讨论同一个问题。