4.策略模式+工厂模式以及自定义starter在MQTT订阅采集原始数据(设备上线次数)中的应用

201 阅读10分钟

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 总结

调整之后的方案优势:

  1. 通用代码复用:将MQTT订阅逻辑封装成通用的Starter以进行各业务工程的复用,只需引入依赖
  2. 解耦业务逻辑和MQTT底层订阅实现:通过将MQTT订阅逻辑封装成通用的Starter以及Spring事件机制,业务工程无需关心MQTT底层实现,只需要接收并处理消息事件
  3. 针对不同的类型事件消息处理使用策略模式+工厂模式进行代码改进,符合开闭原则,添加新的类型消息处理不用修改原有代码,只需新增一个类型处理器,扩展性和维护性高
  4. 配置集中管理:通过@ConfigurationProperties统一管理MQTT参数,支持动态主题、QoS配置