文章

03 产品模型 vs 物模型

03 产品模型 vs 物模型

本文从 device 上下文出发,讲清”产品模型”和”Device”这两个聚合根各自的职责与联系,以及它们怎么通过ThingModel、ThingPropertySource等子模型把内部结构组合在一起。另外,本篇也是实践DDD的核心,阐述了领域对象的划分方法,读者可以参考文中的做法自行尝试。

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

目录

四个核心概念

产品模型 vs 物模型

ProductModel 聚合根

ThingPropertySource的作用

Device 和 ProductModel 是两个聚合,不是包含关系

四个核心概念

device 上下文里有四个互相嵌套的概念,先统一定义:

概念定义在本项目里
产品模型 ProductModel一类设备的”主数据 + 能力声明”,聚合根ProductModel record
物模型 ThingModel同类物理设备的平台抽象契约,属性 / 事件 / 服务ThingModel record
遥测 Schema设备上报的指标怎么解析、转换、校验TelemetrySchema
影子 Schema设备的 期望态/上报态 文档结构ShadowSchema

四者关系:

1
2
3
4
5
6
7
8
9
ProductModel (聚合根)
├── productCode / productName       ← 主数据
├── capabilities[]                  ← 能力声明
├── telemetrySchema                 ← 子模型 1
├── shadowSchema                    ← 子模型 2
└── thingModel                      ← 子模型 3
        ├── properties[]    →ref capability + source
        ├── events[]        →ref capability
        └── services[]      →ref capability

产品模型 vs 物模型

产品模型回答”这个产品是什么”

  • 产品身份:productCodeproductName
  • 产品能力清单:capabilities,列出这类设备有哪些”业务能力”(温度、湿度、空气质量、远程重启……)
  • 产品的子模型:遥测 schema、影子 schema、物模型

它是类型的元数据,回答”我这台设备是什么类别、能提供什么”。

物模型回答”这个产品能怎么交互”

  • 属性(properties):能读什么 / 能写什么
  • 事件(events):能上报什么异常或状态变化
  • 服务(services):能被调用什么动作

为什么不能合并成一个?因为它们回答的问题不在同一个抽象层:

  • 能力声明(产品模型管)是”有什么”——这台设备有”温度采集”这个能力
  • 属性 / 事件 / 服务(物模型管)是”怎么用”——这个温度能力具体怎么读(是从遥测拿、还是从影子拿)、单位是什么、能不能写

另外这两者与平台的关系,可以用如下示例表示:

1
2
3
4
5
6
7
8
9
10
11
12
IoT平台
├── 产品A:智能空调   ← 物模型A(温度、风速、模式...)
│   ├── 设备 ac-001
│   ├── 设备 ac-002
│   └── 设备 ac-003
│
├── 产品B:智能门锁   ← 物模型B(锁状态、开锁记录、电量...)
│   ├── 设备 lock-001
│   └── 设备 lock-002
│
└── 产品C:温度传感器 ← 物模型C(温度、湿度...)
    └── 设备 sensor-001

ProductModel 聚合根

DDD 里聚合根的核心职责是封装内部结构、守护业务不变量,作为聚合对外交互和事务一致性的唯一边界。ProductModel 把能力清单、遥测 schema、影子 schema、物模型这四个整合在一起:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public ProductModel {
    Objects.requireNonNull(productCode, "productCode must not be null");
    // ...

    // 不变量 1:能力不能重复
    Set<CapabilityCode> declaredCapabilities = new HashSet<>(capabilities);
    if (declaredCapabilities.size() != capabilities.size()) {
        throw new BaseDomainException("capabilities must not contain duplicate capability");
    }

    // 不变量 2:遥测指标必须在能力清单里声明过
    boolean containsUndeclaredMetric = telemetrySchema.metricDefinitions().stream()
            .map(TelemetryMetricDefinition::capabilityCode)
            .anyMatch(metricCode -> !declaredCapabilities.contains(metricCode));
    if (containsUndeclaredMetric) {
        throw new BaseDomainException("telemetry metric must be declared in product capabilities");
    }

    // 不变量 3:派生指标也必须在能力清单里
    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");
    }

    // 不变量 4:物模型属性 / 事件 / 服务必须和能力 + 子模型对得上
    validateThingModel(thingModel, declaredCapabilities, telemetrySchema, shadowSchema);
}

validateThingModel 又拆出三层校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static void validateThingModel(
        ThingModel thingModel,
        Set<CapabilityCode> declaredCapabilities,
        TelemetrySchema telemetrySchema,
        ShadowSchema shadowSchema) {
    // 物模型属性:必须在能力清单里 + 来源必须和遥测/影子模型对得上
    for (ThingPropertyDefinition property : thingModel.properties()) {
        if (!declaredCapabilities.contains(property.capabilityCode())) {
            throw new BaseDomainException(
                    "thing property must be declared in product capabilities: " + ...);
        }
        if (!matchesPropertySource(property, telemetrySchema, shadowSchema)) {
            throw new BaseDomainException(
                    "thing property source does not match product model: " + ...);
        }
    }
    // 物模型事件:输出能力必须在能力清单里
    // 物模型服务:输入能力必须在能力清单里
    // ...
}

聚合根真正的价值就是让”业务一致性”在数据层面就被保证,而不是依赖调用方按规矩使用

ThingPropertySource的作用

物模型的每个属性都必须能回答”我的数据从哪来”。一个属性如果声明来自遥测却找不到对应指标,这个产品模型就根本不该被创建出来。ThingPropertyDefinition 有一个字段叫 source,类型是 ThingPropertySource 枚举:

1
2
3
4
5
6
public enum ThingPropertySource {
    TELEMETRY,         // 遥测
    DERIVED,           // 派生
    SHADOW_REPORTED,   // 影子上报
    SHADOW_DESIRED     // 影子期望
}

这四个值定义了物模型属性最关键的问题这个属性的数据从哪来、能不能写。本项目把”数据来源”明确编码进了模型,并且在 ProductModel 构造器里强制校验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static boolean matchesPropertySource(
        ThingPropertyDefinition property,
        TelemetrySchema telemetrySchema,
        ShadowSchema shadowSchema) {
    return switch (property.source()) {
        case TELEMETRY -> property.accessMode() == ThingPropertyAccessMode.READ_ONLY
                && telemetrySchema.metricDefinitions().stream()
                        .map(TelemetryMetricDefinition::capabilityCode)
                        .anyMatch(property.capabilityCode()::equals);
        case DERIVED -> property.accessMode() == ThingPropertyAccessMode.READ_ONLY
                && telemetrySchema.derivedMetricDefinitions().stream()
                        .map(DerivedMetricDefinition::capabilityCode)
                        .anyMatch(property.capabilityCode()::equals);
        case SHADOW_REPORTED -> property.accessMode() == ThingPropertyAccessMode.READ_ONLY
                && shadowSchema.supportsReportedField(property.capabilityCode().value());
        case SHADOW_DESIRED -> property.accessMode() != ThingPropertyAccessMode.READ_ONLY
                && shadowSchema.supportsDesiredField(property.capabilityCode().value());
    };
}

这段 switch 把四种来源的合法性约束分别表达出来:

  • TELEMETRY 来源 → 必须只读 + 必须在遥测指标定义里存在
  • DERIVED 来源 → 必须只读 + 必须在派生指标定义里存在
  • SHADOW_REPORTED 来源 → 必须只读 + 必须在影子 schema 的上报字段里
  • SHADOW_DESIRED 来源 → 必须可写 + 必须在影子 schema 的期望字段里

这种”用枚举 + switch 表达领域规则”的写法是让规则封闭的一种常用设计模式,所有合法组合都列在 switch 里,编译器会强制 exhaustive 检查(Java 17+ 的 sealed switch)。新增一种来源类型时,编译器立刻提醒你”matchesPropertySource 里还有一个分支没加”,规则不会被遗漏。

Device 和 ProductModel 是两个聚合,不是包含关系

有些IoT项目设计会把 ProductModel 嵌套进 Device,让设备直接持有它的产品定义。本项目里 DeviceProductModel 是两个独立聚合,靠 ProductCode 引用:

1
2
3
4
5
6
7
8
9
10
public record Device(
        Long id,
        DeviceCode deviceCode,
        ProductCode productCode,           // ← 引用产品,不内嵌
        String deviceName,
        DeviceGroupCode groupCode,
        DeviceStatus status,
        DeviceShadow shadow,
        // ... 时间戳
) { ... }

为什么不内嵌?三个理由:

1. 生命周期完全不同,产品模型是”类型”,是相对稳定的元数据;设备是”实例”,每天都有新设备注册、激活、退役。一个产品对应几万台设备,如果内嵌,产品模型改一次,几万台设备都要重建

2. 一致性边界不同,产品模型的不变量是”能力声明和子模型自洽”;设备的不变量是”生命周期状态机合法”,这是两条互不相干的模型;

3. DDD 原则,聚合之间只能 ID 引用,不能对象嵌套。

Device是一个完整的状态聚合,它的状态机是这样的:

1
2
3
REGISTERED → ACTIVATED → MAINTENANCE → ACTIVATED
                ↓                            ↓
              DISABLED                    RETIRED

每次状态切换都通过 Device.activate(...) / startMaintenance(...) / retire(...) 这类领域方法,由 DeviceStatusPolicy 统一守护合法转移。这是另一类聚合根的典型形态——状态机驱动,跟 ProductModel 那种结构一致性驱动完全不同。

设备影子(DeviceShadow)作为 Device 的内部实体存在,而不是独立聚合:

1
2
3
4
5
6
7
8
9
10
public record DeviceShadow(Long version, String document, Instant updatedAt) {

    public static DeviceShadow create(String document, Instant updatedAt) {
        return new DeviceShadow(1L, document, updatedAt);
    }

    public DeviceShadow update(String document, Instant updatedAt) {
        return new DeviceShadow(version + 1, document, updatedAt);
    }
}

它有自己的版本号(每次更新 +1),但被 Device 聚合管理,因为影子的生命周期和设备绑定,没有脱离设备的影子。这是聚合边界的判定标准:只有当一个对象有独立的生命周期和不变量时,它才是独立聚合,否则就该作为内部实体存在。


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

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