1 业务背景
在前面章节中,我们说采集保存原始数据(每日设备上线次数),基于原始数据汇总聚合生成每日活跃设备用户数中,有个地方表述有误:当时说是基于AOP+消息队列异步方式由统计服务保存原始数据。其实不是AOP+消息队列异步这种方式,在此更正一下。
那么我们采集存储设备上线次数,是通过什么方式呢???一般我们通过如下的方式:
- 首先设备上线时,会上报上线事件数据到我们的IOT平台
- 然后统计服务基于MQTT订阅平台的设备online事件,基于这个事件数据生成并存储设备每天的上线次数
MQTT订阅设备online事件的代码:
@Slf4j
@Configuration
@RefreshScope
public class MqttConfig {
/**
* 订阅地址
*/
@Value("${broker.url}")
private String url;
/**
* 用户名
*/
@Value("${broker.userName}")
private String userName;
/**
* 密码
*/
@Value("${broker.password}")
private String password;
private final Integer completionTimeout = 3000;
/**
* 订阅的topic集合(每个产品创建一个topic,订阅多个topic集合)
*/
@Value("${product.topics}")
private String[] topics;
@Resource
private DeviceOnlineService deviceOnlineService;
/**
* @description 创建ClientFactory
*/
@Bean
public MqttPahoClientFactory mqttClientFactory() {
DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
MqttConnectOptions options = new MqttConnectOptions();
options.setUserName(userName);
options.setPassword(password.toCharArray());
String[] serverUrls = new String[]{url};
options.setServerURIs(serverUrls);
factory.setConnectionOptions(options);
return factory;
}
/**
* @param
* @return adapter
* 配置订阅,接收消息
*/
@Bean
public MessageProducer inbound() {
String consumerId = "consumerClient" + UUID.randomUUID();
MqttPahoMessageDrivenChannelAdapter adapter = new MqttPahoMessageDrivenChannelAdapter(consumerId, mqttClientFactory(), topics);
adapter.setCompletionTimeout(completionTimeout);
adapter.setConverter(new DefaultPahoMessageConverter());
// 设置服务质量
// 0 最多一次,数据可能丢失;
// 1 至少一次,数据可能重复;
// 2 只有一次,有且只有一次;最耗性能
adapter.setQos(1);
adapter.setOutputChannel(mqttInputChannel());
return adapter;
}
/**
* @param
* @return adapter
* inputChannel指定用于消费消息的channel
*/
@Bean
public MessageChannel mqttInputChannel() {
return new DirectChannel();
}
/**
* @description 消费消息,处理设备上下线
*/
@Bean
@ServiceActivator(inputChannel = "mqttInputChannel")
public MessageHandler handler() {
return message -> {
String payload = message.getPayload().toString();
JSONObject msgPayload = JSON.parseObject(payload);
String type = msgPayload.getString("type");
try {
switch (type) {
case "online":
// 处理设备上线的操作
log.info("处理设备上线的操作,msgPayload: {}", msgPayload);
deviceOnlineService.onlineHandle(msgPayload);
break;
case "offline":
// 处理设备下线的操作
log.info("处理设备下线的操作,msgPayload: {}", msgPayload);
deviceOnlineService.offlineHandle(msgPayload);
break;
default:
break;
}
} catch (Exception e) {
log.error("处理MQTT消息出现异常: {}", e);
}
};
}
}
原始数据表tb_device_online_count每日设备上线次数表,如下:
| 字段 | 描述 |
|---|---|
| online_count | 上线次数 |
| dev_id | 设备id |
| online_date | 上线日期 |
上面的订阅代码,会有什么问题呢?如下问题:
- 在统计服务中订阅设备online事件,我们看到需要创建client、配置inbound、配置mqttInputChannel等,如果以后有需求需要在另外一个服务工程中订阅其他的事件消息(实际项目中遇到过好几次这样的需求),那么这些创建client、配置inbound、配置mqttInputChannel等还需要重复编写一遍,造成重复代码,能否复用??我们的想法是创建client、配置inbound、配置mqttInputChannel等应该被封装在公共通用模块工程中,待业务工程引入复用
- 业务工程应该只关注接收消息处理消息,使得业务工程接收处理业务消息和基础订阅解耦。
- 处理上线、下线的switch操作,存在代码改进空间,可以使用设计模式(策略模式+工程模式)
接下来,我们针对这些问题,进行改进:
我们需要考虑如何将创建client、配置inbound、配置mqttInputChannel等这些公共通用的部分使用创建自定义公共starter抽象出来,让不同的服务工程引入这个start依赖,然后自定义自己的消息处理逻辑,而无需重复编写MQTT配置代码。
2 实现方案
-
MQTT订阅等通用逻辑封装在starter,各个业务工程引入复用
-
基于Spring发布-监听机制解耦基础订阅和各业务工程自定义处理逻辑
-
针对不同的类型事件消息使用策略模式+工厂模式进行代码改进,符合开闭原则,添加新的类型消息处理不用修改原有代码,只需新增一个类型处理器,扩展性和维护性高
3 具体细节
3.1 通用逻辑封装在starter
公共逻辑封装成starter,新服务工程只需引入依赖,避免重复编写工厂类、适配器等代码。
3.1.1 通用的公共逻辑封装在公共starter中
-
配置clientFactory(其中指定连接的url、用户名、密码)
-
消息通道适配器(指定client、topic、Qos、消息通道)
-
定义消息通道
-
接收发布消息给各个业务端
mqtt消费者配置属性类
/**
* @description: mqtt消费者配置属性类
* @author:xg
* @date: 2025/2/22
* @Copyright:
*/
@ConfigurationProperties(prefix = "mqtt.consumer")
@Data
public class MqttConsumerProperties {
private String clientIdPrefix = "consumerClient";
private String[] urls;
private String username;
private String password;
private String[] topics;
private int completionTimeout = 3000;
private int qos = 1;
}
/**
* @description:自动配置类:定义通用的公共逻辑
* @author:xg
* @date: 2025/2/22
* @Copyright:
*/
@Slf4j
@Configuration
@EnableConfigurationProperties(MqttConsumerProperties.class)
@ConditionalOnProperty(prefix = "mqtt.consumer", name = "enabled", havingValue = "true")
public class MqttAutoConfiguration {
/**
* 配置clientFactory(其中指定连接的url、用户名、密码)
* @param properties
* @return
*/
@Bean
@RefreshScope
public MqttPahoClientFactory mqttClientFactory(MqttConsumerProperties properties) {
DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory();
MqttConnectOptions options = new MqttConnectOptions();
options.setUserName(properties.getUsername());
options.setPassword(properties.getPassword().toCharArray());
options.setServerURIs(properties.getUrls());
factory.setConnectionOptions(options);
return factory;
}
/**
* 消息通道适配器(指定client、topic、Qos、消息通道)
* @param properties
* @param mqttClientFactory
* @return
*/
@Bean
public MessageProducer mqttInbound(MqttConsumerProperties properties,
MqttPahoClientFactory mqttClientFactory) {
String clientId = properties.getClientIdPrefix() + UUID.randomUUID();
MqttPahoMessageDrivenChannelAdapter adapter =
new MqttPahoMessageDrivenChannelAdapter(clientId, mqttClientFactory, properties.getTopics());
adapter.setQos(properties.getQos());
adapter.setCompletionTimeout(properties.getCompletionTimeout());
adapter.setConverter(new DefaultPahoMessageConverter());
adapter.setOutputChannel(mqttInputChannel());
return adapter;
}
/**
* 配置通道
* @return
*/
@Bean
public MessageChannel mqttInputChannel() {
return new DirectChannel();
}
/**
* 接收发布消息给各个业务端
* @param eventPublisher
* @return
*/
@Bean
@ServiceActivator(inputChannel = "mqttInputChannel")
public MessageHandler messageHandler(ApplicationEventPublisher eventPublisher) {
return message -> {
String payload = message.getPayload().toString();
// 解耦消息发布和各个业务处理的耦合,可以灵活扩展、可维护性高,同时也符合Spring的设计理念
eventPublisher.publishEvent(new MqttMessageEvent(payload));
};
}
}
封装发布的消息
/**
* @description: 封装发布的消息
* @author:xg
* @date: 2025/2/23
* @Copyright:
*/
public class MqttMessageEvent extends ApplicationEvent {
public MqttMessageEvent(String payload) {
super(payload);
}
public String getPayload() {
return (String) getSource();
}
}
3.1.2 创建spring.factories文件
resource包下面创建META-INF/spring.factories文件。在此文件中指定自动配置类的全路径名称
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.smart.product.config.MqttAutoConfiguration
maven clean && install 打包starter
3.2 业务工程监听并自定义处理逻辑
3.2.1 引入starter依赖
<dependency>
<groupId>com.smart.product</groupId>
<artifactId>smart-product-mqtt-consumer-starter</artifactId>
<version>1.0.0</version>
</dependency>
3.2.2 监听到消息并进行业务处理
/**
* @description: 监听到MQTT消息并进行业务处理
* @author:xg
* @date: 2025/2/23
* @Copyright:
*/
@Component
@Slf4j
public class MqttMsgListener {
@EventListener
public void handleMqttMessage(MqttMessageEvent event) {
String payload = event.getPayload();
JSONObject json = JSON.parseObject(payload);
log.info("msg:{}", json);
// 业务处理逻辑
String tslType = json.getString("tslType");
switch (tslType) {
case "online":
// 处理设备上线的操作(存储设备每天的上线次数)
log.info("处理设备上线的操作,msgPayload: {}", json);
deviceUpdownRecordService.onlineHandle(json);
break;
case "offline":
// 处理设备下线的操作
log.info("处理设备下线的操作,msgPayload: {}", json);
deviceUpdownRecordService.offlineHandle(json);
break;
default:
break;
}
}
}
3.2.3 交给不同业务处理实现类
上面不同消息类型的不同业务处理逻辑,我们使用策略模式+工厂模式进行改进。策略模式在这里的优点:
- 每种事件类型对应一种策略处理对象,当有新的类型消息要处理时,只需要添加新的策略实现类,无需修改现有逻辑
- 业务处理逻辑和路由解耦
- 符合开闭原则,扩展性和维护性比较好
工厂模式并实现ApplicationContextAware在这里的优点:基于注解驱动获取策略实现对象(即事件处理对象)并注册。
第1步:定义策略接口和实现类,其上标记注解
/**
* @description: 事件处理接口
* @author:xg
* @date: 2025/2/28
* @Copyright:
*/
public interface TslEventHandler {
boolean canHandle(String tslType);
void handle(JSONObject json);
}
对不同的策略实现类进行开发注解,来标记实例类。主要用于:
- 便于从Spring容器中获取这个策略实现
- 读取注解上面的标识来自动注册策略实现到map映射
/**
* @description: 对策略实现类进行开发注解,来标记实例类。主要用于:
* 便于从Spring容器中获取这个策略实现
* 读取注解上面的标识来自动注册策略实现到map映射
* @author:xg
* @date: 2025/3/1
* @Copyright:
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface TslTypeHandler {
String value();
}
实现不同的事件处理类,标记上类型注解(用于Spring获取处理类并注册到map映射)
/**
* @description: 上线事件处理
* @author:xg
* @date: 2025/3/1
* @Copyright:
*/
@TslTypeHandler("online")
@Slf4j
@Component
public class OnlineEventHandler implements TslEventHandler {
@Resource
private DeviceOnlineCountMapper deviceOnlineCountMapper;
@Override
public boolean canHandle(String tslType) {
return "online".equals(tslType);
}
/**
* 处理设备上线操作:存储每日设备上线次数
* @param json
*/
@Override
public void handle(JSONObject json) {
log.info("处理设备上线的操作, msgPayload: {}", json);
// 存储每日设备上线次数
String devId = json.getString("devId");
DeviceOnlineCount deviceOnlineCount = new DeviceOnlineCount();
deviceOnlineCount.setDevId(Long.valueOf(devId));
deviceOnlineCount.setOnlineDate(new Date());
deviceOnlineCount.setOnlineCount(1L);
deviceOnlineCountMapper.insert(deviceOnlineCount);
}
}
下线事件处理
/**
* @description: 下线事件处理逻辑
* @author:xg
* @date: 2025/3/1
* @Copyright:
*/
@TslTypeHandler("offline")
@Slf4j
@Component
public class OfflineEventHandler implements TslEventHandler {
@Override
public boolean canHandle(String tslType) {
return "offline".equals(tslType);
}
@Override
public void handle(JSONObject json) {
log.info("处理设备下线的操作, msgPayload: {}", json);
// TODO 下线处理。。。
}
}
第2步: 策略工厂并实现ApplicationContextAware(自动收集所有策略实例)
策略工厂借助Spring容器机制去收集事件处理对象,并基于事件对象上面的标记的注解注册处理对象到map映射。
Spring容器在初始化TslEventHandlerFactory这个Bean 时,发现该 Bean 实现了 ApplicationContextAware 接口,会自动调用 setApplicationContext() 方法,将当前容器的上下文对象applicationContext注入到 Bean 中。通过applicationContext容器上下文获取事件处理对象并存储到map
/**
* @description: 策略工厂: 注册、返回事件处理对象
* @author:xg
* @date: 2025/3/1
* @Copyright:
*/
@Component
public class TslEventHandlerFactory implements ApplicationContextAware {
private final Map<String, TslEventHandler> handlerMap = new ConcurrentHashMap<>();
/**
* 借助Spring容器机制去收集事件处理对象,
* 并基于事件对象上面的标记的注解注册处理对象到map映射。
* @param applicationContext
*/
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
// 基于注解获取事件处理对象
Map<String, Object> beans = applicationContext.getBeansWithAnnotation(TslTypeHandler.class);
// 注册事件处理对象到map映射
beans.forEach((beanName, bean) -> {
TslTypeHandler annotation = applicationContext.findAnnotationOnBean(beanName, TslTypeHandler.class);
handlerMap.put(annotation.value(), (TslEventHandler) bean);
});
}
/**
* 返回事件处理对象
* @param tslType
* @return
*/
public Optional<TslEventHandler> getHandler(String tslType) {
return Optional.ofNullable(handlerMap.get(tslType));
}
}
总结就是:策略工厂负责搜集存储不同的事件处理对象,并根据不同事件类型返回事件处理对象。
第3步:从策略工厂获取不同类型的事件处理对象并执行业务处理
/**
* @description: 监听到MQTT消息并进行业务处理
* @author:xg
* @date: 2025/2/23
* @Copyright:
*/
@Component
@Slf4j
public class MqttMsgListener {
@Autowired
private TslEventHandlerFactory handlerFactory;
/**
* 接收事件消息,处理事件消息:
* 根据不同的事件类型,通过策略工厂获取不同的处理对象,来处理事件
* @param event
*/
@EventListener
public void handleMqttMessage(MqttMessageEvent event) {
String payload = event.getPayload();
JSONObject json = JSON.parseObject(payload);
log.info("msg:{}", json);
// 根据不同的事件类型,通过策略工厂获取不同的处理对象,来处理事件
try {
String tslType = json.getString("tslType");
Optional<TslEventHandler> handlerOptional = handlerFactory.getHandler(tslType);
if (handlerOptional.isPresent()) {
handlerOptional.get().handle(json);
} else {
log.warn("Unsupported tslType: {}", tslType);
}
} catch (Exception e) {
log.error("Process MQTT message failed: {}", event.getPayload(), e);
}
}
}
总结就是:策略模式可以将每个不同类型的事件封装成独立的事件处理对象,通过上下文来选择合适的事件处理对象执行,这样新增类型时只需要添加新的事件处理对象,而不需要修改原有代码。
使用Spring的ApplicationContext来收集所有事件处理器Bean,并通过Map来存储类型与事件处理对象的对应关系,这样在运行时根据tslType查找对应的事件处理对象执行。
3.2.4 配置订阅参数
mqtt:
consumer:
enabled: true
url: tcp://mqtt-broker:1883
username: admin
password: secret
topics:
- device/status/+
- sensor/data/+
qos: 1
4 总结
调整之后的方案优势:
- 通用代码复用:将MQTT订阅逻辑封装成通用的Starter以进行各业务工程的复用,只需引入依赖
- 解耦业务逻辑和MQTT底层订阅实现:通过将MQTT订阅逻辑封装成通用的Starter以及Spring事件机制,业务工程无需关心MQTT底层实现,只需要接收并处理消息事件
- 针对不同的类型事件消息处理使用策略模式+工厂模式进行代码改进,符合开闭原则,添加新的类型消息处理不用修改原有代码,只需新增一个类型处理器,扩展性和维护性高
- 配置集中管理:通过@ConfigurationProperties统一管理MQTT参数,支持动态主题、QoS配置