规则引擎真正的工程挑战不在执行本身,而在于如何让运营人员在画布上配置的东西、引擎实际执行的东西、出了问题后用来排查的东西,能够对应到同一套业务语义。做到这一点,可编排、可执行、可解释就不再是三个独立的问题。
本文围绕 Mousika 规则引擎平台,沿着规则从配置到执行到归因的数据流,拆解它在每一层做了什么设计决策、以及为什么这么做。
1. 为什么需要规则引擎
1.1 业务规则与代码的矛盾
在实际业务系统中,典型的业务规则如:
- "代理商 A 旗下客户不允许跨开户操作"
- "广告主行业为游戏且日预算低于 1 万时,走人工审核"
- "购票人为残疾人时半价,满足特定条件时免费,否则全价"
这些规则有三个共同特征:变更频繁、逻辑复杂、影响面大。如果硬编码在业务代码中,每次变更都需要经历 开发→测试→上线 的完整周期。
1.2 规则引擎的核心价值
规则引擎解决的本质问题是规则与代码的解耦:
已定义能力范围内的规则变更,可以通过配置、测试和发布完成;新增业务能力则由研发扩展规则表达式或 UDF。
但仅仅把规则从代码里剥离出来并不够。规则一旦变得复杂——包含条件分支、并行判断、跨场景引用——就需要一套专用的编排语言来表达它们之间的组合关系;规则要在发布后及时生效,就需要变更通知和热加载机制;规则出了问题,运营需要知道"这条数据为什么被拦了",就需要执行过程本身可追溯、可归因。Mousika 在这几个方向上做了系统性的设计,后文会逐层展开。
1.3 将配置、执行与排查放在同一条链路上
Mousika 的目标来自具体的协作需求:研发维护可复用的规则与 UDF,运营组合已经定义的能力,在发布前验证,在执行后查看命中路径。选择自研,是为了让领域模型、编排界面和归因结果围绕这些操作共同设计。
因此,文章关心的不只是表达式能否执行,还包括规则如何引用、变更如何生效、结果如何回到配置界面。
2. 整体架构
2.1 模块全景
Mousika 采用多模块 Maven 工程组织,各模块职责明确:
| 模块 | 职责 |
|---|---|
mousika-core |
规则引擎内核:解析、执行、结果分析(约 30 个类) |
mousika-udf-sdk / mousika-udf |
UDF 定义 SDK + 内置系统 UDF(场景调用、RPC 调用等) |
mousika-runtime-base |
运行时公共组件:监听器、转换器、ES 写入 |
mousika-rpc |
中心化 RPC 服务(gRPC/Krpc) |
mousika-brms |
规则管理平台后端(Web UI) |
mousika-sdk |
业务方调用 SDK(Fact 定义 + RPC 接口) |
mousika-local-runtime-sdk |
去中心化本地运行时 SDK |
mousika-consumer |
Kafka 消费者(执行结果对比验证) |
核心依赖栈:ANTLR4(规则语法解析)、Nashorn(JS 表达式执行)、ByteBuddy(动态类生成)、Krpc/gRPC(RPC 通信)、jOOQ(数据库访问)、Kafka/RocketMQ(消息驱动)。
2.2 分层架构
从数据流视角,Mousika 的架构分为四层,每一层都有明确的职责边界:
为什么分四层而不是两层? 关键的设计洞察在于:规则的"编排"和"求值"是两个不同性质的问题。编排(AST 层)处理的是节点之间的逻辑关系(与或非、条件分支、串并行),这是一个树遍历问题;求值(引擎层)处理的是单条规则表达式的计算,这是一个脚本执行问题。将两者分离,使得编排逻辑可以用类型安全的 Java AST 实现,而求值逻辑可以利用 JS 引擎的灵活性——各取所长。
2.3 双模部署
Mousika 支持两种部署模式,业务方根据延迟敏感度和运维复杂度选型:
| 模式 | 实现模块 | 规则加载方式 | 特点 |
|---|---|---|---|
| 中心化 RPC | mousika-rpc |
从数据库直接加载 | 统一部署,规则集中管理,有网络开销 |
| 去中心化 SDK | mousika-local-runtime-sdk |
从中心服务拉取 | 引擎嵌入业务进程,省去调用引擎服务的网络往返 |
两种模式共享同一个 mousika-core 内核。去中心化模式的核心权衡是:用内存换延迟,用复杂度换自主性——每个业务进程持有一份规则副本,省去调用中心引擎的 RPC 开销,但需要处理规则同步和版本一致性;规则中的 UDF 仍可能访问远程服务。
两种模式下 RuleSuite 均为进程内全局单例(volatile 引用),不同业务场景通过 RuleScene 隔离。当多个业务线共享同一实例时,ParNode 的并行执行线程池是全局共享的——高优先级场景可以通过去中心化模式独立部署来获得资源隔离。
3. 核心概念模型
在深入实现之前,先厘清 Mousika 的核心领域概念及其关系:
| 概念 | 类 | 说明 |
|---|---|---|
| RuleSuite | RuleSuite |
规则套件,顶层容器。持有 RuleEvaluator 和所有 RuleScene。全局单例(volatile + 引用替换实现热更新) |
| RuleScene | RuleScene |
规则场景,一个业务场景对应一个 Scene(如"广告审核""客户分配")。包含活跃规则集 + 候选规则集 |
| RuleConfig | RuleConfig |
规则集配置,包含表达式字符串和解析后的 RuleNode AST |
| RuleDefinition | RuleDefinition |
单条规则定义:规则 ID + JS 表达式 + 通过/未通过描述文案 + 类型标识 |
| RuleNode | RuleNode |
规则 AST 节点接口,9 种具体节点类型 |
| RuleContext | RuleContextImpl |
执行上下文,同时是 Visitor、缓存和跨规则状态容器 |
| UDF | @Udf + Functions.* |
用户自定义函数,通过注解或动态 JAR 注册 |
| Fact | 业务 POJO | 业务方提交的待匹配数据对象,在 JS 引擎中绑定为 $ |
它们之间的关系构成了两棵树——配置树(从 UI 节点到 RuleNode AST)和执行树(运行时的 EvalNode 追踪树)。但 Mousika 实际上有四种相互关联的递归结构贯穿全链路:运营人员在画布上编排的 UI Node,经过解析生成的 AST RuleNode,执行过程中构建的 EvalNode(第 5.4 节),以及面向展示的 RuleResult(第 8.3 节)。它们通过规则 ID、表达式和节点转换建立关联:一个 UI 网关可以展开为多个 AST 节点,短路又会使执行轨迹只包含实际访问的分支。可解释性的基础是业务语义的对应,而非形状完全相同。
4. 规则表达式与 AST 解析
4.1 DSL 设计:为什么不直接用 JS
一个自然的问题是:既然底层已经用了 Nashorn JS 引擎,为什么不直接让用户写 JS?
答案是 关注点分离。用户需要表达的是规则之间的编排关系("先执行 A,如果通过再执行 B 和 C"),而不是通用编程逻辑。Mousika 设计了一套领域专用语言(DSL),专门用于规则编排:
| 操作符 | 语义 | 节点类型 | 执行语义 |
|---|---|---|---|
&& |
逻辑与 | AndNode |
短路求值:任一子节点为 false 立即返回 |
|| |
逻辑或 | OrNode |
短路求值:任一子节点为 true 立即返回 |
! |
逻辑非 | NotNode |
对子节点结果取反 |
?: |
条件分支 | CaseNode |
惰性求值:只执行选中分支;该分支不存在时 CaseNode 返回 NaResult |
-> |
串行执行 | SerNode |
全量执行:按顺序执行所有子节点,取最后一个结果 |
=> |
并行执行 | ParNode |
并发执行:线程池并发,任一为 true 则整体为 true |
limit(l,h,...) |
范围匹配 | LimitNode |
命中数在 [l, h] 区间内为 true |
这套 DSL 与 JS 的关系是:DSL 负责"编排"(哪些规则按什么逻辑组合),JS 负责"求值"(单条规则怎么计算)。两者在不同抽象层次工作。
一条实际的规则表达式:
1269->((1242||1243)?1246:(1241?1244:1245))
在配置平台上渲染为可视化流程图,运营人员通过拖拽节点和连线即可生成这种表达式——他们不需要理解语法。
4.2 ANTLR4 解析流程
规则表达式的解析由 NodeBuilder 驱动,内部使用 ANTLR4 完成从文本到 AST 的转换。选择 ANTLR4 而非手写 Recursive Descent Parser 的原因是:语法可能随业务演化(如后来添加了 limit 和 => 操作符),ANTLR4 的 grammar 文件易于扩展。
解析从词法分析、语法分析进入语法树 Visitor,再生成可执行 AST:
NodeBuilder 对解析结果做了缓存(ConcurrentHashMap),同一表达式可复用缓存节点。以下摘录省略计时变量与异常通知:
public static RuleNode build(String expr) {
return nodeCache.computeIfAbsent(expr, ruleExpr -> {
RuleNode node = Antlr4Parser.parse(ruleExpr, defaultGenerator);
ListenerProvider.DEFAULT.onParse(
new RuleEvent(EventType.PARSE_SUCCEED, ruleExpr, node, cost));
return node;
});
}
4.3 复合规则的递归解析与环检测
普通规则的叶子节点(ExprNode)直接引用一个规则 ID。但 Mousika 还支持复合规则(useType=2)——一条规则的表达式本身是另一个规则集的编排。这意味着解析时需要递归展开。
NodeGenerator 处理了这个递归,并通过 Stack 做环检测,防止 A → B → A 的循环依赖:
private RuleNode parseRecursively(String expr, Stack<String> resolved) {
if (compositeRules.containsKey(expr)) {
resolved.push(expr);
try {
return new CompositeNode(expr,
NodeParser.parse(compositeRules.get(expr), s -> {
if (resolved.contains(s))
throw new IllegalStateException(
"circular dependency between [" + expr + "] and [" + s + "]");
return parseRecursively(s, resolved);
}));
} finally {
resolved.pop();
}
}
return new ExprNode(expr);
}
这本质上是一个带回溯的深度优先搜索:Stack<String> resolved 维护当前解析路径,如果即将解析的节点已经在路径上,说明存在环依赖,立即抛出异常。
4.4 RuleNode 接口与 Interpreter 模式
所有节点实现 RuleNode 接口,核心方法只有三个:
public interface RuleNode {
EvalResult eval(RuleContext context);
String expr();
NodeType ruleNodeType();
// Builder 风格的链式组合
default RuleNode and(RuleNode node) { return new AndNode(this, node); }
default RuleNode or(RuleNode node) { return new OrNode(this, node); }
default RuleNode not() { return new NotNode(this); }
default RuleNode next(RuleNode node) { return new SerNode(this, node); }
}
这个设计有两个值得注意的地方。
Interpreter 模式:每个节点自己负责自己的执行逻辑(eval 方法),而不是由一个集中的解释器遍历 AST。程序化扩展节点可从实现接口开始;要让 DSL 和 UI 识别它,还需补充语法、解析和界面映射。
Builder 风格的 default 方法:AST 可以通过编程方式动态构建(ruleA.and(ruleB).or(ruleC)),而不仅限于从表达式解析生成。
4.5 关键节点实现
短路求值与 Visitor 间接层
AndNode 的短路求值实现简洁——遍历子节点,一旦遇到 false 立即返回:
public EvalResult eval(RuleContext context) {
for (RuleNode node : nodes) {
if (!context.visit(node).isMatched()) {
return new EvalResult(expr(), false, ruleNodeType());
}
}
return new EvalResult(expr(), true, ruleNodeType());
}
注意调用的是 context.visit(node) 而非 node.eval(context)——这个间接层是关键,它使得 DefaultNodeVisitor 有机会在每次节点执行时记录执行树(详见 5.4 节),实现了执行逻辑与追踪逻辑的分离。
CaseNode:选中分支与空分支
CaseNode 是最能体现 Mousika 表达力的节点。它先按条件选择分支,再返回该分支的原始结果和判定;选中的分支不存在时,返回无结果标记 NaResult:
public EvalResult eval(RuleContext context) {
boolean succeed = context.visit(condition).isMatched();
RuleNode branch = succeed ? trueCase : falseCase;
if (branch != null) {
EvalResult result = context.visit(branch);
return new EvalResult(expr(), result.getResult(), result.isMatched(), ruleNodeType());
}
return new EvalResult(expr(), NaResult.DEFAULT, ruleNodeType());
}
这里的 null 指没有配置所选分支。另一条未选分支不会被访问,判断它没有执行需要结合配置与轨迹。NaResult 不是独立的布尔真值:此版本的 EvalResult 将这个非空对象判为 true。
SerNode 与 ParNode:两种执行语义
SerNode(->)按顺序执行所有子节点,取最后一个节点的结果。前面的节点视为"前置动作",可以通过 $$(上下文 Map)为后续节点准备数据。
ParNode(=>)将子节点提交到线程池并发执行,结果聚合策略是任一为 true 则整体为 true。ParNode 中最复杂的部分是 ThreadLocal 上下文的迁移——DefaultNodeVisitor 使用 ThreadLocal<EvalNode> 追踪当前执行位置,在并行场景下,每个工作线程需要创建独立的执行树根节点,完成后再将子节点合并回主线程的执行树:
public EvalResult eval(RuleContext context) {
RuleContextImpl ruleContext = (RuleContextImpl) context;
ThreadLocal<EvalNode> currentEval = ruleContext.getCurrentEval();
EvalNode stashEvalNode = currentEval.get(); // 暂存主线程执行节点
CountDownLatch latch = new CountDownLatch(nodes.size());
for (RuleNode node : nodes) {
executor.execute(() -> {
try {
EvalNode root = new EvalNode(null, ruleNodeType());
currentEval.set(root); // 每个线程独立的执行树根
EvalResult result = context.visit(node);
stashEvalNode.getChildren().addAll(root.getChildren()); // 合并回主线程
vector.add(result);
} finally {
currentEval.set(null);
latch.countDown();
}
});
}
currentEval.set(stashEvalNode);
latch.await(timeout, TimeUnit.MILLISECONDS);
// ...聚合结果
}
使用 Vector(线程安全)收集结果,EvalNode.children 也使用 Vector 以保证并发写入安全。
LimitNode:范围匹配
LimitNode 表达的语义是"N 个规则中命中了 M 个,M 是否在 [low, high] 范围内"(high = -1 表示无上限)。这实现了类似"至少满足 2 个条件中的 1 个"或"恰好满足 3 个条件中的 2 个"这样的投票逻辑,为业务规则提供了灵活的组合能力。统计对象是直接子节点,嵌套复合条件整体算一项;当前实现仅在正数上限被超过时提前终止。
5. 执行引擎
5.1 RuleEngine:JS 脚本编译与缓存
RuleEngine 是单条规则的执行核心,基于 Nashorn JavaScript 引擎。选择 JS 引擎而非自研表达式求值器的原因是:JS 天然支持属性链访问($.advertiser.industry)、运算符、字符串操作等,省去了大量的解析和执行逻辑开发。
几个关键的设计细节:
按需编译 + 缓存:注册规则保存源表达式,首次求值时通过 Compilable.compile() 预编译为 CompiledScript,后续执行直接调用 compiledScript.eval(bindings)。编译结果按表达式文本做 key 缓存在 ConcurrentHashMap 中,避免重复解析。
独立绑定表:每次求值创建独立的 Bindings;其中引用的 Fact、上下文与 UDF 对象仍可能被多个分支共享。三种绑定注入:$(Fact 数据对象)、$$(执行上下文 Map)、UDF 函数(命名空间对象)。
private Object doEval(CompiledScript script, Object root, Object context) {
Bindings bindings = engine.createBindings();
bindings.putAll(udfContainer.compileUdf()); // UDF 函数
bindings.put("$", root); // Fact 数据
bindings.put("$$", context); // 上下文 Map
Object result = script.eval(bindings);
return ScriptUtils.convertIntoJavaObject(result);
}
内置规则:true、false、null、nop 是预注册的规则 ID。null 和 nop 返回 NaResult.DEFAULT(通过 Nashorn 的 Java.type() 引用 Java 类),用于表示无结果;串并行节点还会跳过表达式名为 nop 的子节点。
5.2 规则描述的动态插值
每条规则可以配置两个描述文案(分别对应通过/不通过时展示),支持 {$.field} 语法引用 Fact 对象字段。evalRuleDesc() 方法通过正则替换将模板转换为 JS 字符串拼接表达式,然后复用 JS 引擎执行:
// 正则替换: {$.agentId} → "+$.agentId+"
// 最终拼接为 JS 表达式: "代理商【"+$.agentId+"】不允许跨开"
originDesc = "\"" + originDesc.replaceAll("\\{(\\$+\\..+?)\\}", "\\\"+$1+\\\"") + "\"";
return (String) evalExpr(originDesc, root, context);
巧妙之处在于复用了 JS 引擎的求值能力来做模板渲染——不需要引入额外的模板引擎,$ 绑定在 Bindings 中天然可用。
5.3 RuleContextImpl:三位一体的执行上下文
RuleContextImpl 是整个执行流程的核心协调者,它的类定义本身就揭示了多重身份:
public class RuleContextImpl extends LinkedHashMap<String, Object> implements RuleContext
继承 LinkedHashMap:自身就是上下文 Map,以 $$ 的身份暴露给 JS 引擎。规则执行过程中可以通过 $$.put("key", value) 在规则之间传递状态——这是 SerNode 能够实现"前置动作准备数据,后续规则使用数据"模式的基础。
实现 RuleContext:同时承担 Visitor 协调和规则执行两个职责。evalCache 使用 ConcurrentSkipListMap 实现——有序且线程安全。同一上下文中已缓存的规则结果可被后续引用复用。并发计算使用 computeIfAbsent,但该容器不保证映射函数只被调用一次;有副作用的 UDF 仍需自己的幂等控制,见 ConcurrentSkipListMap 文档。
5.4 DefaultNodeVisitor:记录实际访问的节点
第 3 章提到 Mousika 有四种相互关联的递归结构,前两棵(UI Node → AST RuleNode)在配置和解析阶段产生。第三棵——EvalNode 执行树——在这里诞生。DefaultNodeVisitor 在每次 visit() 调用时向执行树加入当前访问的节点,记录了"实际执行了哪些节点,每个节点的结果是什么",是后续归因分析的基础。
public EvalResult visit(RuleNode node) {
EvalNode evalNode = new EvalNode(node.expr(), node.ruleNodeType());
boolean isExprNode = node.getClass() == ExprNode.class;
currentEval.get().getChildren().add(evalNode); // 挂到父节点下
if (!isExprNode) {
evalNode.setParent(currentEval.get());
currentEval.set(evalNode); // 进入子树
}
EvalResult result = node.eval(ruleContext); // 实际执行
if (!isExprNode) {
((RuleContextImpl) ruleContext).getEvalCache().put(node.expr(), result);
currentEval.set(currentEval.get().getParent()); // 回溯到父节点
}
return result;
}
区分 ExprNode 和复合节点是关键:ExprNode(叶子节点)直接挂到当前节点下;复合节点则需要"进入"——将 currentEval 指向自己,子节点会被正确地挂到它下面,执行完成后"回溯"到父节点。这本质上是一个基于 ThreadLocal 的栈帧模拟,用来在扁平的 visit() 调用序列中重建树形结构。
5.5 规则类型与决策表
Mousika 通过 RuleDefinition.useType 支持三种规则类型:普通规则(useType=0,JS 表达式直接注册)、决策表(useType=1,转换为 UDF 复用引擎执行)、复合规则(useType=2,递归解析为 CompositeNode)。
决策表的处理体现了 Mousika 的统一抽象能力——不引入新的执行机制,而是将决策表 JSON 转换为 RuleTableUdf 函数,修改规则表达式为 udf_rule_table_$ID($) 的 UDF 调用形式。RuleTableUdf 接收 Fact 对象,遍历表格每一行检查所有列条件是否匹配——本质上是一个多维度 AND 匹配器。
5.6 将执行错误与条件不满足分开
RuleContextImpl.doEval() 成功时发出 EVAL_SUCCEED,失败时发出 EVAL_FAIL,随后抛出带规则 ID 的 RuleEvalException。异常不会自动变成 false:串行求值中的错误会沿调用链向上传播,与/或节点也不会把异常当作普通条件结果继续计算。
这使排查能够区分两件事:业务数据未满足条件,以及规则根本没有正确算完。字段缺失是否抛错取决于表达式访问方式;远程 UDF 的超时、重试与降级也应按该函数的契约处理。
6. UDF 扩展机制
UDF(User Defined Function)是 Mousika 的能力扩展基座。决策表、外部 RPC 调用、跨场景规则引用——这些看似不同的功能,全部通过 UDF 机制统一实现。
6.1 注册与调用
UDF 通过 @Udf 注解声明分组和名称(如 @Udf(value = "eval", group = "sys.scene")),配合 Functions.FunctionN 函数式接口定义参数签名。JS 引擎中以属性链方式调用:sys.scene.eval(sceneKey, $, $$)。
6.2 UdfDelegate:反射代理与自动类型转换
JS 引擎调用 Java UDF 时,参数可能是 Nashorn 的内部对象,需要转换为 Java 类型。UdfDelegate 先尝试通过 ScriptUtils.convertIntoJavaObject 转换;类型不匹配时,再序列化为 JSON 并反序列化为目标类型。JSON 适合桥接兼容的值对象;需要保持精度、泛型结构或对象身份的类型,应单独约定调用契约。
6.3 UdfContainer:ByteBuddy 动态类生成
UDF 在 JS 引擎中以属性链方式访问(如 sys.scene.eval(...)),但 Nashorn 的 Bindings 只支持扁平的 key-value。UdfContainer 使用 ByteBuddy 在运行时动态生成 Java 类,将嵌套的 UDF 注册表转换为嵌套的 Java 对象:
private static Object compileUdf(String name, Object udf) {
if (!(udf instanceof HashMap)) return udf;
Map<String, Object> udfMap = (Map<String, Object>) udf;
// ByteBuddy 动态生成类,为每个 key 创建 public 字段
Builder<Object> subclass = new ByteBuddy()
.subclass(Object.class).name(name);
for (Entry<String, Object> entry : udfMap.entrySet()) {
subclass = subclass.defineField(entry.getKey(), Object.class, Visibility.PUBLIC);
}
Object instance = subclass.make()
.load(Thread.currentThread().getContextClassLoader())
.getLoaded().newInstance();
// 递归处理嵌套命名空间并赋值
for (Entry<String, Object> entry : udfMap.entrySet()) {
instance.getClass().getField(entry.getKey())
.set(instance, compileUdf(name + "$" + capitalize(entry.getKey()), entry.getValue()));
}
return instance;
}
对于 sys.scene.eval 这样的三层命名空间,ByteBuddy 会生成 UDF$Sys → UDF$Sys$Scene → UdfDelegate 的类层次。Nashorn 引擎通过属性访问依次解引用,最终调用 UdfDelegate.apply()。整个过程对 JS 表达式编写者完全透明。
6.4 动态 JAR 加载:插件化 UDF
SpringUdfLoader 支持在运行时从外部加载 JAR 文件,实现插件化的 UDF 扩展。关键设计是容器隔离 + 父子关系:每个 JAR 有独立的 URLClassLoader 和 ApplicationContext,但以主应用容器为父容器——JAR 中的 UDF 可以注入主应用的 Bean(如 RPC 客户端),但不会污染主应用的 Bean 空间。
卸载时需要做 Spring 缓存清理:关闭子容器、清理 AbstractAutoProxyCreator 的代理缓存、清理 Krpc 的引用缓存、清理 gRPC transport。这些清理工作是防止 ClassLoader 泄漏的关键——如果不清理,被卸载的类仍会被缓存引用,导致 ClassLoader 无法被 GC,最终耗尽 Metaspace。
7. 事件驱动体系
UDF 和规则都支持运行时热更新,这就需要一套事件机制来协调变更的传播、执行的追踪和审计的记录。Mousika 的事件体系覆盖了规则生命周期的三个阶段:解析时、执行时、变更时。
7.1 引擎内事件:观察者模式
ListenerProvider 实现了经典的观察者模式——它自身既是 RuleListener,也是监听器注册中心。所有引擎内事件通过 ListenerProvider.DEFAULT(全局静态单例)扇出到所有注册的监听器。事件触发精确定义在两个位置:NodeBuilder.build() 触发解析事件(PARSE_SUCCEED / PARSE_FAIL),RuleContextImpl.doEval() 触发执行事件(EVAL_SUCCEED / EVAL_FAIL)。
内置监听器包括:RuleEvalLogListener(EVAL_FAIL 和 PARSE_FAIL 时上报错误指标)和 RuleEvalElapsedListener(按 pass / fail / error 三种状态分维度上报耗时指标,用于快速定位某条规则突然变慢的问题)。
7.2 规则变更事件(MQ 驱动热加载)
规则热加载是 Mousika 的核心能力之一。变更通知通过 RocketMQ 广播推送:
变更消息通知运行服务重新加载配置,加载器构建新的 RuleSuite,再替换当前引用。
热加载的线程安全依赖两个机制。volatile 引用替换:RuleSuite.current 是 volatile 的,新实例构造完成后直接替换引用。正在执行的请求仍持有旧实例的引用(旧实例在执行线程的栈帧中仍然可达,GC 不会回收),新请求使用新实例——这是一种无锁的 Copy-on-Write 策略。双重保障:MQ 通知触发刷新,RuleSuiteRefreshTask 默认以 5 分钟 fixed delay 全量加载,减少遗漏通知后长期停留旧版本的情况;刷新失败仍需告警和重试处理。
7.3 执行审计事件(Kafka + ES)
在中心化 RPC 模式下,每次规则执行的完整上下文会异步写入 Kafka。这条数据链支撑了三个下游场景:
执行记录供规则结果对比、离线分析和在线归因查询使用。
灰度验证的机制值得展开:每个 RuleScene 除了 activeRule(线上生效的规则集),还可以挂载 candidateRules(候选规则集)。执行时,活跃规则集在主线程执行返回结果,候选规则集在独立线程池异步执行,两组结果写入 Kafka 后由 EvalCompareService 对比——这允许在不替换线上返回值的情况下比较结果;候选执行仍消耗资源,有外部副作用的 UDF 需要单独处理。
8. 平台能力与可解释性
到目前为止,文章沿着数据流讲完了规则的解析、执行、扩展和运行时事件。但对于运营人员来说,他们面对的不是 AST 和 JS 引擎,而是三个具体问题:怎么配置规则、怎么验证配置对不对、线上出问题了怎么定位原因。这一章从平台视角出发,看 Mousika 如何在配置侧(可视化编排、调试)和输出侧(归因分析、路径渲染)形成闭环——RuleResult 在这里把运行结果交给业务读者。
8.1 可视化规则编排:从流程图到 AST
运营人员不写代码,他们需要的是"画流程图"——在画布上拖拽节点、连接边线,所见即所得。Mousika 的 BRMS 经历了三代 UI 编排方案演进:v1.0 TreeNode(树形嵌套面板,适用于简单 if-else)、v2.0 GraphNode(有向图 + 环检测,支持复杂条件链)、v3.0 GraphNodeV2(结构化流程图,组合条件与动作)。三代方案共享同一个核心接口 UiConfig:
public interface UiConfig {
RuleNode toRule(); // UI 配置 → 引擎可执行的 AST
void valid(); // 配置合法性校验
Set<Long> collectRuleIds(); // 收集引用的规则 ID
}
这个接口是整个平台能力的锚点:无论前端用什么形态展示规则,后端只关心一件事——它能否转换为合法的 RuleNode AST。
v3.0 流程图:语义化节点体系
GraphNodeV2 是当前主力方案,定义了 9 种语义化节点类型,每种对应一种 AST 结构:
UI 条件节点可生成 ExprNode、NotNode 或 CaseNode;串行与并行网关分别生成 SerNode 与 ParNode,排他网关则展开为 CaseNode 链。UI 类型与 AST 类型不是一一对应关系。
每种 UI 节点通过 toRule() 方法递归生成对应的 AST 节点。ExclusiveNode(排他网关)的转换最为巧妙——它将多个互斥条件分支从后向前折叠为嵌套的 CaseNode 链:
// ExclusiveNode.toRule() — 排他网关的递归折叠
// 输入: [条件A → 动作1, 条件B → 动作2, 条件C → 动作3] + 默认动作D
// 输出: A ? 动作1 : (B ? 动作2 : (C ? 动作3 : D))
while (CollectionUtils.isNotEmpty(ruleNodes)) {
CaseNode lastCaseNode = (CaseNode) ruleNodes.removeLast();
if (isHandleLastCondition && defaultNode != null) {
caseNode = new CaseNode(lastCaseNode.getCondition(),
lastCaseNode.getTrueCase(), defaultNode.toRule());
isHandleLastCondition = false;
} else {
caseNode = new CaseNode(lastCaseNode.getCondition(),
lastCaseNode.getTrueCase(), caseNode);
}
}
这意味着运营人员在画布上看到的是"排他网关"(类似 BPMN 中的 XOR Gateway),但引擎实际执行的是嵌套的三元表达式——视觉语义与执行语义的分离。
JSON 序列化与草稿机制
GraphNodeV2 通过 Jackson 的 @JsonTypeInfo + @JsonTypeIdResolver 实现多态 JSON 序列化,前后端通过同一份 JSON 结构进行数据交换。还支持草稿模式(isDraft = true):运营人员可以保存未完成的流程图配置而不触发 AST 转换和校验——这对于复杂规则集的渐进式编排至关重要。feUiConfig 字段存储前端画布的布局信息(节点坐标、连线路径等),确保再次打开时视觉布局不丢失。
8.2 动态调试:实时验证规则逻辑
规则配置完成后,运营人员需要在发布前验证逻辑正确性。Mousika 提供了三层调试能力:
| 调试层级 | 接口 | 特点 |
|---|---|---|
| 规则集级别 | /api/brms/rule/debug/call |
从数据库读取规则集配置,调用引擎 RPC 服务执行,使用引擎服务入口,需选定相同版本与依赖 |
| 实时表达式 | /api/brms/rule/debug/execRuleExpr |
对尚未保存的规则表达式进行实时调试,创建独立引擎实例不影响线上 |
| 参数模板生成 | genRequestModel() |
自动分析规则引用的变量,通过 Protobuf 反射生成带默认值的 JSON 模板 |
实时表达式调试有一个关键细节:它创建了一个全新的 RuleEngine 实例——拥有独立的规则注册状态;若调试 UDF 会调用外部服务,仍需使用演示依赖或控制副作用。
参数模板生成通过类路径扫描加载所有 Protobuf Message 类,构造默认实例,然后用 JS 引擎实际执行变量路径来获取默认值类型——这样可复用引擎的属性访问方式,减少手工构造入参的工作;默认值仍需按业务场景补充。
BRMS 还支持持久化的测试用例,每个用例包含场景参数、Fact 入参和期望执行路径(如 1269->1242->1246)。回归测试时,系统将实际执行路径与期望路径对比,发现不一致则标记失败——使得规则变更的影响范围可以通过自动化测试提前发现。
8.3 归因分析:从执行轨迹生成结果树
规则引擎最常见的运营诉求是:"这条数据为什么被拦截了?"回答这个问题需要完成从执行到解释的最后一步——将第 5.4 节中 DefaultNodeVisitor 构建的 EvalNode 执行树,转换为面向展示的 RuleResult 结果树。
结果类型层次
RuleResult 是面向展示的递归结构,每个节点携带规则 ID、原始返回值、布尔判定、动态描述(如"广告主【张三】行业【游戏】不合规")和子规则列表。RuleContextImpl 将 EvalNode 执行树转换为 RuleResult 结果树时做两件关键的事:关联 evalCache(取出每个节点的实际执行结果)和动态描述插值(将规则描述模板中的 {$.field} 替换为实际值)。
private RuleResult transform(EvalNode node) {
EvalResult result = evalCache.get(node.getExpr());
RuleResult ruleResult = new RuleResult(result, evalDesc(node.getExpr()), node.getNodeType());
for (EvalNode subNode : node.getChildren()) {
ruleResult.getSubRules().add(transform(subNode));
}
return ruleResult;
}
最终的 RuleResult 按已记录的 EvalNode 层次组织,不包含完整 AST 的所有未执行分支。为前端提供了两种展示模式:树形归因(完整的决策路径)和列表归因(通过 deepTraverse 只展示叶子节点,直接看哪些具体规则通过/未通过)。
布尔类型转换
JS 引擎的返回值类型不确定,EvalResult.parseBoolean() 做智能转换:null → false,Boolean 直接取值,Number 大于 0 为 true,String 匹配 yes|true|1 为 true,UdfPredicate 调用自定义 test() 方法。UdfPredicate 接口是一个扩展点——UDF 可以返回"富结果"(携带额外数据),同时仍能作为布尔条件参与 AST 的逻辑判断。
验证对比:多规则集横向分析
ValidationDetail 支持同一份 Fact 数据在多个规则集上的横向对比。运营人员可以选择多个规则集版本(如"当前线上版本"和"待发布版本"),对同一批业务数据进行批量验证,结果支持导出 Excel。这与第 7.3 节的灰度验证机制形成互补:灰度验证是线上流量的自动对比,验证对比是指定数据的手动对比——两者共同保障了规则变更的安全性。
8.4 将运行结果映射回编排界面
归因展示同时读取配置和执行信息:配置说明有哪些分支,轨迹说明实际访问了哪些节点,结果说明已计算节点的返回值与判定。未选择或被短路跳过的分支,不应涂成“执行失败”;空分支的 NaResult 则按其无结果含义展示。
这条链路允许运营沿着同一个规则 ID 查看配置、执行和描述。UI 网关展开、复合规则引用和短路都可能改变结构,映射层负责保留它们的业务对应关系。
9. 设计权衡与工程总结
9.1 关键设计决策
| 决策 | 选择 | 权衡 |
|---|---|---|
| 规则表达式执行 | AST + JS 引擎分层 | Java 节点定义组合语义,脚本表达单条规则,两层分别演进 |
| UDF 注册表 → JS 可访问对象 | ByteBuddy 动态生成类 | JS 以属性链调用 UDF。代价是调试复杂度和 Metaspace 占用 |
| 规则热加载 | volatile 引用替换(CoW) | 请求读取已发布实例,后台构建新实例后替换引用;加载阶段仍有协调锁 |
| 执行结果追踪 | ThreadLocal + 栈帧模拟 | 不侵入 AST 节点。代价是 ParNode 中需手动处理 ThreadLocal 迁移 |
| 类型转换 | JSON 作为中间格式 | 适配兼容的值对象,类型与序列化成本按调用契约处理 |
| 插件 JAR 卸载 | 显式清理 Spring 缓存 | 防止 ClassLoader 泄漏。代价是需要反射访问 Spring / Krpc 内部缓存字段 |
9.2 计算引擎的演进
本文展示的是 Mousika 早期基于 JSR-223 与 Nashorn 的实现。Nashorn 在 JDK 11 被标记为待移除,JDK 15 从 JDK 中移除,见 JEP 372。项目后续演进到 GraalJS;迁移需要适配引擎初始化、Java 对象访问与 UDF 类型桥接,既有编排语义可以继续保留。
历史项目记录给出 GraalJS 迁移后约 6 倍的性能改善。这是当时工作负载下的观察,缺少完整环境和原始测试记录时,不将它当作两种运行时的通用倍率。
9.3 架构模式总结
回顾整个 Mousika 的设计,可以提炼出几个核心的架构模式:
DSL + Interpreter 模式:规则编排语言通过 ANTLR4 解析为 AST,每个节点自解释执行。新节点在执行、DSL 解析和 UI 映射三个入口分别扩展。
Visitor 模式(变体):执行时通过 context.visit(node) 间接调用,而非直接 node.eval(context)。这个间接层让 DefaultNodeVisitor 可以在不修改节点代码的前提下记录执行树。
观察者模式:ListenerProvider 聚合所有 RuleListener,引擎在关键路径上触发事件。监听器承接日志和指标,Visitor 承接执行轨迹,业务节点不必重复实现这些能力。
Copy-on-Write:RuleSuite 的热加载通过构造新实例 + volatile 引用替换实现,正在执行的请求不受影响。
统一抽象:决策表、复合规则、外部 RPC 调用——所有扩展功能都被归约到 UDF 机制,编排节点保持稳定,叶子表达式通过 UDF 接入业务能力。
这些模式共同构成了一个稳定内核 + 灵活扩展的架构——引擎核心代码量不大(mousika-core 约 30 个类),但通过 UDF、事件监听器、规则热加载的扩展点,支撑起了整个业务体系的规则管理需求。
10. 性能特征
规则引擎在生产环境中是请求链路的关键节点,性能直接影响业务 RT。以下保留作者提供的历史生产与前端测试记录,各项采集条件并不相同;它们用于描述项目当时的运行规模,不作为统一环境的基准测试。
10.1 执行引擎性能
| 指标 | 数值 | 说明 |
|---|---|---|
| P99 延迟 | 200ms | 生产环境全量请求的 P99 |
| 1200 QPS 下的平均 RT | 20ms | 中心化部署模式 |
| 日均请求量 | 80 万+ | 场景级请求(单次请求触发一个规则集) |
| 日均规则执行量 | 1200 万+ | 单次请求内多条规则的展开执行 |
| 线上故障 | 0 | 作者所述观察期内无 P0/P1 故障,观察区间未列明 |
10.2 关键性能优化手段
| 优化手段 | 原理 | 效果 |
|---|---|---|
| 预编译 | 编排提前解析,JS 首次求值编译后缓存 | 后续复用编译产物 |
| 中间结果缓存 | ConcurrentSkipListMap 缓存同一请求内已执行规则的结果 |
避免重复规则的重复执行 |
| 重复规则复用 | 规则定义由多个场景引用 | 配置复用不等于跨请求共享执行结果 |
| GraalJS 迁移 | 从 Nashorn 迁移到 GraalJS | 执行性能提升约 6 倍 |
| 去中心化模式 | 规则引擎嵌入业务进程,省去调用中心引擎的 RPC 往返 | RT 降低至个位数毫秒 |
10.3 可视化编辑器性能
| 节点规模 | 渲染时间 | 说明 |
|---|---|---|
| 300 节点 | < 0.06s | 覆盖大部分业务场景(平均节点数 80) |
| 2,000 节点 | < 0.193s | 可流畅编辑 |
| 8,000 节点 | < 1s | 极端复杂场景 |
| 80,000 节点 | 可渲染 | 理论上限,非典型场景 |
11. 插件与运行时的职责边界
Mousika 面向受控的业务规则与可信 UDF。插件加载器用独立类加载器和 Spring 子容器组织生命周期,同时允许插件使用父容器中的基础能力;这种组织方式方便扩展与卸载,并不等于不可信代码的安全沙箱。
规则发布、UDF 上传与调试属于不同权限的操作。部署时应把可配置的规则能力、可调用的 UDF 和可访问的数据范围纳入权限体系;执行 Java 插件与只调整已审核规则参数,也需要不同的授权。独立 Bindings 解决参数绑定组织,不能替代进程与服务层的资源和访问控制。
12. 让发布与运行结果可追踪
事件监听器按规则记录成功、失败和耗时,测试用例用于发布前回归,候选规则集用于结果对比。这些能力让配置变更有检查入口,让运行异常能够定位到规则,而不必只依赖最终的通过/拒绝结果。
旧版并行节点采用线程池与限时等待,然后聚合已经收集的结果。等待超时不会自动停止工作线程,也不会为未完成节点补齐 NaResult;业务侧的完成判定必须包含错误和超时信息,不能把部分返回解释为全部执行成功。超时策略与资源保护由运行服务及 UDF 的调用契约共同确定。
缓存服务于当前上下文中的结果复用。跨请求操作、嵌套场景的新上下文以及有副作用的 UDF,需要分别管理结果生命周期与幂等性。
13. 落地成果
以下为作者提供的历史项目统计与团队估算,反映当时的接入和协作收益;效率比例不是对照实验中的因果估计。
13.1 接入规模
| 指标 | 数据 |
|---|---|
| 接入团队 | 商业化、主站、海外等多个事业部,8 个团队 |
| 业务场景 | 90+ |
| 沉淀规则 | 1400+ |
| 公有 UDF | 28+ |
| 私有 UDF | 160+ |
| 规则复用次数 | 约 11 倍(对不同场景规则重复使用次数的统计) |
13.2 降本增效
| 维度 | 效果 | 说明 |
|---|---|---|
| 沟通成本 | 节约约 1pd / 需求 | 业务规则可视化后一目了然,减少"这条规则到底是什么意思"的反复沟通 |
| 研发提效 | 50% | 部分需求不需要研发参与,运营自助配置规则 |
| 测试提效 | 30% | 回归测试范围收敛到变更的规则,不需要全量回归 |
| Oncall 提效 | 每周节省约 2pd | 线上 Case 产运可通过执行路径自助排查,不需要研发介入 |
13.3 私有化部署
Mousika 支持私有化部署能力,配置端和引擎服务可以独立部署在客户自己的基础设施上。项目历史部署包含国内和海外两个配置端;具体的数据存储与访问范围由部署配置决定。
让规则的变化成为可管理的过程
Mousika 把业务规则从代码中的分支,变成可以引用、组合、验证和解释的对象。DSL 定义组合语义,JS 与 UDF 提供求值能力,Visitor 留下执行路径,管理平台把这些能力交给配置与排查人员。
这套设计让调整规则有了稳定的操作路径,也为后续增加节点类型、参数模板和更完整的交互界面留下了扩展位置。