一个规则引擎的可视化方案
背景
近期因新项目需求,可能需要引入规则引擎,因此花费了一些时间对相关技术进行了调研。通过主流搜索引擎检索“规则引擎”关键字,可以找到大量相关资料,其中绝大部分都会提到 Drools、URules、Easy-Rules 等开源项目,部分文章也指出它们采用 Groovy 脚本来实现。通过对项目需求的评估,初步判定 Groovy 脚本方案已能满足实际场景。
然而,在这些资料或方案中,除 URules 外,大部分仅关注框架的性能和使用便捷性,很少探讨如何让业务人员自行定义规则的方案。URules 虽然自带可视化规则管理界面,但其界面样式难以自定义,无法与现有的后台管理界面无缝融合。
经过多次尝试变换关键字检索,最终在 StackOverflow 上找到了一个探讨该问题的帖子。特此将帖子中提到的方案分享给大家,如果你也在研究同样的问题,希望本文能对你有所帮助。在介绍具体方案之前,我们先简单了解一下什么是规则引擎。
什么是规则引擎?
简单来说,规则引擎负责的核心逻辑是:判定某个数据或对象是否满足特定条件,并根据判定结果执行相应的动作。例如:
对于刚刚在网站上完成购物的一个用户(对象),
如果她是 "女性用户 并且(连续登录天数大于 10 天 或者 订单金额大于 200 元)" (条件),
那么系统就自动给该用户发放一张优惠券(动作)。在上述场景中,规则引擎最重要的优势在于实现了“条件”表达式的配置化。如果条件表达式无法配置,程序员就需要在代码中硬编码各种 if...else... 逻辑;若条件组合特别复杂,代码将难以维护。同时,若无法配置化,每次条件的细微变更都需要修改代码并走发布流程,无法快速响应业务需求。
在 Groovy 脚本方案中,上述场景可以这样实现:
- 定义一个 Groovy 脚本:
def validateCondition(args) {
return args.用户性别 == "女性" && (args.连续登录天数 > 10 || args.订单金额 > 200);
}- 通过 Java 提供的
ScriptEngineManager对象去执行:
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<version>3.0.7</version>
</dependency>/*
* @param condition 从数据库中读出来的条件表达式
*/
private Boolean validateCondition(String condition) {
// 实际使用上,ScriptEngineManager 建议定义为单例
ScriptEngineManager engineManager = new ScriptEngineManager();
ScriptEngine engine = engineManager.getEngineByName("groovy");
Map<String, Object> args = new HashMap<>();
args.put("用户性别", "女性");
args.put("连续登录天数", 11);
args.put("订单金额", 220);
engine.eval(condition);
return ((Invocable) engine).invokeFunction("validateCondition", args);
}在上面的 Groovy 脚本中,经常需要变动的部分是 args.用户性别 == "女性" && (args.连续登录天数 > 10 || args.订单金额 > 200) 这个表达式。最简单的方案是在后台界面提供一个文本框,录入整个 Groovy 脚本后保存到数据库。但这种方案存在缺点:表达式的定义有一定门槛。这对程序员来说很简单,但对于未接触过编程的业务人员而言,很容易录入错误的表达式。
这就引出了本文的另一个话题:如何实现 Boolean 表达式的可视化编辑?
如何实现 Boolean 表达式的可视化编辑?
一种方案是前端对指定的表达式进行语法解析,然后渲染成界面元素;业务人员编辑完成后,再将界面元素结构转换回表达式。然而,直接解析语法存在两个缺点:
- 需要考虑的边界条件较多,容易因处理不当导致解析出错。
- 限定了后端可选用的脚本语言。例如,上述方案选用的是 Groovy,其“与”运算符是
&&;假如某天引入了一种性能更好的脚本语言,其“与”运算符定义为and,那么就需要修改大量表达式解析逻辑。
另一种方案,是定义一个数据结构来描述表达式的结构:
{
"all": [
{
"any": [
{ "gt": ["连续登录天数", 10] },
{ "gt": ["订单金额", 200] }
]
},
{ "eq": ["用户性别", "女性"] }
]
}然后,使用递归的方式解析该结构。对于前端开发,可以在递归解析过程中渲染成对应的界面元素;对于后端人员,则可以生成对应的 Boolean 表达式。有了 Boolean 表达式,就可以使用预定的脚本模板生成最终的规则。
// 模板示例
def validateCondition(args) { return $s; }/**
* 动态 Boolean 表达式解析器
*/
public class RuleParser {
private static final Map<String, String> operatorMap = new HashMap<>();
private static final ObjectMapper objectMapper = new ObjectMapper();
static {
operatorMap.put("all", "&&");
operatorMap.put("any", "||");
operatorMap.put("ge", ">=");
operatorMap.put("gt", ">");
operatorMap.put("eq", "==");
operatorMap.put("ne", "!=");
operatorMap.put("le", "<=");
operatorMap.put("lt", "<");
}
/**
* 解析规则字符串,转换成表达式形式
* 示例:
* 输入:
* { "any": [
* { "all": [
* { "ge": ["A", 10] },
* { "eq": ["B", 20] }
* ]},
* { "lt": ["C", 30] },
* { "ne": ["D", 50] }
* ]}
*
* 输出:
* ( A >= 10 && B == 20 ) || ( C < 30 ) || ( D != 50 )
*
* @param rule 规则的 JSON 字符串形式
* @return 返回 Boolean 表达式
* @throws IOException 解析 JSON 字符串异常
*/
public static String parse(String rule) throws IOException {
JsonNode jsonNode = objectMapper.readTree(rule);
return parse(jsonNode);
}
/**
* 解析规则节点,转换成表达式形式
* @param node Jackson Node
* @return 返回 Boolean 表达式
*/
private static String parse(JsonNode node) {
// TODO: 支持变量的"arg."前缀定义
if (node.isObject()) {
Iterator<Map.Entry<String, JsonNode>> it = node.fields();
if (it.hasNext()) {
Map.Entry<String, JsonNode> entry = it.next();
List<String> arrayList = new ArrayList<>();
for (JsonNode jsonNode : entry.getValue()) {
arrayList.add(parse(jsonNode));
}
return "(" + String.join(" " + operatorMap.get(entry.getKey()) + " ", arrayList) + ")";
} else {
// 兼容空节点:例如 {"all": [{}, "eq":{"A","1"}]}
return " 1==1";
}
} else if (node.isValueNode()) {
return node.asText();
}
return "";
}
}结语
以上就是本文要阐述的全部内容。对于这个话题,如果你有这方面的经验或者更好的方案,也请多多指教,谢谢!
完整源码:https://github.com/RongieZeng/rule-parser
说明:文中代码示例基于 Groovy 3.0.7 及 Java 标准脚本引擎接口编写,实际使用时请确保引入 groovy-jsr223 等相关依赖以保证兼容性。 版权声明:本文为原创文章,版权归 戴老师的博客 所有,转载请联系博主获得授权。
本文地址:https://1diff.fun/archives/yi-ge-gui-ze-yin-qing-de-ke-shi-hua-fang-an.html
如果对本文有什么问题或疑问都可以在评论区留言,我看到后会尽量解答。