文章

11 mock-device 上篇:脱离 Spring 之后,DDD 长什么样

11 mock-device 上篇:脱离 Spring 之后,DDD 长什么样

前几篇讲的全是 backend 这个 Spring Boot 工程,所有 DDD 原则都在 Spring 容器下落地。本篇换个mock-device,纯 Java实现、不依赖 Spring 容器,用 DDD 来做java版的模拟设备、和LwM2M网关。读完本篇,你会看到1. DDD 的核心原则一条都没少,四层、端口、聚合、值对象、依赖倒置,每条都有;2. Spring 替你做的那些事,全都得自己写,依赖注入、生命周期、配置加载、事务边界都自己来管理。另外,本篇也算是领域驱动设计的标准教学篇,所以值得大家阅读。

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

目录

四层目录

依赖注入靠手工

端口与适配器

配置、生命周期、日志——这些”Spring 替你做的事”在这里怎么做

四层目录

mock-device 工程的目录是这样的:

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
30
mock-device/src/main/java/com/example/iotalarmcopilot/mockdevice/
├── MockDeviceApplication.java          ← 启动入口(手工组装)
├── application/                        ← 应用层
│   ├── MockDeviceTelemetryService.java
│   ├── GatewayServerService.java
│   ├── GatewayTelemetryForwarder.java
│   ├── GatewayTelemetryDeduplicator.java
│   └── port/                           ← 端口接口
│       ├── MqttMessagePublisher.java
│       ├── Lwm2mServerHandler.java
│       └── Lwm2mServerRuntime.java
├── domain/                             ← 领域层
│   ├── MockTelemetryPayload.java
│   ├── CommandAckPayload.java
│   ├── SetReportIntervalCommandPayload.java
│   ├── GatewayUplinkMessage.java
│   ├── Lwm2mDeviceSnapshot.java
│   └── InvalidReportIntervalException.java
├── infrastructure/                     ← 基础设施层(端口实现)
│   ├── lwm2m/LeshanLwm2mServer.java
│   └── mqtt/PahoMqttMessagePublisher.java
├── interfaces/                         ← 接口层
│   └── mqtt/
│       ├── AbstractMqttCommandConsumer.java
│       ├── MockDeviceMqttCommandConsumer.java
│       └── GatewayMqttCommandConsumer.java
├── config/                             ← 配置 record
│   ├── MockDeviceConfig.java
│   └── Lwm2mGatewayConfig.java
└── support/MockDeviceLoggers.java

端口/适配器分得一清二楚

注意 application/port/ 这个子目录里面是接口,没有实现:

1
2
3
4
// application/port/MqttMessagePublisher.java
public interface MqttMessagePublisher {
    void publish(String topic, String payload, int qos);
}

实现在 infrastructure/mqtt/

1
2
3
4
// infrastructure/mqtt/PahoMqttMessagePublisher.java
public class PahoMqttMessagePublisher implements MqttMessagePublisher {
    // 用 paho-mqtt 实现
}

这是 DDD 最经典的端口适配器模式

  • application 层定义”我需要什么能力”(接口)
  • infrastructure 层提供”用什么技术实现”(具体类)

如果未来要换到 NATS、AMQP、HTTP,只要在 infrastructure 加一个新实现,application 层一行不动。

domain 层全是 record

1
2
3
4
5
6
public record MockTelemetryPayload(
        String deviceId,
        BigDecimal temperature,
        BigDecimal humidity,
        String ts) {
}

5 个领域对象,全部用 Java record 实现,这样值对象天然不可变,符合 DDD 对值对象的核心要求。

依赖注入靠手工

没有 Spring 容器,所有依赖都得在 main 方法里手工 new 出来再传进去,MockDeviceApplication.main代码:

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
30
31
32
33
34
35
36
37
38
39
40
public static void main(String[] args) {
    MockDeviceConfig mockDeviceConfig = MockDeviceConfig.load();
    Lwm2mGatewayConfig lwm2mGatewayConfig = Lwm2mGatewayConfig.load();

    // 设备 MQTT 发布器
    PahoMqttMessagePublisher mockDeviceMqttMessagePublisher =
            new PahoMqttMessagePublisher(mockDeviceConfig.brokerUrl(), mockDeviceConfig.clientId(), DEVICE_LOGGER);

    // 设备遥测服务
    MockDeviceTelemetryService mockDeviceTelemetryService = new MockDeviceTelemetryService(
            mockDeviceConfig,
            mockDeviceMqttMessagePublisher);

    // 设备命令消费者
    MockDeviceMqttCommandConsumer mockDeviceMqttCommandConsumer = new MockDeviceMqttCommandConsumer(
            new PahoMqttSubscriberClientProvider(...),
            mockDeviceTelemetryService);
    mockDeviceMqttCommandConsumer.subscribe();

    // 网关 MQTT 发布器、转发器、调度器、去重器、Lwm2m server、命令消费者...
    PahoMqttMessagePublisher gatewayMqttMessagePublisher = ...;
    GatewayTelemetryPublishScheduler scheduler = new GatewayTelemetryPublishScheduler(150L);
    GatewayTelemetryDeduplicator deduplicator = new GatewayTelemetryDeduplicator(5000L);
    GatewayTelemetryForwarder forwarder = new GatewayTelemetryForwarder(...);
    Lwm2mServerRuntime lwm2MServerRuntime = new LeshanLwm2mServer(lwm2mGatewayConfig, forwarder);
    GatewayServerService gatewayServerService = new GatewayServerService(...);
    GatewayMqttCommandConsumer gatewayMqttCommandConsumer = new GatewayMqttCommandConsumer(...);
    gatewayMqttCommandConsumer.subscribe();

    // 启动 + 关闭钩子
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        mockDeviceMqttCommandConsumer.close();
        mockDeviceTelemetryService.stop();
        // ...
    }));

    gatewayServerService.start();
    mockDeviceTelemetryService.start();
    mockDeviceTelemetryService.awaitCompletion();
}

这一段代码就是这个工程的组装车间,所有对象按依赖顺序被 new 出来、按构造器参数被注入、最后启动。

没有容器之后才发现,DDD 真正要求的”依赖倒置”和容器无关,它只要求 application 层定义接口、infrastructure 层实现接口。怎么把它们装到一起,是工程问题,不是设计问题。

端口与适配器

前面已经点过 MqttMessagePublisher 端口接口,这一节展开看 application 层怎么使用这个端口。

应用服务持有端口接口,不持有具体实现

MockDeviceTelemetryService 的字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MockDeviceTelemetryService {

    private final MockDeviceConfig config;
    private final MqttMessagePublisher mqttMessagePublisher;   // ← 接口
    private final ScheduledExecutorService scheduler;
    // ...

    public MockDeviceTelemetryService(MockDeviceConfig config, MqttMessagePublisher mqttMessagePublisher) {
        this.config = config;
        this.mqttMessagePublisher = mqttMessagePublisher;       // ← 注入接口
        // ...
    }
}

注意类型MqttMessagePublisher 而不是 PahoMqttMessagePublisher。这是依赖倒置原则的字面落地:高层模块(应用服务)依赖抽象(接口),不依赖具体实现(paho-mqtt 实现)。

端口在 application/port,不在 domain

注意目录布局MqttMessagePublisher 接口放在 application/port/,不在 domain/

DDD 的”端口”概念有两种放法:

  1. 放在 application 层,表达”应用服务需要外部协助”;
  2. 放在 domain 层,表达”领域内部依赖外部能力”。

mock-device 选了第一种。理由是MQTT 是个传输细节,领域层不应该知道有 MQTT。MockTelemetryPayload 这个领域对象本身没有”能不能发消息”的方法,”发消息”是 application 层把领域对象交给基础设施的过程。

如果是 DeviceRepository 这种真正的领域抽象(领域层需要持久化),就该放在 domain/repository/ 包里。

适配器在 infrastructure,连同其他三方库依赖一起

PahoMqttMessagePublisherinfrastructure/mqtt/,import 区里能看到 org.eclipse.paho.client.mqttv3.*,这是基础设施层的正常位置,所有三方技术依赖都应该集中在这一层。

  • domain 层 import:只有 JDK 标准库 ✅
  • application 层 import:JDK + application/port/* 接口 + domain/* 领域对象 ✅(不直接 import infrastructure)
  • infrastructure 层 import:JDK + 三方库(paho、leshan)+ application/port/* 接口(实现接口)+ domain/* 领域对象 ✅
  • interfaces 层 import:JDK + 三方库 + application/*(调应用服务) ✅

这就是 DDD 四层架构的依赖方向

1
2
3
4
5
6
interfaces ──────▶ application ──────▶ domain
                       │
                       │              (port 接口)
                       │                   ▲
                       ▼                   │ implements
                  infrastructure ──────────┘

依赖只能往内层指,不能往外层指——除了 infrastructure 通过实现 application/port 的接口”反向”提供能力。这就是依赖倒置的形象表达。

配置、生命周期、日志——这些”Spring 替你做的事”在这里怎么做

最后看没有 Spring 时怎么手工做。

1. 配置加载

backend 里用的是:

1
2
@ConfigurationProperties(prefix = "iot.command.timeout")
public record CommandTimeoutProperties(...) { }

mock-device 里没有这套,配置是 record + 静态 load 方法:

1
2
3
public record MockDeviceConfig(
        String brokerUrl,
}

读取顺序:系统属性(-D)→ 环境变量 → 默认值,这套读取顺序跟 Spring Boot 的 @Value 默认行为基本一致。

2. 生命周期管理

backend 里用 @PostConstruct / @PreDestroy / SmartLifecycle,mock-device 里全部手写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 启动
gatewayServerService.start();
mockDeviceTelemetryService.start();
mockDeviceTelemetryService.awaitCompletion();

// 关闭:JDK 标准库 ShutdownHook
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    mockDeviceMqttCommandConsumer.close();
    mockDeviceTelemetryService.stop();
    mockDeviceMqttMessagePublisher.close();

    gatewayMqttCommandConsumer.close();
    gatewayServerService.stop();
    gatewayTelemetryForwarder.close();
    gatewayMqttMessagePublisher.close();
}));

Spring 的 SmartLifecycle 会按 phase 自动决定启动/关闭顺序,但phase 是个隐式数字,不读 Spring 源码很难推出哪些 bean 先启动。

Runtime.getRuntime().addShutdownHook 写出来的关闭顺序是显式的,看一眼就知道:先停止接收新输入(close 命令消费者)→ 停止业务(stop 服务)→ 释放资源(close 发布器)。

3. 日志

backend 用 logback-spring.xml + Spring 的环境变量替换,mock-device 直接用标准 logback.xml + slf4j。

但 mock-device 有双 logger 设计:

1
2
3
4
5
6
7
8
public final class MockDeviceLoggers {
    public static Logger deviceLogger() {
        return LoggerFactory.getLogger("mock.device");
    }
    public static Logger gatewayLogger() {
        return LoggerFactory.getLogger("mock.gateway");
    }
}

设备 (mock.device) 和网关 (mock.gateway) 用不同的 logger 名——这样 logback.xml 里可以分别给它们配不同的 appender 和级别,比如设备日志写到 device.log,网关日志写到 gateway.log

4. 没了事务,但 AtomicXxx 可以

backend 里 @Transactional 是事务边界。mock-device 没有数据库,自然也就没有事务,但并发安全仍然是要保护的——MockDeviceTelemetryService 用的是:

1
2
3
4
private final AtomicBoolean finished;
private final AtomicInteger publishedCount;
private final AtomicLong currentIntervalMs;
private final AtomicLong lastPublishedAtMs;

收到下行命令后改 currentIntervalMs,调度器循环里读 currentIntervalMs,两个线程通过 AtomicLong 同步。这不是事务,是更基础的”内存可见性”保证。Spring 的事务能力是覆盖在 JDK 并发原语之上的

总结:剥离 Spring 之后留下了什么

剩下的全是真正属于 DDD 的东西:

留下不在了
四层目录结构@Service @Component 注解
端口/适配器分离@Autowired 自动装配
领域对象不依赖框架@Transactional 声明式事务
应用服务依赖接口而非实现@PostConstruct 生命周期
不可变值对象@ConfigurationProperties
显式启动/关闭顺序SmartLifecycle

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

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