11 mock-device 上篇:脱离 Spring 之后,DDD 长什么样
前几篇讲的全是 backend 这个 Spring Boot 工程,所有 DDD 原则都在 Spring 容器下落地。本篇换个mock-device,纯 Java实现、不依赖 Spring 容器,用 DDD 来做java版的模拟设备、和LwM2M网关。读完本篇,你会看到1. DDD 的核心原则一条都没少,四层、端口、聚合、值对象、依赖倒置,每条都有;2. Spring 替你做的那些事,全都得自己写,依赖注入、生命周期、配置加载、事务边界都自己来管理。另外,本篇也算是领域驱动设计的标准教学篇,所以值得大家阅读。
目录
四层目录
依赖注入靠手工
端口与适配器
配置、生命周期、日志——这些”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 的”端口”概念有两种放法:
- 放在 application 层,表达”应用服务需要外部协助”;
- 放在 domain 层,表达”领域内部依赖外部能力”。
mock-device 选了第一种。理由是MQTT 是个传输细节,领域层不应该知道有 MQTT。MockTelemetryPayload 这个领域对象本身没有”能不能发消息”的方法,”发消息”是 application 层把领域对象交给基础设施的过程。
如果是 DeviceRepository 这种真正的领域抽象(领域层需要持久化),就该放在 domain/repository/ 包里。
适配器在 infrastructure,连同其他三方库依赖一起
PahoMqttMessagePublisher 在 infrastructure/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 |