3068 字
约 10 分钟
14
S27-X14 · 规则引擎:从大量 if 到可配置规则

一段代码里出现第十个 if (type == ...) 时,"上规则引擎"的声音就会冒出来。但它不是免费午餐:编译期能查出的错误变成运行期错误,IDE 的跳转与重构换成字符串表达式。判断标准只有一个——规则的变化频率是否已高于代码的发布频率。


1. 什么时候该迁移,什么时候不该

信号 该迁移 不该迁移
变更频率 每月都在改,改一次发一次版 一年动不了一次
谁在改 业务方要求自己配 只有开发看得懂
组合数 条件 × 动作的组合爆炸 三条互不干扰的分支
复杂度 单条规则本身很简单 每条规则都是一段算法
排错需求 要能回答"为什么这单走了这条路" 出问题看日志就够
团队状态 有人能维护引擎与规则表 没人有精力做这套基建

反过来说,这几种情况引入引擎基本是亏的:规则写死且稳定;只有两三条;规则里要查库、调外部接口、算复杂金额;团队没人愿意写回归用例。把简单逻辑复杂化,是规则引擎最常见的失败方式。


2. 四个实现层级与成本对照

if 到规则引擎之间不是一步跳,中间有两个更务实的选择。

层级 做法 改动成本 上线成本 表达能力 适合
硬编码 if 直接写在 Service 改代码 发版 最强 稳定、量少
策略模式 + 配置 每种规则一个实现类,用配置选类 加类 发版(加新规则) 强 规则种类有限、分支清晰
表达式引擎 条件写成字符串存库,运行期求值 改数据 不发版 中 阈值/条件频繁变
完整规则引擎 事实 + 规则 + 会话,支持多规则推理 改规则集 不发版 中偏强(有 DSL) 规则数量大、需链式推理
// 层级 1:硬编码
if (order.getAmount() > 5000 && user.isVip()) { order.setDiscount(0.9); }

// 层级 2:策略 + 配置(配置决定用哪个策略)
order.setDiscount(strategies.get(config.get("discount.strategy")).apply(order));

// 层级 3:条件从库里读出来,运行期求值
// 库里存:amount > 5000 && vip == true    结果列:discount = 0.9

判断方法很朴素:改动是否需要发版。若每次调整只改数据、不发版,第三层就够,不必上第四层。


3. 表达式引擎:SpEL、Aviator、QLExpress

维度 SpEL Aviator QLExpress
来源 Spring 自带 独立开源库 独立开源库(阿里系)
语法 类 Java,功能全 类 JS,轻量 类 Java,支持宏与函数
依赖 已在 Spring Boot 里 需引依赖 需引依赖
性能取向 一般,适合低频 面向高频、可编译 一般,功能丰富
安全默认值 能力大、风险也大 可关掉不必要的特性 可限制函数与变量
适合 已有 Spring 生态、低频规则 高频、条件简单 需要脚本体量与函数库
资料 官方文档详尽 中文文档为主 中文文档为主
// SpEL:解析一次、复用(不要每次 new)
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression("amount > 5000 && vip");
// 生产务必用 SimpleEvaluationContext,限制可写属性与方法调用
EvaluationContext ctx = SimpleEvaluationContext
        .forReadOnlyDataBinding()
        .withRootObject(orderView)
        .build();
boolean hit = Boolean.TRUE.equals(exp.getValue(ctx, Boolean.class));
// Aviator:同样是编译一次、复用
Expression compiled = AviatorEvaluator.compile("amount > 5000 && vip");
Object result = compiled.execute(Map.of("amount", order.getAmount(), "vip", user.isVip()));

表达式的安全边界必须显式设定——表达式注入的本质是"把用户可控的字符串当代码执行":

危险写法 后果 处理
用 StandardEvaluationContext 求值外部输入 可调用任意方法、读系统属性 换只读上下文
表达式里允许 T(...) 类型引用 可构造任意对象 关闭类型引用
缓存无上限 表达式种类多则内存涨 限定表达式来源集合

4. 完整规则引擎:fact、rule、when/then、session

Drools 这套概念值得单独记,因为换成别的引擎,名词不同但结构一样。

概念 含义 对应到业务里
Fact 放进规则引擎工作内存的事实对象 订单、用户、商品
Rule 一条"条件 → 动作" when 金额>5000 then 打折
when(LHS) 条件部分,模式匹配 金额、等级、时间窗
then(RHS) 动作部分,可修改事实或输出 置折扣、加标签、写审计
Session 工作内存 + 待匹配规则 一次评估的上下文
Agenda / 冲突 多条规则都匹配时的执行顺序 谁先谁后、谁覆盖谁
// 典型流程:装载规则 → 插入事实 → 触发 → 取回被修改的事实
KieServices ks = KieServices.Factory.get();
KieContainer kc = ks.getKieClasspathContainer();
KieSession session = kc.newKieSession("order-rules");
try {
    OrderFact fact = new OrderFact(order);
    session.insert(fact);
    session.fireAllRules();
    apply(fact);            // 事实被 RHS 修改过,回写到业务对象
} finally {
    session.dispose();      // 每次评估后销毁,避免状态串味
}

学习成本要摆明:DSL 语法、Session 生命周期、Agenda 冲突策略、规则文件的热更新与版本管理,这四样至少吃掉一个熟手工程师一两周的产出,之后还要有人长期维护。团队三五个人、规则不到几十条时,收益通常盖不住成本。


5. 规则的组织:存库 + 热加载 + 版本化

不管用哪一层实现,"规则怎么存、怎么生效、怎么回滚"这三件事都得想清楚。

要素 做法 注意
存储 规则表(条件、动作、优先级、启用状态) 条件与动作分列存,便于统计
生效 定时拉取 + 版本号比对,或发布时推送 避免每次请求都查库
热加载 内存里保留"规则集 + 版本号",变更后整体替换 用不可变对象整体替换,不要原地改
版本化 每次修改生成新版本,旧版本保留 回滚就是切回旧版本
审计 谁在什么时候改了什么 这是规则引擎相比 if 的最大额外收益
CREATE TABLE rule_config (
  id            BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_type      VARCHAR(64)  NOT NULL,        -- 业务场景
  rule_code     VARCHAR(64)  NOT NULL,        -- 规则标识
  condition_exp VARCHAR(1024) NOT NULL,       -- 条件表达式
  action_json   JSON         NOT NULL,        -- 动作参数
  priority      INT          NOT NULL DEFAULT 100,  -- 越小越先
  enabled       TINYINT      NOT NULL DEFAULT 1,
  version       INT          NOT NULL DEFAULT 1,
  UNIQUE KEY uk_biz_code (biz_type, rule_code, version)
);
// 热加载:整体替换 + 版本号,读侧只用 volatile 引用
@Component
public class RuleRegistry {
    private volatile RuleSet current = RuleSet.empty();

    @Scheduled(fixedDelay = 30_000)
    public void refresh() {
        long latest = ruleDao.maxVersion();
        if (latest == current.version()) return;      // 没变就不动
        current = RuleSet.compile(ruleDao.loadEnabled(latest));  // 原子替换
    }
    public RuleSet get() { return current; }
}

热加载失败必须告警且保留旧规则,而不是把内存清空。清空等于所有规则失效,业务上往往表现为"折扣全没了"或"全部走兜底分支",比报错更难查。


6. 可测试性:一条规则一个用例

规则从代码里搬进数据库后,最大的损失是编译期检查与 IDE 跳转。补回来的唯一手段是测试用例。

层次 测什么 形式
表达式本身 语法、字段是否存在 加载时校验,失败即拒绝发布
单条规则 命中 / 不命中 / 边界值 每条至少三个用例
规则组合 多条同时命中的结果 决策表风格用例
兜底分支 全不命中时的默认行为 必须有用例,最易漏
回归 本次改动是否影响存量规则 全量规则跑一遍的回归套件
@Test
void vip_over_5000_gets_discount() {
    RuleSet set = RuleSet.compile(ruleDao.loadEnabled(1L));
    Assessment r = set.assess(new OrderView(new BigDecimal("5000.01"), true));
    assertEquals(new BigDecimal("0.90"), r.discount());
    // 边界:恰好 5000 不应命中(条件为 > 5000)
    assertNull(set.assess(new OrderView(new BigDecimal("5000"), true)).discount());
}

规则发布前强制跑全量回归,把"改坏了"挡在发布环节而不是等投诉。这一步越自动,允许业务方自助改规则的空间越大。


7. 规则冲突与优先级

多条规则同时命中时的处理方式,决定了整套规则是否可预期。

策略 说明 适用
优先级数值 数值小的先执行 最常见、最直观
首个命中即停 按序求值,命中一条就结束 互斥型规则(分类、路由)
全部执行并合并 都执行,动作累加或取极值 优惠叠加、标签累加
// 首个命中即停,排序在编译期完成,运行期只做短路求值
for (CompiledRule rule : set.ordered()) {
    if (rule.matches(view)) return rule.assess(view);
}
return set.fallback().assess(view);   // 兜底一定存在

发布时校验很有价值:把"两条规则重叠"变成拒绝发布的错误,而不是运行期随机命中一条。


8. 排错:七个常见坑

现象 原因 处理
改了规则没生效 缓存没刷新,版本号比对失效 检查刷新任务与版本号;提供手动刷新入口
规则改了以后大量订单走默认值 加载失败后规则集被清空 加载失败保留旧版本并告警
表达式报"找不到属性" 事实对象字段名与表达式不一致 加载时做一次字段存在性校验
线上出现不该有的折扣 表达式注入或条件可被外部构造 只读上下文 + 白名单字段
规则改坏没人发现 没有回归用例 全量回归纳入发布流程
冲突时行为随机 依赖规则加载顺序 显式 priority,并做重叠校验

9. 边界:这些情况别上规则引擎

场景 更合适 理由
规则稳定、一年不动 硬编码 if 引擎的全部收益为零
规则是复杂算法 代码 DSL 表达力有限,写出来更难懂
只有两三条规定 if 或决策表 引入成本大于收益
要求强一致、不可出错 决策表 + 全量测试 引擎的灵活性反而是风险

一句话:改规则要不要发版,是唯一的硬判据。 答案是"要"就不要上引擎;答案是"不要",才轮到选哪一层。


10. 自测

  1. 判断"是否该上规则引擎"的那条硬判据是什么?
  2. "策略模式 + 配置"和"表达式引擎",差别在改动是否需要发版吗?
  3. SpEL 生产环境为什么建议用只读上下文?
  4. 规则热加载失败时,为什么必须保留旧规则而不是清空?
  5. 多条规则同时命中,有哪几种处理策略,各自适合什么场景?
  6. 规则从代码搬进数据库后,损失了什么,靠什么补回来?
S27-X14 · 规则引擎:从大量 if 到可配置规则
https://www.clxhxhhr.top/posts/5792/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。