文章

06 规则的分层设计与执行

06 规则的分层设计与执行

本文从 rule 上下文的代码出发,把规则系统的几个关键分层讲一遍“规则的生命周期、规则的输入模型等”,重点不在表达式怎么写,在它们之间的边界怎么划。最后还介绍一下生产环境中的规则执行层次。

项目地址:https://github.com/Liyuwen85/iot-alarm-copilot

目录

规则系统常见的认知误区

规则的三个生命周期分层

规则的输入模型:TelemetryRuleFacts 是怎么来的

规则评估:从事件到结果的纯函数

生产环境中的规则分层与执行

规则系统常见的认知误区

讲规则系统之前,先列几个工程上常见的认知误区——这一节是”反面教材”,下面四节都是在回应这些误区。

误区 1:规则等于”if-else 加上 SpEL”

把规则简化成”在某个 service 里写 if-else,复杂一点的换成 SpEL 表达式”。这种写法在规则只有几条时没问题,但只要规则数量上去、生命周期变长(草稿 / 上线 / 灰度 / 下线),代码会立刻失控。

误区 2:规则直接用 TelemetryRecordedEvent

规则上下文订阅遥测事件,最直接的写法是把 TelemetryRecordedEvent 直接丢进规则引擎执行。这种写法把”上游事件契约”和”规则输入模型”绑在了一起,上游事件加一个字段,规则代码就要跟着改。

误区 3:规则是无状态的纯逻辑

规则被认为”就是计算”,不应该有状态。但规则有版本、有上线/下线、有归属的。

下面几节分别用项目里的代码对应这四个误区。

规则的三个生命周期分层

回应误区 3,规则不是无状态的纯逻辑,规则定义本身就是有生命周期的领域对象。本项目里 rule 上下文把规则的生命周期切成三层:

1
2
3
4
5
定义层 (RuleDefinition)        ←  谁定义了这条规则、它是什么、它现在能不能跑
       ↓
评估层 (TelemetryRuleMatcher)  ←  把"事实"和"定义"对起来,产生命中结果
       ↓
命中层 (RuleTriggeredResult)   ←  这次评估命中了什么,作为事件对外发布

这三层各自有不同的不变量、不同的可变性、不同的发布周期

  • 定义层——规则定义是聚合根,状态机管它从草稿到下线的全生命周期
  • 评估层——规则评估是无状态的纯计算,可以频繁触发、可以并发、可以重放
  • 命中层——规则命中是不可变的事实,一旦产生就只能被订阅,不能被改

定义层的状态机

RuleDefinition 是一个 record,状态机非常简单——只有三个状态:

1
2
3
4
5
6
7
8
9
public enum RuleStatus {
    DRAFT,      // 草稿:可以改、不会被执行
    ACTIVE,     // 已发布:会被执行
    INACTIVE;   // 已下线:不会被执行、可以重新启用

    public boolean executable() {
        return this == ACTIVE;
    }
}

RuleDefinition 的状态切换方法都返回新对象(record 不可变):

1
2
3
4
5
6
public RuleDefinition publish() {
    RuleStatusPolicy.ensurePublishAllowed(status);
    return new RuleDefinition(id, code, name, RuleStatus.ACTIVE, metricName, threshold, condition);
}
public RuleDefinition disable() { ... }
public RuleDefinition reactivate() { ... }

executable() 是评估层进入定义层的唯一入口

注意 RuleStatus.executable() 这个方法:

1
2
3
public boolean executable() {
    return this == ACTIVE;
}

它在 RuleDefinition.evaluate(...) 的最开始被调用:

1
2
3
4
5
6
public Optional<RuleTriggeredResult> evaluate(...) {
    if (!executable()) {
        return Optional.empty();
    }
    // ... 真正的评估逻辑
}

这一行让”定义层的状态”和”评估层的执行”解耦,评估层不需要关心规则的状态机怎么演进,只需要在每次评估开始时问一句”这条规则当前可执行吗”。

规则的输入模型:TelemetryRuleFacts 是怎么来的

回应误区 2规则不应该直接消费 TelemetryRecordedEvent

TelemetryRecordedEvent 是 telemetry 上下文的契约,rule 上下文直接用它的话,意味着规则代码要理解 telemetry 的事件结构。一旦 telemetry 修改事件契约(加字段、改类型、拆分事件),rule 也要跟着改。这违反了限界上下文最基本的原则,上下文之间的依赖只能走契约,不能走对方的内部模型

本项目用 TelemetryRuleFacts 这个值对象作为规则上下文的输入模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public record TelemetryRuleFacts(
        Long telemetryEventId,
        DeviceId deviceId,
        TelemetryMetrics metrics,
        Instant reportedAt) {

    public static TelemetryRuleFacts fromTelemetryRecorded(
            Long telemetryEventId,
            String deviceId,
            TelemetryMetrics metrics,
            Instant reportedAt) {
        return new TelemetryRuleFacts(
                telemetryEventId,
                new DeviceId(deviceId),
                metrics,
                reportedAt);
    }
}

TelemetryRuleFacts 是 rule 上下文里的一个领域概念,”评估规则所需的事实”。它的字段看起来和 TelemetryRecordedEvent 几乎一样,但语义层不同

  • TelemetryRecordedEvent 是 telemetry 上下文的”事件”,表达”我刚记录了一条遥测”
  • TelemetryRuleFacts 是 rule 上下文的”事实”,规则评估所依赖的输入

rule 上下文里 DeviceId 是一个独立值对象,不是 telemetry 上下文的;事件 id 在 facts 里只是一个 Long,没有事件契约的语义。同名概念在不同上下文里有自己独立的模型,是限界上下文最经典的形态

Facts 还提供了表达式变量的扁平化方法

1
2
3
4
5
6
7
8
9
public Map<String, Object> toExpressionVariables() {
    Map<String, Object> variables = new LinkedHashMap<>();
    variables.put("telemetryEventId", telemetryEventId);
    variables.put("deviceId", deviceId.value());
    variables.putAll(metrics.toFlatMap());
    variables.put("reportedAt", reportedAt);
    variables.put("reportedAtEpochMs", reportedAt.toEpochMilli());
    return variables;
}

这个方法把 facts 里的数据展平成 Map<String, Object>,供 SpEL 表达式使用。规则定义里写的 temperature > 80,能拿到 temperature 这个变量就是因为 facts 把 metrics 摊平了。注意 reportedAtreportedAtEpochMs 同时提供,SpEL 里写时间比较时直接拿毫秒数比 Instant 操作方便。

规则评估:从事件到结果的纯函数

回应误区 1,规则不只是”if-else 加 SpEL”。规则评估在本项目里被建模成一个纯函数,给定规则定义和事实,返回 0 个或 1 个命中结果,不抛异常、不产生副作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Optional<RuleTriggeredResult> evaluate(
        TelemetryRuleFacts facts,
        RuleExpressionEvaluator ruleExpressionEvaluator) {
    if (!executable()) {
        return Optional.empty();
    }
    boolean matched = ruleExpressionEvaluator.evaluate(condition, facts);
    if (!matched) {
        return Optional.empty();
    }
    return Optional.of(new RuleTriggeredResult(
            code,
            facts.telemetryEventId(),
            facts.deviceId(),
            metricName,
            facts.metricValue(metricName),
            threshold,
            facts.reportedAt()));
}

几个关键设计:

1. 返回 Optional<RuleTriggeredResult> 而不是抛异常

规则不命中是正常情况,不是异常。把”不命中”建模成 Optional.empty() 而不是抛异常,让批量评估可以写成简单的 stream 操作:

1
2
3
4
5
List<RuleTriggeredResult> triggeredResults = new ArrayList<>();
for (RuleDefinition rule : rules) {
    rule.evaluate(facts, ruleExpressionEvaluator)
        .ifPresent(triggeredResults::add);
}

2. 表达式引擎是端口,不是实现

RuleExpressionEvaluator 是 domain 层定义的接口:

1
2
3
public interface RuleExpressionEvaluator {
    boolean evaluate(RuleCondition condition, TelemetryRuleFacts facts);
}

具体用 SpEL、用 Drools、还是用自研 DSL,是基础设施层的事。这一点和上一篇里 telemetry 的派生指标计算器是同一个套路,领域只关心”表达式按定义算出 true/false”,引擎想换就换

3. 评估是无状态的

RuleDefinition.evaluate(...) 是一个”看起来像 method 但本质是 function”的方法,它不修改 RuleDefinition 自身、不依赖外部状态、对同样的输入永远返回同样的输出。这种无状态特征让规则评估天然支持:

  • 并发,多个 facts 可以并发评估同一组规则;
  • 重放,同一条 facts 可以重新评估,结果一致;
  • 测试,直接 new RuleDefinition(...).evaluate(facts, evaluator),不需要任何容器。

批量匹配领域服务

多条规则评估的编排放在 TelemetryRuleMatcher(领域服务)里:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TelemetryRuleMatcher {
    public List<RuleTriggeredResult> evaluate(
            List<RuleDefinition> rules,
            TelemetryRuleFacts facts,
            RuleExpressionEvaluator ruleExpressionEvaluator) {
        List<RuleTriggeredResult> triggeredResults = new ArrayList<>();
        for (RuleDefinition rule : rules) {
            rule.evaluate(facts, ruleExpressionEvaluator)
                .ifPresent(triggeredResults::add);
        }
        return List.copyOf(triggeredResults);
    }
}

这是 DDD 里”领域服务”的典型用法,逻辑不属于任何单一聚合,它是聚合之间的协调TelemetryRuleMatcher 不属于某条规则,它属于”评估一批规则”这个领域行为,所以独立成一个无状态服务。

注意 TelemetryRuleMatcher 没有任何字段,也没有 @Service 注解,它在 application service 里直接 new 出来:

1
private final TelemetryRuleMatcher telemetryRuleMatcher = new TelemetryRuleMatcher();

领域服务不需要被 Spring 容器管理。它是纯领域对象,应该能脱离任何框架直接 new。这是 DDD 的小但重要的原则,领域代码不应该依赖容器

三个层次的事件流

回顾整条链路,事件流过三个层次:

1
2
3
4
5
6
7
8
9
TelemetryRecordedEvent  (telemetry 上下文产出)
        ↓
TelemetryRuleFacts      (rule 上下文内部模型)
        ↓
RuleTriggeredResult     (rule 上下文领域结果)
        ↓
RuleTriggeredEvent      (rule 上下文对外契约)
        ↓
AlarmCreatedEvent       (alarm 上下文产出)

注意每一步都是独立的概念,故意没有合并:

  • TelemetryRecordedEventTelemetryRuleFacts:跨上下文翻译,外部事件变成内部事实;
  • RuleTriggeredResultRuleTriggeredEvent:内部领域对象变成对外契约,结构相似但语义层不同;
  • RuleTriggeredEventAlarmCreatedEvent:又一次跨上下文翻译,规则命中变成告警。

每一层翻译都增加了一点工程成本,但换来了每一层都可以独立演化,这也是 DDD 在”事件驱动架构”里最通用的做法之一让事件之间的边界清晰

生产环境中的规则分层与执行

本项目把规则统一放在 backend 的 rule 上下文里执行,但大型IoT生产环境里规则引擎基本都是分层执行的。

三层是常见形态

层级典型位置适合的规则延迟
边缘层设备本身 / 网关数据过滤、本地阈值、断网兜底毫秒级
接入层broker / kafka 流处理 / EMQX 规则引擎简单转换、限流、数据丢弃十毫秒级
后端层业务规则引擎(本项目就是这一层)复杂条件、跨指标、跨设备、需要业务上下文百毫秒级

每一层都有自己的职责:

  • 边缘层做的是网络都没出去就过滤掉,比如温度传感器明显抖动出来的离谱值(-300℃),在网关层就丢,不要把这种垃圾数据送到平台;
  • 接入层做的是数据进入业务系统前的最后一道闸,比如 EMQX 自带的规则引擎可以直接根据 topic 和 payload 做路由、丢弃、改写,不必让 backend 看到;
  • 后端层做的是需要业务上下文才能判断的事”,比如”温度持续超阈值 5 分钟”需要状态、”某客户某型号设备”需要主数据,这些只有 backend 才有。

这样的好处有很多,比如:

1. 流量塌方,海量遥测上报到 backend,规则匹配开销吃光 CPU。边缘和接入层做掉 80% 简单过滤,backend 才能扛住剩下的 20% 复杂规则;

2. 延迟要求,某些规则(如”过流保护”)必须在毫秒级响应,等数据穿过 broker → kafka → backend 几跳之后才判定,已经晚了。这种规则必须在边缘做;

3. 数据合规,某些原始数据(位置、生物特征)按合规要求不能出网关,但又要做规则判断。这种规则只能在边缘层做。

DSL 引擎是另一种分层

除了”按位置分层”,还有”按表达能力分层”:

  • 简单阈值规则(temperature > 80):直接 SpEL / Drools 就够
  • 时间窗口规则(”5 分钟内温度连续超阈”):需要流处理引擎(Flink CEP / Kafka Streams)
  • 复杂业务规则(”故障率 + 客户等级 + SLA 联动”):需要专门 DSL(自研规则语言 + 解释器)

本项目目前只覆盖第一类,因为学习项目阶段够用。生产环境里第二、第三类一定要做的,而且多种引擎并存,一个规则系统里同时有 SpEL、Flink CEP、自研 DSL,每种规则按形态自动路由到对应引擎。


项目地址:https://github.com/Liyuwen85/iot-alarm-copilot

本文由作者按照 CC BY 4.0 进行授权