springboot集成mqtt

1,300 阅读6分钟

本章介绍springboot集成mqtt,协议版本是v5.0

依赖

我们使用eclipse.paho提供的java客户端来集成,首先引入pom依赖。

 <!-- 用于通过ConfigurationProperties注解引入配置 -->
 <dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-configuration-processor</artifactId>
   <optional>true</optional>
 </dependency>
 <!-- mqtt -->
 <dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-integration</artifactId>
 </dependency>
 <dependency>
   <groupId>org.springframework.integration</groupId>
   <artifactId>spring-integration-stream</artifactId>
 </dependency>
 <dependency>
   <groupId>org.springframework.integration</groupId>
   <artifactId>spring-integration-mqtt</artifactId>
 </dependency>
 <dependency>
   <groupId>org.eclipse.paho</groupId>
   <artifactId>org.eclipse.paho.mqttv5.client</artifactId>
   <version>1.2.5</version>
 </dependency>

增加配置

application-dev.yml中增加以下配置:

 mqtt:
   enable: true
   hostUrl: ws://localhost:63084
   username: xxx
   password: xxx
   cleanSession: true
   reconnect: true
   timeout: 100
   keepAlive: 100
   isOpen: true
   qos: 1
   client-id: wms
   defaultTopic: wms/#

注意此处的hostUrl,协议使用ws,也不需要拼接上/mqtt

因为我们的微服务中,引入了mqtt模块的话,在启动时就会自动注册到emqx服务器上,为了避免各个微服务的clientId相同导致冲突,所以我们的clientId需要区分每个微服务实例。

最终生成的client-id的格式为:

 ${spring.application.name}-${mqtt.clientId}-${hostname}

defaultTopic是服务端默认订阅的topic,多个主题用,隔开。

代码实现

配置类

创建配置类MqttProperties来接收上述配置参数。

 @Component
 @ConfigurationProperties("mqtt")
 @Data
 public class MqttProperties {
     private static final String HOST_NAME = IpUtils.getHostName();
 ​
     @Autowired
     private Environment env;
 ​
     /**
      * 用户名
      */
     private String username;
 ​
     /**
      * 密码
      */
     private String password;
 ​
     /**
      * 连接地址
      */
     private String hostUrl;
 ​
     /**
      * 客户端Id,同一台服务器下,不允许出现重复的客户端id
      */
     private String clientId;
 ​
     /**
      * 默认连接主题
      */
     private String defaultTopic;
 ​
     /**
      * 超时时间
      */
     private int timeout;
 ​
     /**
      * 设置会话心跳时间 单位为秒 服务器会每隔1.5*20秒的时间向客户端
      * 发送个消息判断客户端是否在线,但这个方法并没有重连的机制
      */
     private int keepAlive;
 ​
     /**
      * 设置是否清空session,这里如果设置为false表示服务器会保留客户端的连
      * 接记录,这里设置为true表示每次连接到服务器都以新的身份连接
      */
     private Boolean cleanSession;
 ​
     /**
      * 是否断线重连
      */
     private Boolean reconnect;
 ​
     /**
      * 启动的时候是否关闭mqtt
      */
     private Boolean isOpen;
 ​
     /**
      * 连接方式
      */
     private Integer qos;
 ​
     public String getClientId() {
         return String.format("%s-%s-%s", env.getProperty("spring.application.name"), clientId, HOST_NAME);
     }
 ​
     public String getTopic(String topic) {
         return getTopic(topic, false);
     }
 ​
     public String getTopic(String topic, boolean global) {
         AssertUtil.stringNotBlank(topic, "主题不能为空");
         return global ? String.format(topic, "+") : getUserTopic(topic);
     }
 ​
     private String getUserTopic(String topic) {
       // 此处SecurityContextHolder是一个ThreadLocal,在gateway的拦截器中填入的上下文信息。
       // 可以使用其他逻辑替换,或者直接删掉
         String userName = SecurityContextHolder.getUserName();
         if (StringUtils.isEmpty(userName)) {
             return topic;
         }
         return topic.contains("%s") ? String.format(topic, userName) : String.format("%s/%s", topic, userName);
     }
 ​
     public MqttConnectionOptions getConnectionOptionsV5() {
         MqttConnectionOptions options = new MqttConnectionOptions();
         options.setUserName(getUsername());
         options.setPassword(getPassword().getBytes(StandardCharsets.UTF_8));
         options.setConnectionTimeout(getTimeout());
         options.setKeepAliveInterval(getKeepAlive());
         options.setCleanStart(getCleanSession());
         options.setAutomaticReconnect(true);
         return options;
     }
 }

String getTopic(String topic, boolean global)方法封装了订阅主题构建逻辑,如果希望订阅全局消息,则会在传入的主题后再拼接上/+,如果是用户消息,则拼接上当前登录用户的用户名。

MqttConnectionOptions为创建连接时的选项,注意使用v5版本和v3版本时,类名有所区别。

客户端类

InformMqttClient,提供了单例的客户端,支持连接的创建、销毁,消息的发布、订阅等功能。

 ​
 @ConditionalOnProperty(prefix = "mqtt", name = "enable", havingValue = "true")
 @Component
 @Slf4j
 public class InformMqttClient {
 ​
     @Autowired
     private InformMqttCallback mqttAcceptCallback;
 ​
     @Autowired
     private MqttProperties mqttProperties;
 ​
     private MqttClient client = null;
 ​
     public MqttClient getClient() {
         return client;
     }
 ​
     /**
      * 客户端连接
      */
     @PostConstruct
     public void connect() {
         try {
             client = new MqttClient(mqttProperties.getHostUrl(), mqttProperties.getClientId(), new MemoryPersistence());
             // 设置回调
             client.setCallback(mqttAcceptCallback);
             client.connect(mqttProperties.getConnectionOptionsV5());
             setDefaultSubscribe();
         } catch (Exception e) {
             throw new ServiceException("建立mqtt客户端连接异常", e);
         }
     }
 ​
     private void setDefaultSubscribe() throws MqttException {
         if (StringUtils.isEmpty(mqttProperties.getDefaultTopic())) {
             return;
         }
         String[] topics = mqttProperties.getDefaultTopic().split(",");
         MqttSubscription[] mqttSubscriptions = Arrays.stream(topics)
                 .map(t -> new MqttSubscription(t, mqttProperties.getQos())).toArray(MqttSubscription[]::new);
         client.subscribe(mqttSubscriptions);
     }
 ​
     /**
      * 重新连接
      */
     public void reconnection() {
         try {
             AssertUtil.objectNotNull(client, "请先建立连接");
             client.connect();
         } catch (MqttException e) {
             throw new ServiceException("建立mqtt客户端连接异常", e);
         }
     }
 ​
     /**
      * 订阅某个主题
      *
      * @param topic 主题
      * @param qos   连接方式
      */
     public void subscribe(String topic, boolean global, int qos) {
         AssertUtil.objectNotNull(client, "请先建立连接");
         log.debug("==============开始订阅主题==============" + topic);
         try {
             client.subscribe(mqttProperties.getTopic(topic, global), qos);
         } catch (MqttException e) {
             throw new ServiceException("mqtt订阅主题异常", e);
         }
     }
 ​
     /**
      * 订阅某个主题
      *
      * @param topic 主题
      */
     public void subscribeGlobal(String topic) {
         subscribe(topic, true, mqttProperties.getQos());
     }
 ​
 ​
 ​
     /**
      * 订阅某个主题
      *
      * @param topic 主题
      */
     public void subscribe(String topic) {
         subscribe(topic, false, mqttProperties.getQos());
     }
 ​
     /**
      * 取消订阅某个主题
      *
      * @param topic
      */
     public void unsubscribe(String topic) {
         AssertUtil.objectNotNull(client, "请先建立连接");
         log.debug("==============开始取消订阅主题==============" + topic);
         try {
             client.unsubscribe(topic);
         } catch (MqttException e) {
             throw new ServiceException("mqtt取消订阅主题异常", e);
         }
     }
 ​
     public void publish(EnumMqttTopic topic, Object msg) {
         AssertUtil.objectNotNull(topic, "主题不能为空");
         AssertUtil.objectNotNull(msg, "消息内容不能为空");
         publish(topic.getTopic(), msg, topic.isGlobal());
     }
 ​
     public void publish(String topic, String msg) {
         publish(topic, msg, false);
     }
 ​
     public void publish(String topic, Object msg) {
         AssertUtil.objectNotNull(msg, "消息体不能为空");
         publish(topic, JsonUtils.toJSon(msg), false);
     }
 ​
     public void publish(String topic, String msg, boolean global) {
         publish(topic, msg, global, false);
     }
 ​
     public void publish(String topic, Object msg, boolean global) {
         AssertUtil.objectNotNull(msg, "消息体不能为空");
         publish(topic, JsonUtils.toJSon(msg), global, false);
     }
 ​
     /**
      * 发布消息
      *
      * @param topic    消息主题
      * @param pushMessage 消息体
      * @param global 是否全局消息
      * @param retained 是否保留消息
      */
     public void publish(String topic, String pushMessage, boolean global, boolean retained) {
         MqttMessage message = new MqttMessage();
         message.setQos(mqttProperties.getQos());
         message.setRetained(retained);
         message.setPayload(pushMessage.getBytes());
         try {
             getClient().publish(mqttProperties.getTopic(topic, global), message);
         } catch (MqttException e) {
             throw new ServiceException("mqtt发布消息失败", e);
         }
     }
 ​
 ​
 ​
     @PreDestroy
     public void destory() {
         disconnect();
         close();
     }
     /**
      * 关闭连接
      */
     public void disconnect() {
         try {
             if (getClient() != null) {
                 getClient().disconnect();
             }
         } catch (MqttException e) {
             throw new ServiceException("mqtt关闭连接失败", e);
         }
     }
 ​
     /**
      * 释放资源
      */
     public void close() {
         try {
             if (getClient() != null) {
                 getClient().close();
             }
         } catch (MqttException e) {
             throw new ServiceException("mqtt释放资源失败", e);
         }
     }
 }

注意@ConditionalOnProperty(prefix = "mqtt", name = "enable", havingValue = "true")的写法,如果在配置中开启了mqtt功能,才会创建客户端。

name必须是单层属性,不能写成mqtt.name,否则会不生效。

以下是错误示例:

 @ConditionalOnProperty(name = "mqtt.enable", havingValue = "true")

服务启动时,会自动调用connect()方法,建立连接,并订阅默认主题,服务关闭时,会自动调用destory()方法释放连接资源。

回调类

InformMqttCallback

 @ConditionalOnProperty(prefix = "mqtt", name = "enable", havingValue = "true")
 @Component
 @Slf4j
 public class InformMqttCallback implements MqttCallback {
 ​
     @Autowired
     private InformMqttClient informMqttClient;
 ​
     /**
      * 客户端收到消息触发
      *
      * @param topic       主题
      * @param mqttMessage 消息
      */
     @Override
     public void messageArrived(String topic, MqttMessage mqttMessage) throws Exception {
         log.debug("接收消息主题 : " + topic);
         log.debug("接收消息Qos : " + mqttMessage.getQos());
         log.debug("接收消息内容 : " + new String(mqttMessage.getPayload()));
     }
 ​
     /**
      * 发布消息成功
      *
      * @param token token
      */
     @Override
     public void deliveryComplete(IMqttToken token) {
         String[] topics = token.getTopics();
         for (String topic : topics) {
             log.debug(String.format("向主题:【%s】 发送消息成功!", topic));
         }
         try {
             MqttMessage message = token.getMessage();
             byte[] payload = message.getPayload();
             String s = new String(payload, StandardCharsets.UTF_8);
             log.debug("消息的内容是:{}", s);
         } catch (MqttException e) {
             throw new ServiceException("发布mqtt消息异常", e);
         }
     }
 ​
     @Override
     public void disconnected(MqttDisconnectResponse mqttDisconnectResponse) {
         log.info("client: {}, 连接断开: {}", informMqttClient.getClient().getClientId(),
                 mqttDisconnectResponse.getReasonString());
         if (informMqttClient.getClient() == null || !informMqttClient.getClient().isConnected()) {
             log.debug("emqx重新连接....................................................");
             informMqttClient.reconnection();
         }
     }
 ​
     @Override
     public void mqttErrorOccurred(MqttException e) {
         log.error("client: {}, 发生异常: {}", informMqttClient.getClient().getClientId(), e.getMessage());
     }
 ​
     /**
      * 连接emq服务器后触发
      *
      * @param b
      * @param s
      */
     @Override
     public void connectComplete(boolean b, String s) {
         log.debug("--------------------ClientId: {}, 客户端连接成功!--------------------",
                 informMqttClient.getClient().getClientId());
     }
 ​
     @Override
     public void authPacketArrived(int i, MqttProperties mqttProperties) {
     }
 }

MqttCallback接口中提供了mqtt客户端与服务端交互的一些重要切点,便于我们实现自己的自定义方法。例如:

  • 在客户端连接成功后,修改redis中的当前在线客户端数量,(当然也可以通过emqx提供的指标来实现,emqx接入Prometheus也很方便);
  • 在接收到认证消息时,处理自定义的认证逻辑;
  • 在消息发布成功后,调用数据库服务修改发布状态;
  • 接收到订阅消息时,做一些指标的收集等

至此,springboot集成mqtt的代码就完成了,需要注意的地方有:

  • 不要重复创建客户端,尽量使用单例或者资源池来管理
  • callback中的日志是方便调试,线上环境建议关闭debug日志