从零实现一个可扩展的规则解析引擎 —— 支持 AND/OR 优先级、短路求值与多类型运算符
在日常业务开发中,我们经常需要基于一些“规则”来决定程序的走向。比如:
- 客服机器人 根据用户问题领域和复杂度选择不同的模型;
- 营销系统 根据用户画像匹配不同优惠券;
- 风控引擎 根据请求参数、时间、分值判定是否放行。
这些规则往往以「条件 + 条件组 + 规则」的形式组织,支持 AND/OR 逻辑、字符串匹配、数值比较、日期比较、布尔运算等。
本文将手把手带大家实现一个可扩展的规则解析引擎,最终效果如下:
- 支持
EQUALS / NOT_EQUALS / CONTAINS / IN / GREATER_THAN / LESS_THAN / REGEX
多种运算符; - 支持
STRING / NUMBER / DATE / BOOLEAN
多种数据类型; - 逻辑解析采用“AND 优先于 OR”的优先级;
- 短路求值:在 AND 为 false 或 OR 为 true 时提前结束计算;
- 代码可嵌入 Spring Boot,对接 REST API。
(完整项目代码附在文末,感兴趣的小伙伴可以自取hh~)
一、规则结构设计
我们将规则分为三层:
- 条件(Condition):原子判断,如「问题领域 == 编译原理」。
- 条件组(Group):由多个条件组成,内部支持 AND/OR。
- 规则(Rule):由多个条件组组成,组之间也支持 AND/OR。
前端传参示例(JSON):
{"rule": {"groups": [{"groupsOperator": "","conditions": [{ "field": "问题领域", "operator": "EQUALS", "value": "编译原理", "fieldType": "STRING", "groupOperator": "" },{ "field": "问题类别", "operator": "IN", "value": "静态分析,代码优化", "fieldType": "STRING", "groupOperator": "AND" },{ "field": "问题名称", "operator": "EQUALS", "value": "编译优化", "fieldType": "STRING", "groupOperator": "OR" }]},{"groupsOperator": "AND","conditions": [{ "field": "score", "operator": "GREATER_THAN", "value": "0.9", "fieldType": "NUMBER", "groupOperator": "" }]}]},"context": {"问题领域": "语法分析","问题类别": "LR","问题名称": "编译优化","score": 0.91}
}
对应逻辑表达式:
((问题领域 == 编译原理 AND 问题类别 ∈ {静态分析,代码优化}) OR 问题名称 == 编译优化)
AND (score > 0.9)
二、关键实现思路
1. 工具类(类型解析与容错)
- 字符串转数字、日期、布尔值时采用
Optional
,解析失败直接返回空。 - IN 运算符支持逗号分隔。
- REGEX 支持动态编译正则。
2. 条件求值器
负责判断单条条件是否成立:
- STRING: equals/contains/in/regex
- NUMBER: equals/greater/less/in
- DATE: equals/greater/less/in
- BOOLEAN: equals/not equals
3. 逻辑解析 —— AND 优先于 OR
这是本引擎的核心:
-
错误做法:简单从左到右短路,会把
(A AND B) OR C
错判成(A AND B)
不成立直接 false。 -
正确做法:先按 OR 分块,块内做 AND,再对块结果做 OR。
- 条件层:
cond[0] groupOperator cond[1] ...
- 规则层:
group[0] groupsOperator group[1] ...
- 条件层:
算法逻辑:
// AND 优先级高于 OR : 按照 OR 切块,块内做 AND,块间做 OR
Boolean currentAnd = null; // 当前块的累计结果
boolean anyOrTrue = false; // 之前是否已有 AND 块为 True (用于整体 OR)for (元素 e : 序列) {if (第一个元素) { // 第一个条件,开启 AND 块currentAnd = eval(e);continue;}if (连接符是AND) { // AND:块内与currentAnd = currentAnd && eval(e);} else { // OR:结束上一个 AND 块,合入总结果anyOrTrue = anyOrTrue || Boolean.TRUE.equals(currentAnd);if (anyOrTrue) return true; // OR短路currentAnd = eval(e); // 开新 AND 块}
}
// 合并最后一块
anyOrTrue = anyOrTrue || Boolean.TRUE.equals(currentAnd);
return anyOrTrue;
三、代码示例
以条件组求值为例(AND > OR):
private boolean evalGroup(RuleDetailVO.GroupVO group, Map<String, Object> ctx) {List<RuleDetailVO.CondVO> conds = group.getConditions();if (conds == null || conds.isEmpty()) return false;Boolean currentAnd = null;boolean anyOrTrue = false;for (int i = 0; i < conds.size(); i++) {RuleDetailVO.CondVO c = conds.get(i);boolean curr = condEval.test(c, ctx);String join = c.getGroupOperator();boolean isOrJoin = RuleEvalUtils.isOr(join);if (i == 0) {currentAnd = curr;continue;}if (!isOrJoin) { // ANDcurrentAnd = currentAnd && curr;} else { // ORanyOrTrue = anyOrTrue || Boolean.TRUE.equals(currentAnd);if (anyOrTrue) return true;currentAnd = curr;}}anyOrTrue = anyOrTrue || Boolean.TRUE.equals(currentAnd);return anyOrTrue;
}
规则层求值同理,只是把元素换成 组。
四、测试用例
测试例1:
POST http://localhost:8080/api/rules/match
Content-Type:application/json
{"rule": {"groups": [{"groupsOperator": "","conditions": [{ "field": "问题领域", "operator": "EQUALS", "value": "编译原理", "fieldType": "STRING", "groupOperator": "" },{ "field": "问题类别", "operator": "IN", "value": "静态分析,代码优化", "fieldType": "STRING", "groupOperator": "AND" },{ "field": "问题名称", "operator": "EQUALS", "value": "编译优化", "fieldType": "STRING", "groupOperator": "OR" }]},{"groupsOperator": "AND","conditions": [{ "field": "score", "operator": "GREATER_THAN", "value": "0.9", "fieldType": "NUMBER", "groupOperator": "" }]}]},"context": {"问题领域": "语法分析","问题类别": "LR","问题名称": "编译优化","score": 0.91}
}
响应体:
{"hit": true
}
测试例2:
POST http://localhost:8080/api/rules/match
Content-Type:application/json
{"rule": {"groups": [{"groupsOperator": "","conditions": [{ "field": "问题领域", "operator": "EQUALS", "value": "编译原理", "fieldType": "STRING", "groupOperator": "" },{ "field": "问题类别", "operator": "IN", "value": "静态分析,代码优化", "fieldType": "STRING", "groupOperator": "AND" },{ "field": "问题名称", "operator": "EQUALS", "value": "编译优化", "fieldType": "STRING", "groupOperator": "OR" }]},{"groupsOperator": "AND","conditions": [{ "field": "score", "operator": "GREATER_THAN", "value": "0.9", "fieldType": "NUMBER", "groupOperator": "" }]}]},"context": {"问题领域": "语法分析","问题类别": "LR","问题名称": "编译优化","score": 0.91}
}
响应体:
{"hit": false
}
五、读者可进一步扩展
-
枚举化运算符
- 避免拼写错误,支持自动校验。
-
Bean Validation
- 校验前端传参是否合法(如 fieldType 必须在 ENUM 内)。
-
规则预编译
- 将规则提前编译为
Predicate<Map<String,Object>>
,执行时直接调用,提升性能。
- 将规则提前编译为
-
匹配路径审计
- 记录每条条件的判断结果,用于调试与可视化。
六、总结
本文实现了一个 高可扩展的规则解析引擎,核心亮点:
- 类型多样化:支持字符串、数字、日期、布尔;
- 运算符丰富:equals、contains、in、regex、比较运算;
- 逻辑正确性:严格保证 AND 优先于 OR,避免短路误判;
- 短路优化:提升性能;
- 可扩展性:支持与 Spring Boot 集成、缓存、审计等功能。
通过这种设计,我们可以在实际业务场景中灵活定义规则,既满足 前端可配置化,又保证 后端执行的确定性和高效性。
附项目完整代码:
目录结构如下
rule-engine-springboot/
├─ pom.xml
└─ src/└─ main/├─ java/│ └─ per/│ └─ mjn/│ ├─ RuleEngineApplication.java│ ├─ common/│ │ └─ vo/│ │ └─ RuleDetailVO.java│ ├─ engine/│ │ ├─ ConditionEvaluator.java│ │ ├─ RuleEngine.java│ │ └─ RuleEvalUtils.java│ └─ web/│ ├─ MatchController.java│ └─ dto/│ ├─ MatchRequest.java│ └─ MatchResponse.java└─ resources/└─ application.yml
RuleDetailVO.java
package per.mjn.common.vo;import lombok.Data;import java.util.ArrayList;
import java.util.List;@Data
public class RuleDetailVO {private List<GroupVO> groups = new ArrayList<>();@Datapublic static class GroupVO {private String groupsOperator;private List<CondVO> conditions;}@Datapublic static class CondVO {private String field;private String operator;private String value;private String fieldType;private String groupOperator;}
}
ConditionEvaluator.java
package per.mjn.engine;import per.mjn.common.vo.RuleDetailVO;import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;public class ConditionEvaluator {public boolean test(RuleDetailVO.CondVO cond, Map<String, Object> ctx) {final String field = RuleEvalUtils.normField(cond.getField());final String op = cond.getOperator();final String fieldType = cond.getFieldType();final String expect = cond.getValue();final Object actual = ctx.get(field);if (!RuleEvalUtils.hasText(field) || !RuleEvalUtils.hasText(op)) return false;return switch (fieldType == null ? "STRING" : fieldType.toUpperCase()) {case "NUMBER" -> compareNumber(op, actual, expect);case "DATE" -> compareDate(op, actual, expect);case "BOOLEAN" -> compareBoolean(op, actual, expect);default -> compareString(op, actual, expect);};}private boolean compareString(String op, Object actual, String expect) {String a = RuleEvalUtils.asString(actual);if (a == null || expect == null) return false;return switch (op) {case "EQUALS" -> a.equals(expect);case "NOT_EQUALS" -> !a.equals(expect);case "CONTAINS" -> a.contains(expect);case "IN" -> RuleEvalUtils.parseInList(expect).contains(a);case "REGEX" -> Pattern.compile(expect).matcher(a).find();default -> false;};}private boolean compareNumber(String op, Object actual, String expect) {Optional<BigDecimal> aOpt = RuleEvalUtils.asDecimal(actual);Optional<BigDecimal> eOpt = RuleEvalUtils.asDecimal(expect);if (aOpt.isEmpty() || eOpt.isEmpty()) return false;BigDecimal a = aOpt.get();BigDecimal e = eOpt.get();int cmp = a.compareTo(e);return switch (op) {case "EQUALS" -> cmp == 0;case "NOT_EQUALS" -> cmp != 0;case "GREATER_THAN" -> cmp > 0;case "LESS_THAN" -> cmp < 0;case "IN" -> RuleEvalUtils.parseInList(expect).stream().anyMatch(s -> RuleEvalUtils.asDecimal(s).map(v -> v.compareTo(a) == 0).orElse(false));default -> false;};}private boolean compareBoolean(String op, Object actual, String expect) {Optional<Boolean> aOpt = RuleEvalUtils.asBoolean(actual);Optional<Boolean> eOpt = RuleEvalUtils.asBoolean(expect);if (aOpt.isEmpty() || eOpt.isEmpty()) return false;boolean a = aOpt.get();boolean e = eOpt.get();return switch (op) {case "EQUALS" -> a == e;case "NOT_EQUALS" -> a != e;default -> false;};}private boolean compareDate(String op, Object actual, String expect) {Optional<LocalDate> aOpt = RuleEvalUtils.asDate(actual);Optional<LocalDate> eOpt = RuleEvalUtils.asDate(expect);if (aOpt.isEmpty() || eOpt.isEmpty()) return false;LocalDate a = aOpt.get();LocalDate e = eOpt.get();int cmp = a.compareTo(e);return switch (op) {case "EQUALS" -> cmp == 0;case "NOT_EQUALS" -> cmp != 0;case "GREATER_THAN" -> cmp > 0;case "LESS_THAN" -> cmp < 0;case "IN" -> RuleEvalUtils.parseInList(expect).stream().anyMatch(s -> RuleEvalUtils.asDate(s).map(v -> v.compareTo(a) == 0).orElse(false));default -> false;};}
}
RuleEngine.java
package per.mjn.engine;import per.mjn.common.vo.RuleDetailVO;import java.util.List;
import java.util.Map;public class RuleEngine {private final ConditionEvaluator condEval = new ConditionEvaluator();public boolean matches(RuleDetailVO rule, Map<String, Object> ctx) {List<RuleDetailVO.GroupVO> groups = rule.getGroups();if (groups == null || groups.isEmpty()) return false;Boolean currentAnd = null;boolean anyOrTrue = false;for (int i = 0; i < groups.size(); i++) {RuleDetailVO.GroupVO g = groups.get(i);boolean curr = evalGroup(g, ctx);String join = g.getGroupsOperator();boolean isOrJoin = RuleEvalUtils.isOr(join);if (i == 0) {currentAnd = curr;continue;}if (!isOrJoin) {currentAnd = currentAnd && curr;} else {anyOrTrue = anyOrTrue || currentAnd;if (anyOrTrue) return true;currentAnd = curr;}}anyOrTrue = anyOrTrue || currentAnd;return anyOrTrue;}private boolean evalGroup(RuleDetailVO.GroupVO group, Map<String, Object> ctx) {List<RuleDetailVO.CondVO> conds = group.getConditions();if (conds == null || conds.isEmpty()) return false;// AND 优先级高于 OR : 按照 OR 切块,块内做 AND,块间做 ORBoolean currentAnd = null; // 当前块的累计结果boolean anyOrTrue = false; // 之前是否已有 AND 块为 True (用于整体 OR)for (int i = 0; i < conds.size(); i++) {RuleDetailVO.CondVO c = conds.get(i);boolean curr = condEval.test(c, ctx);String join = c.getGroupOperator(); // 当前条件与“上一条”的连接符boolean isOrJoin = RuleEvalUtils.isOr(join);if (i == 0) { // 第一个条件,开启 AND 块currentAnd = curr;continue;}if (!isOrJoin) { // AND 块内与currentAnd = currentAnd && curr;} else { // OR:结束上一个 AND 块,合入总结果anyOrTrue = anyOrTrue || currentAnd;if (anyOrTrue) return true; // OR 短路currentAnd = curr; // 开新 AND 块}}// 合并最后一个 AND 块anyOrTrue = anyOrTrue || currentAnd;return anyOrTrue;}
}
RuleEvalUtils.java
package per.mjn.engine;import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;public final class RuleEvalUtils {private RuleEvalUtils() {}public static boolean hasText(String s) {return s != null && !s.isBlank();}public static boolean isOr(String op) {return "OR".equalsIgnoreCase(op);}public static String normField(String f) {return f == null ? "" : f.trim();}public static List<String> parseInList(String v) {if (v == null) return List.of();String[] arr = v.split(",");List<String> list = new ArrayList<>(arr.length);for (String s : arr) {if (!s.isBlank()) list.add(s.trim());}return list;}public static String asString(Object o) {return (o == null) ? null : String.valueOf(o);}public static Optional<BigDecimal> asDecimal(Object o) {try {if (o == null) return Optional.empty();if (o instanceof BigDecimal bd) return Optional.of(bd);if (o instanceof Number n) return Optional.of(new BigDecimal(n.toString()));if (o instanceof String s && !s.isBlank()) return Optional.of(new BigDecimal(s.trim()));} catch (Exception ignore) {}return Optional.empty();}public static Optional<Boolean> asBoolean(Object o) {if (o == null) return Optional.empty();if (o instanceof Boolean b) return Optional.of(b);if (o instanceof String s) {String x = s.trim().toLowerCase(Locale.ROOT);if ("true".equals(x) || "1".equals(x) || "yes".equals(x)) return Optional.of(true);if ("false".equals(x) || "0".equals(x) || "no".equals(x)) return Optional.of(false);}return Optional.empty();}public static Optional<LocalDate> asDate(Object o) {if (o == null) return Optional.empty();try {if (o instanceof LocalDate d) return Optional.of(d);if (o instanceof OffsetDateTime odt) return Optional.of(odt.toLocalDate());if (o instanceof String s && !s.isBlank()) {DateTimeFormatter f = DateTimeFormatter.ISO_LOCAL_DATE;return Optional.of(LocalDate.parse(s.trim(), f));}} catch (Exception ignore) {}return Optional.empty();}
}
MatchRequest.java
package per.mjn.web.dto;import jakarta.validation.constraints.NotNull;
import lombok.Data;
import per.mjn.common.vo.RuleDetailVO;
import java.util.Map;@Data
public class MatchRequest {@NotNullprivate RuleDetailVO rule;@NotNullprivate Map<String, Object> context;
}
MatchResponse.java
package per.mjn.web.dto;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;@Data
@NoArgsConstructor
@AllArgsConstructor
public class MatchResponse {private boolean hit;
}
MatchController.java
package per.mjn.web;import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import per.mjn.engine.RuleEngine;
import per.mjn.web.dto.MatchRequest;
import per.mjn.web.dto.MatchResponse;@RestController
@RequestMapping(path = "/api/rules", produces = MediaType.APPLICATION_JSON_VALUE)
public class MatchController {private final RuleEngine ruleEngine;public MatchController() {this.ruleEngine = new RuleEngine();}@PostMapping(path = "/match", consumes = MediaType.APPLICATION_JSON_VALUE)public MatchResponse match(@RequestBody @Valid MatchRequest req) {boolean hit = ruleEngine.matches(req.getRule(), req.getContext());return new MatchResponse(hit);}
}
RuleEngineApplication.java
package per.mjn;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
public class RuleEngineApplication {public static void main(String[] args) {SpringApplication.run(RuleEngineApplication.class, args);}}
application.yml
server:port: 8080
spring:jackson:serialization:WRITE_DATES_AS_TIMESTAMPS: false
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<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 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.3.2</version><relativePath/></parent><groupId>per.mjn</groupId><artifactId>rule-engine-springboot</artifactId><version>0.0.1-SNAPSHOT</version><name>rule-engine-springboot</name><description>rule-engine-springboot</description><properties><java.version>17</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency><dependency><groupId>com.fasterxml.jackson.datatype</groupId><artifactId>jackson-datatype-jsr310</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build></project>