MQTT(EMQX) - SpringBoot 整合MQTT 連線池 Demo - 附原始碼 + 線上客服聊天架構圖

VipSoft發表於2023-04-10

MQTT(EMQX) - Linux CentOS Docker 安裝

MQTT 概述

MQTT (Message Queue Telemetry Transport) 是一個輕量級傳輸協議,它被設計用於輕量級的釋出/訂閱式訊息傳輸,MQTT協議針對低頻寬網路,低計算能力的裝置,做了特殊的最佳化。是一種簡單、穩定、開放、輕量級易於實現的訊息協議,在物聯網的應用下的資訊採集,工業控制,智慧家居等方面具有廣泛的適用性。
image

  1. MQTT更加簡單:MQTT是一種訊息佇列協議,使用釋出/訂閱訊息模式,提供一對多的訊息釋出,解除應用程式耦合,相對於其他協議,開發更簡單;
  2. MQTT網路更加穩定:工作在TCP/IP協議上;由TCP/IP協議提供穩定的網路連線;
  3. 輕量級:小型傳輸,開銷很小(固定長度的頭部是 2 位元組),協議交換最小化,以降低網路流量;適合低頻寬,資料量較小的應用;

MQTT支援三種訊息釋出服務質量(QoS):

  • “至多一次”(QoS==0):訊息釋出完全依賴底層 TCP/IP 網路。會發生訊息丟失或重複。這一級別可用於如下情況,環境感測器資料,丟失一次讀記錄無所謂,因為不久後還會有第二次傳送。
  • “至少一次”(QoS==1):確保訊息到達,但訊息重複可能會發生。
  • “只有一次”(QoS==2):確保訊息到達一次。這一級別可用於如下情況,在計費系統中,訊息重複或丟失會導致不正確的結果。小型傳輸,開銷很小(固定長度的頭部是 2 位元組),協議交換最小化,以降低網路流量。

MQTT 三種身份

  • 釋出者、代理、訂閱者,釋出者和訂閱者都為客戶端,代理為伺服器,同時訊息的釋出者也可以是訂閱者(為了節約記憶體和流量釋出者和訂閱者一般都會定義在一起)。
  • MQTT傳輸的訊息分為主題(Topic,可理解為訊息的型別,訂閱者訂閱後,就會收到該主題的訊息內容(payload))和負載(payload,可以理解為訊息的內容)兩部分。
    image

image

MQTT和Websocket的區別是什麼?

MQTT是為了物聯網場景設計的基於TCP的Pub/Sub協議,有許多為物聯網最佳化的特性,比如適應不同網路的QoS、層級主題、遺言等等。

WebSocket是為了HTML5應用方便與伺服器雙向通訊而設計的協議,HTTP握手然後轉TCP協議,用於取代之前的Server Push、Comet、長輪詢等老舊實現。
兩者之所有有交集,是因為一個應用場景:如何透過HTML5應用來作為MQTT的客戶端,以便接受裝置訊息或者向裝置傳送資訊,那麼MQTT over WebSocket自然成了最合理的途徑了。

語言支援

Java、C#、Python、C/C++、Objective-C、Node.js、Javascript、Ruby、Golang、PHP

應用場景

遙感資料、汽車、智慧家居、智慧城市、醫療醫護
即時通訊:MQ 可以透過訂閱主題,輕鬆實現 1對1、1對多的通訊。
image

連線

  1. 登入IM服務
  2. 獲取MQTT 伺服器地址
  3. 建立MQTT連線

通訊

1、4. 傳送者 向IM 服務傳送訊息
2、5. IM 服務,將訊息持久化,併發給 MQTT
3、6. 消費者 從MQTT訂閱到訊息

本文 Demo 介紹

主要用到 InitializingBean、BasePooledObjectFactory、GenericObjectPool、GenericObjectPoolConfig
InitializingBean:例項化工廠、連線池,參考:Java SpringBoot Bean InitializingBean
GenericObjectPool:獲取連線物件,如果池中沒有,透過工廠建立 參考:Java GenericObjectPool 物件池化技術--SpringBoot sftp 連線池工具類
BasePooledObjectFactory::建立 MqttClient 連線 參考:Java BasePooledObjectFactory 物件池化技術
GenericObjectPoolConfig:GenericObjectPoolConfig是封裝GenericObject池配置的簡單“結構”,此類不是執行緒安全的;它僅用於提供建立池時使用的屬性。大多數情況,可以使用GenericObjectPoolConfig提供的預設引數就可以滿足日常的需求。
image
image

物件獲取流程圖

image

username(使用者名稱)和password(密碼)。這裡的使用者名稱和密碼是用於客戶端連線服務端時進行認證需要的。

有些MQTT服務端需要客戶端在連線時提供使用者名稱和密碼。只有客戶端正確提供了使用者名稱和密碼後,才能連線服務端。否則服務端將會拒絕客戶端連線,那麼客戶端也就無法釋出和訂閱訊息了。 當然,那些沒有開啟使用者密碼認證的服務端無需客戶端提供使用者名稱和密碼認證資訊。

Deom程式碼

image

POM

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>vipsoft-parent</artifactId>
        <groupId>com.vipsoft.boot</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>vipsoft-mqtt</artifactId>
    <version>1.0-SNAPSHOT</version>


    <dependencies>


        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
            <version>2.7.0</version>
        </dependency>

        <dependency>
            <groupId>org.eclipse.paho</groupId>
            <artifactId>org.eclipse.paho.client.mqttv3</artifactId>
            <version>1.2.5</version>
        </dependency>
 
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.3.6</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Resource

application.yml

server:
  port: 8088
  application:
    name: MQTT Demo

mqtt:
  host: tcp://172.16.0.88:1883
  clientId: VipSoft_MQTT
  poolConfig:
    customSet: false
    minIdle: 8
    maxIdle: 20
    maxTotal: 20
    lifo: false

Config

MqttConfig
使用者名稱和密碼除了有以上功能外,有些公用MQTT服務端也利用此資訊來識別客戶端屬於哪一個使用者,從而對客戶端進行管理。比如使用者可以擁有私人主題,這些主題只有該使用者可以釋出和訂閱。對於私人主題,服務端就可以利用客戶端連線時的使用者名稱和密碼來判斷該客戶端是否有釋出訂閱該使用者私人主題的許可權。

package com.vipsoft.mqtt.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "mqtt")
public class MqttConfig {
    /**
     * MQTT host 地址
     */
    private String host;

    /**
     * 客戶端Id
     */
    private String clientId;

    /**
     * 登入使用者(可選)
     */
    private String userName;

    /**
     * 登入密碼(可選)
     */
    private String password;
 
    /**
     * Mqtt Pool Config
     */
    private MqttPoolConfig poolConfig;

    public String getHost() {
        return host;
    }

    public void setHost(String host) {
        this.host = host;
    }

    public String getClientId() {
        return clientId;
    }

    public void setClientId(String clientId) {
        this.clientId = clientId;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public MqttPoolConfig getPoolConfig() {
        return poolConfig;
    }

    public void setPoolConfig(MqttPoolConfig poolConfig) {
        this.poolConfig = poolConfig;
    }

}

MqttPoolConfig

package com.vipsoft.mqtt.config;

public class MqttPoolConfig {

    /**
     * 是否啟用自定義配置
     */
    private boolean customSet;
    /**
     * 最小的空閒連線數
     */
    private int minIdle;
    /**
     * 最大的空閒連線數
     */
    private int maxIdle;
    /**
     * 最大連線數
     */
    private int maxTotal;

    public boolean isCustomSet() {
        return customSet;
    }

    public void setCustomSet(boolean customSet) {
        this.customSet = customSet;
    }

    public int getMinIdle() {
        return minIdle;
    }

    public void setMinIdle(int minIdle) {
        this.minIdle = minIdle;
    }

    public int getMaxIdle() {
        return maxIdle;
    }

    public void setMaxIdle(int maxIdle) {
        this.maxIdle = maxIdle;
    }

    public int getMaxTotal() {
        return maxTotal;
    }

    public void setMaxTotal(int maxTotal) {
        this.maxTotal = maxTotal;
    }
}

Pool

MqttClientManager

package com.vipsoft.mqtt.pool;

import cn.hutool.core.util.StrUtil;
import com.vipsoft.mqtt.config.MqttConfig;
import com.vipsoft.mqtt.config.MqttPoolConfig;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Service;


/**
 * 對類的建立之前進行初始化的操作,在afterPropertiesSet()中完成。
 */
@Service
public class MqttClientManager implements InitializingBean {
    private static Logger logger = LoggerFactory.getLogger(MqttClientManager.class);

    /**
     * mqtt連線配置
     */
    private final MqttConfig mqttConfig;

    private MqttConnectionPool<MqttConnection> mqttPool;

    public MqttClientManager(MqttConfig mqttConfig) {
        this.mqttConfig = mqttConfig;
    }

    /**
     * 建立連線池
     */
    @Override
    public void afterPropertiesSet() {
        try {
            // 連線池配置
            GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig();
            this.initPoolConfig(poolConfig);

            // mqtt連線配置
            MqttConnectOptions connOpts = new MqttConnectOptions();
            connOpts.setUserName(this.mqttConfig.getUserName());
            if (StrUtil.isNotEmpty(mqttConfig.getPassword())) {
                connOpts.setPassword(this.mqttConfig.getPassword().toCharArray());
            }

            // 建立工廠物件
            MqttConnectionFactory connectionFactory = new MqttConnectionFactory(mqttConfig.getHost(), connOpts);

            // 建立連線池
            mqttPool = new MqttConnectionPool<>(connectionFactory, poolConfig);

        } catch (Exception e) {
            logger.error(e.getMessage(), e);
        }
    }

    private void initPoolConfig(GenericObjectPoolConfig poolConfig) {

        MqttPoolConfig mqttConnectionPoolConfig = this.mqttConfig.getPoolConfig();

        if (mqttConnectionPoolConfig.isCustomSet()) {

            // 設定連線池配置資訊
            poolConfig.setMinIdle(mqttConnectionPoolConfig.getMinIdle());
            poolConfig.setMaxIdle(mqttConnectionPoolConfig.getMaxIdle());
            poolConfig.setMaxTotal(mqttConnectionPoolConfig.getMaxTotal());
            // TODO 補全

        }
    }

    /**
     * 根據key找到對應連線
     */
    public MqttConnection getConnection() throws Exception {
        return this.mqttPool.borrowObject();
    }

}

MqttConnection

package com.vipsoft.mqtt.pool;

import org.apache.commons.pool2.impl.GenericObjectPool;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MqttConnection {

    private Logger logger = LoggerFactory.getLogger(this.getClass());

    private MqttClient mqttClient;

    public MqttConnection(MqttClient mqttClient) {
        this.mqttClient = mqttClient;
    }

    /**
     * 隸屬於的連線池
     */
    private GenericObjectPool<MqttConnection> belongedPool;


    /**
     * 推送方法訊息
     */
    public void publish(String topic, String message) throws Exception {
        MqttMessage mqttMessage = new MqttMessage();
        mqttMessage.setPayload(message.getBytes());
        mqttClient.publish(topic, mqttMessage);
        System.out.println("物件:" + mqttClient + " " + "傳送訊息:" + message);
    }


    /**
     * 銷燬連線
     */
    public void destroy() {
        try {
            if (this.mqttClient.isConnected()) {
                this.mqttClient.disconnect();
            }
            this.mqttClient.close();
        } catch (Exception e) {
            logger.error("MqttConnection destroy ERROR ; errorMsg={}", e.getMessage(), e, e);
        }
    }

    /**
     * 換回連線池
     */
    public void close() {
        if (belongedPool != null) {
            this.belongedPool.returnObject(this);
        }
    }


    public MqttClient getMqttClient() {
        return mqttClient;
    }

    public void setMqttClient(MqttClient mqttClient) {
        this.mqttClient = mqttClient;
    }

    public GenericObjectPool<MqttConnection> getBelongedPool() {
        return belongedPool;
    }

    public void setBelongedPool(GenericObjectPool<MqttConnection> belongedPool) {
        this.belongedPool = belongedPool;
    }
}

MqttConnectionFactory

package com.vipsoft.mqtt.pool;

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.system.HostInfo;
import org.apache.commons.pool2.BasePooledObjectFactory;
import org.apache.commons.pool2.PooledObject;
import org.apache.commons.pool2.impl.DefaultPooledObject;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.atomic.AtomicInteger;

public class MqttConnectionFactory extends BasePooledObjectFactory<MqttConnection> {

    private static final Logger logger = LoggerFactory.getLogger(MqttConnectionFactory.class);


    // AtomicInteger是一個提供原子操作的Integer類,透過執行緒安全的方式操作加減
    private AtomicInteger counter = new AtomicInteger();

    /**
     * 連線地址
     */
    private String serverURI;
    /**
     * 當前服務IP
     */
    private String localHostIP;


    /**
     * mqtt連線配置
     */
    private MqttConnectOptions mqttConnectConfig;


    /**
     * 根據mqtt連線 配置建立工廠
     */
    public MqttConnectionFactory(String serverURI, MqttConnectOptions mqttConnectConfig) {
        this.serverURI = serverURI;
        this.mqttConnectConfig = mqttConnectConfig;
    }

    /**
     * 在物件池中建立物件
     *
     * @return
     * @throws Exception
     */
    @Override
    public MqttConnection create() throws Exception {
        // 實現執行緒安全避免在高併發的場景下出現clientId重複導致無法建立連線的情況
        int count = this.counter.addAndGet(1);

        // 根據ip+編號,生成唯一clientId
        String clientId = this.getLosthostIp() + "_" + DateUtil.thisMillsecond();

        // 建立MQTT連線物件
        MqttClient mqttClient = new MqttClient(serverURI, clientId);

        // 建立連線
        mqttClient.connect(mqttConnectConfig);

        // 構建mqttConnection物件
        MqttConnection mqttConnection = new MqttConnection(mqttClient);
        logger.info("在物件池中建立物件 {}", clientId);
        return mqttConnection;
    }

    /**
     * common-pool2 中建立了 DefaultPooledObject 物件對物件池中物件進行的包裝。
     * 將我們自定義的物件放置到這個包裝中,工具會統計物件的狀態、建立時間、更新時間、返回時間、出借時間、使用時間等等資訊進行統計
     *
     * @param mqttConnection
     * @return
     */
    @Override
    public PooledObject<MqttConnection> wrap(MqttConnection mqttConnection) {
        logger.info("封裝預設返回型別 {}", mqttConnection.toString());
        return new DefaultPooledObject<>(mqttConnection);
    }

    /**
     * 銷燬物件
     *
     * @param p 物件池
     * @throws Exception 異常
     */
    @Override
    public void destroyObject(PooledObject<MqttConnection> p) throws Exception {
        if (p == null) {
            return;
        }
        MqttConnection mqttConnection = p.getObject();
        logger.info("銷燬物件 {}", p.getObject().getMqttClient());
        mqttConnection.destroy();
    }

    /**
     * 校驗物件是否可用
     *
     * @param p 物件池
     * @return 物件是否可用結果,boolean
     */
    @Override
    public boolean validateObject(PooledObject<MqttConnection> p) {
        MqttConnection mqttConnection = p.getObject();
        boolean result = mqttConnection.getMqttClient().isConnected();
        logger.debug("validateObject serverURI {},client_id {},result {}", mqttConnection.getMqttClient().getServerURI(),
                mqttConnection.getMqttClient().getClientId(), result);
        return result;
    }

    /**
     * 啟用鈍化的物件系列操作
     *
     * @param p 物件池
     * @throws Exception 異常資訊
     */
    @Override
    public void activateObject(PooledObject<MqttConnection> p) throws Exception {
        logger.info("啟用鈍化的物件 {}", p.getObject().getMqttClient());
    }

    /**
     * 鈍化未使用的物件
     *
     * @param p 物件池
     * @throws Exception 異常資訊
     */
    @Override
    public void passivateObject(PooledObject<MqttConnection> p) throws Exception {
        logger.info("鈍化未使用的物件 {}", p.getObject().getMqttClient());
    }


    /**
     * 獲取當前服務真實IP
     */
    private String getLosthostIp() {
        if (StrUtil.isNotBlank(this.localHostIP)) {
            return this.localHostIP;
        }
        HostInfo hostInfo = new HostInfo();
        this.localHostIP = hostInfo.getAddress();
        return this.localHostIP;
    }

}

MqttConnectionPool

package com.vipsoft.mqtt.pool;

import org.apache.commons.pool2.impl.GenericObjectPool;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;

public class MqttConnectionPool<T> extends GenericObjectPool<MqttConnection> {

    public MqttConnectionPool(MqttConnectionFactory factory, GenericObjectPoolConfig config) {
        super(factory, config);
    }

    /**
     * 從物件池獲得一個物件
     */
    @Override
    public MqttConnection borrowObject() throws Exception {
        MqttConnection conn = super.borrowObject();
        // 設定所屬連線池
        if (conn.getBelongedPool() == null) {
            conn.setBelongedPool(this);
        }
        return conn;
    }

    /**
     * 歸還一個連線物件
     * @param conn
     */
    @Override
    public void returnObject(MqttConnection conn) {
        if (conn!=null) {
            super.returnObject(conn);
        }
    }
} 

utils

MqttClientManager

package com.vipsoft.mqtt.utils;

import com.vipsoft.mqtt.pool.MqttClientManager;
import com.vipsoft.mqtt.pool.MqttConnection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


@Service
public class MqttUtil {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Autowired
    MqttClientManager mqttManager;

    public void publish(String clientId, String message) {
        logger.info("publish INFO ; clientId={}, message={}", clientId, message);
        MqttConnection connection = null;
        try {
            connection = mqttManager.getConnection();
            logger.info("publish INFO ; clientId={},targetUrl={}", clientId, connection.getMqttClient().getServerURI());
            connection.publish(clientId, message);
        } catch (Exception e) {
            logger.error("publish ERROR ; clientId={},message={}", clientId, message, e, e);
        } finally {
            if (null != connection) {
                connection.close();
            }
        }
    }


}

test

PushCallback

package com.vipsoft.mqtt;

import cn.hutool.core.date.DateUtil;
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken;
import org.eclipse.paho.client.mqttv3.MqttCallback;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class PushCallback implements MqttCallback {
    private final Logger logger = LoggerFactory.getLogger(this.getClass());

    @Override
    public void connectionLost(Throwable cause) {
        // 連線丟失後進行重連
        System.out.println("連線斷開,可以做重連");
        logger.info("掉線時間:{}", DateUtil.now());
    }

    @Override
    public void deliveryComplete(IMqttDeliveryToken token) {
        System.out.println("deliveryComplete---------" + token.isComplete());
    }

    @Override
    public void messageArrived(String topic, MqttMessage message) throws Exception {
        System.out.println("接收訊息主題 : " + topic);
        System.out.println("接收訊息Qos : " + message.getQos());
        System.out.println("接收訊息內容 : " + new String(message.getPayload()));
    }
}

MqttProducerTest

package com.vipsoft.mqtt;

import cn.hutool.core.date.DateUtil;
import com.vipsoft.mqtt.utils.MqttUtil;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.concurrent.CountDownLatch;

@SpringBootTest
public class MqttProducerTest {

    @Autowired
    MqttUtil mqttUtil;

    @Test
    void pushMessateTest() throws Exception {
        for (int i = 0; i < 50; i++) {
            String topic = "VipSoft_MQTT";
            mqttUtil.publish(topic, "傳送訊息:" + DateUtil.now());
            Thread.sleep(3000);
        }
        new CountDownLatch(1).await();
    }
}

MqttConsumerTest

package com.vipsoft.mqtt;
 
import com.vipsoft.mqtt.pool.MqttClientManager;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;


@SpringBootTest
public class MqttConsumerTest {


    @Autowired
    MqttClientManager mqttManager;

    @Test
    void subscribeTest() throws Exception {
        String topic = "VipSoft_MQTT";
        MqttClient mqttClient = mqttManager.getConnection().getMqttClient();
        //這裡的setCallback需要新建一個Callback類並實現 MqttCallback 這個類
        mqttClient.setCallback(new PushCallback());
        while (true) {
            mqttClient.subscribe(topic);
            Thread.sleep(1000);
        }
    }
}

執行方式
  1. MqttConsumerTest.subscribeTest()
  2. MqttProducerTest.pushMessateTest()

image

image

更多文章參考:
小程式mqtt實現聊天功能
Gitee 原始碼地址:https://gitee.com/VipSoft/VipBoot/

相關文章