文章

04 把派生指标做成领域规则

04 把派生指标做成领域规则

设备上报的指标有两种:原始指标(设备直接上报的,如温度、湿度)和派生指标(基于原始指标算出来的,如热感指数 heatIndex = temperature + humidity * 0.1)。本文聚焦派生指标,它在 telemetry 上下文中可校验、可演进。

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

目录

把派生指标建模成领域规则

计算时机:先派生再校验

跨上下文的能力声明对齐

几个工程细节

把派生指标建模成领域规则

本项目里派生指标是 telemetry 上下文的一个值对象 DerivedMetricDefinition

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public record DerivedMetricDefinition(
        TelemetryMetricName metricName,        // 派生指标名
        List<TelemetryMetricName> sourceMetrics, // 来源指标
        String expression,                     // 计算表达式(SpEL)
        boolean required,                      // 必填还是可选
        String unit) {                         // 单位

    public DerivedMetricDefinition {
        // ... 空值校验
        if (sourceMetrics.isEmpty()) {
            throw new BaseDomainException("sourceMetrics must not be empty");
        }
        if (sourceMetrics.contains(metricName)) {
            throw new BaseDomainException("derived metric must not depend on itself");
        }
        // ...
    }
}

构造器里几条不变量很关键:

  • sourceMetrics 不能为空,派生指标必须有依赖来源,否则它就不是派生的
  • metricName 不能出现在 sourceMetrics,禁止自依赖(heatIndex 不能由 heatIndex 算出来)
  • expressionunit 不能空白,表达式和单位是派生指标的语义核心

这几条约束保证了一个 DerivedMetricDefinition 一旦被创建出来,就一定是逻辑自洽的

派生指标定义被聚集在 TelemetrySchema 里:

1
2
3
4
5
6
public record TelemetrySchema(
        ProductCode productCode,
        List<TelemetryMetricDefinition> metricDefinitions,        // 原始指标
        List<DerivedMetricDefinition> derivedMetricDefinitions) { // 派生指标
    // ...
}

这意味着每个产品有自己独立的派生指标定义,不同设备型号可以有不同公式,运营调整公式时只需要改这个产品的 schema,不影响其他产品。

计算器作为端口

具体的计算逻辑放在 application 层的端口 TelemetryDerivedMetricCalculator

1
2
3
4
5
public interface TelemetryDerivedMetricCalculator {
    TelemetryMetrics apply(
            TelemetryMetrics baseMetrics,
            List<DerivedMetricDefinition> derivedMetricDefinitions);
}

实现是 SpelTelemetryDerivedMetricCalculator,用 Spring 的 SpEL 引擎执行表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public TelemetryMetrics apply(TelemetryMetrics baseMetrics,
                               List<DerivedMetricDefinition> definitions) {
    Map<TelemetryMetricName, BigDecimal> values = new LinkedHashMap<>(baseMetrics.values());
    for (DerivedMetricDefinition definition : definitions) {
        BigDecimal value = evaluate(definition, values);
        if (value != null) {
            values.put(definition.metricName(), value);
        } else if (definition.required()) {
            throw new BaseDomainException("Required derived metric is missing. metric="
                    + definition.metricName().value());
        }
    }
    return new TelemetryMetrics(values);
}

注意这里端口用 SpEL 实现是一个工程选择,不是领域决策,领域只关心”派生指标按定义算出来”,至于用 SpEL、Groovy、还是写死在 Java,是基础设施层的事。

计算时机:先派生再校验

TelemetryIngestApplicationService.record(...) 里这一段顺序值得提一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
public TelemetryEvent record(RecordTelemetryCommand command) {
    TelemetrySchema schema = telemetrySchemaResolver.resolveByDeviceId(command.deviceId());

    // 1. 先派生
    TelemetryMetrics normalizedMetrics = telemetryDerivedMetricCalculator.apply(
            command.metrics(),
            schema.derivedMetricDefinitions());

    // 2. 再 schema 校验(包含派生后的指标)
    schema.validate(normalizedMetrics);

    // 3. 落库 + 发布事件
    // ...
}

可查看 TelemetryIngestApplicationService.java 源码

注意是先派生再校验,不是反过来。这件事的领域含义是:

  • 派生指标也要被 schema 校验,它和原始指标在 schema 里是平等的,必填指标少了照样抛领域异常
  • 校验时看到的是”完整指标集”,如果先校验再派生,原始字段不全时校验直接挂,派生指标根本没机会算
  • 派生失败本身就是数据问题required = true 的派生指标算不出来,意味着这条遥测数据不完整,不应该入库

TelemetrySchema.validate(...) 里同时校验两类指标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void validate(TelemetryMetrics metrics) {
    // 验证基础指标值是否满足要求
    for (TelemetryMetricDefinition metricDefinition : metricDefinitions) {
        metricDefinition.validate(
                metrics.valueOf(metricDefinition.metricName()), productCode);
    }
    // 验证派生指标值是否满足要求
    for (DerivedMetricDefinition derivedMetricDefinition : derivedMetricDefinitions) {
        if (derivedMetricDefinition.required()
                && metrics.valueOf(derivedMetricDefinition.metricName()) == null) {
            throw new BaseDomainException("..."
                    + derivedMetricDefinition.metricName().value());
        }
    }
}

跨上下文的能力声明对齐

派生指标不只属于 telemetry,它还要被 device 上下文的 ProductModel 识别,因为产品模型决定了一类设备”对外暴露哪些能力”,派生指标作为一种能力,必须在 ProductModel 的 capabilities 里声明过。

device 上下文里 ProductModel 构造器有一段约束:

1
2
3
4
5
6
7
8
boolean containsUndeclaredDerivedMetric =
        telemetrySchema.derivedMetricDefinitions().stream()
                .map(DerivedMetricDefinition::capabilityCode)
                .anyMatch(metricCode -> !declaredCapabilities.contains(metricCode));
if (containsUndeclaredDerivedMetric) {
    throw new BaseDomainException(
            "derived telemetry metric must be declared in product capabilities");
}

派生指标的 capability 必须在产品的 capability 清单里出现。IoT 平台对外暴露能力时,派生指标和原始指标对客户/上层应用是一样的,客户订阅 heatIndex 时不关心它是设备直接上报的还是平台算出来的,只关心”这个产品能不能提供这个指标”,能力清单就是这个对外契约。

派生指标横跨 device 和 telemetry 两个上下文的含义:

  • device 上下文管”声明”,这个产品有哪些能力,每种能力的元数据
  • telemetry 上下文管”实现”,派生指标具体怎么算、依赖哪些原始指标

这正好体现了上下文边界:同一个概念在不同上下文里关注不同侧面。device 看 capability 清单,telemetry 看计算公式,二者通过 CapabilityCode 这个值对象对齐。

device 上下文里有自己的 DerivedMetricDefinition,telemetry 上下文里也有自己的 DerivedMetricDefinition,两个同名 record 字段不同。device 侧用 CapabilityCode 描述”能力身份”,telemetry 侧用 TelemetryMetricName 描述”指标名称”。同名不同义是限界上下文最经典的范例,模型不能跨上下文复用,哪怕名字一样。

几个工程细节

1. SpEL 表达式缓存

SpelTelemetryDerivedMetricCalculator 里有一个 ConcurrentMap<String, Expression>

1
2
3
4
5
private final ConcurrentMap<String, Expression> expressionCache = new ConcurrentHashMap<>();

private Expression compile(String expression) {
    return expressionCache.computeIfAbsent(expression, expressionParser::parseExpression);
}

SpEL 的 parseExpression 解析表达式还是比较耗时的,笔者用 computeIfAbsent 做表达式缓存,同一个表达式只解析一次,后续直接复用编译结果。

2. 上下文隔离

SpEL 的 EvaluationContext 默认很宽松,能调任意方法、读任意字段。这在执行外部传入的表达式时是巨大的安全隐患,这里收紧:

1
2
3
private final SimpleEvaluationContext evaluationContext = SimpleEvaluationContext
        .forPropertyAccessors(new MapAccessor())
        .build();

SimpleEvaluationContextStandardEvaluationContext 严格得多:默认禁止方法调用、类型引用等,只允许访问通过 MapAccessor 暴露的属性。这样派生指标的 SpEL 表达式只能做算术运算,没机会触达文件系统等。

3. 必填语义的校验

required 在派生指标里被两次检查:

  • 计算时evaluate(...) 里如果发现某个必填派生指标的 source 缺失,直接抛领域异常;
  • 校验时schema.validate(...) 里如果发现必填派生指标值为 null(无论是没算还是算出来是 null),再抛一次。

看起来有些重复,但语义不同:前者是”算不出”,后者是”算出来但缺失”,对必填指标这种合规性诉求是必要的。

4. 派生指标的执行顺序

apply(...) 里派生指标按 definitions 列表顺序逐个计算,后面的派生可以依赖前面的派生结果

1
2
3
4
5
6
7
8
Map<TelemetryMetricName, BigDecimal> values = new LinkedHashMap<>(baseMetrics.values());
for (DerivedMetricDefinition definition : definitions) {
    BigDecimal value = evaluate(definition, values);
    if (value != null) {
        values.put(definition.metricName(), value);  // 写回 values,后续派生可见
    }
    // ...
}

这表明派生指标是可以链式依赖的,如:heatIndextemperaturehumidity 算出,heatIndexLevel 又可以由 heatIndex 算出,前提是 heatIndexLeveldefinitions 列表里排在 heatIndex 后面。

这种”按声明顺序计算”的策略简单可控,但如果未来出现复杂依赖图,会需要换成DAG这种拓扑结构。当前的设计假设产品模型的派生指标定义是手工编排的,不会有循环依赖。


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

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