最近在做IM(及時通信)相關的應用,長連接(消息收發)採用的Mqtt5,mqtt支持會話保存(cleanStart)、會話超期(sessionExpire)、Qos等設置,可以實現移動端弱網絡(或網絡斷開後再重連)的離線消息接收。
之所以選擇Mqtt協議,因爲mqtt是一種極其輕量級的發佈/訂閱消息傳輸協議(專爲受限設備和低帶寬、高延遲或不可靠的網絡而設計),且代碼體積小、功耗低,適合移動設備、車機等終端,且需要支持手機、車機等在網絡信號不穩定(弱網、斷網、進隧道沒有網絡等)且之後再恢復網絡時,可以繼續收發消息、且可以收到之前離線時消息的補充推送。關於離線消息的補充推送亦可由IM服務端自己控制,但若Mqtt協議原生支持離線推送,豈不是省的開發者再去自己處理。同時秉承着用新不用舊的觀點,果斷選用Mqtt5而棄用Mqtt3,Mqtt5相較於Mqtt3有了很多升級,如:原因代碼(PUBACK / PUBREC)、共享訂閱、會話過期、請求/響應模式(ResponseTopic, CorrelationData)、Will Delay等。
關於Mqtt的服務端、客戶端選型可參考如下鏈接:
Mqtt官網
Mqtt中文網
Mqtt Server端
Mqtt Client端
實際開發過程中,Server端選用的Emq,Client端選用的HiveMq,二者均支持Mqtt5。
Mqtt5支持離線消息接收的幾個核心設置:
ClientId
CleanStart: false
SessionExpiry
Qos:2
CONNACK中的session present flag
ClientId用於唯一標識用戶session。
CleanStart設置爲0,表示創建一個持久會話,在客戶端斷開連接時,會話仍然保持並保存離線消息,直到會話超時註銷。CleanStart設置爲1,表示創建一個新的臨時會話,在客戶端斷開時,會話自動銷燬。
SessionExpiry即指定在CleanStart爲0時,會話的保存時長,如果客戶端未在用戶定義的時間段內連接,則可以丟棄狀態(例如,訂閱和緩衝的消息)而無需進行清理。
Qos即消息的Quality of Service,若要支持離線消息,需要訂閱端、發佈端Qos >= 1
session present即在connect到mqtt服務器的返回結果ConnAck中,包含session present標識,該標識表示當前clientId是否存在之前的持久會話(persistent session),若之前已存在session(此時千萬不要再次重複訂閱topic,若再次訂閱則之前的消息都將收不到),則session會保留之前的訂閱關係、客戶端離線時的消息(Qos>=1)、未ack的消息。重點說明一下session present的使用,在客戶端連接到mqtt服務器並獲取到connack中的isSessionPresent標識時,若isSessionPresent=true則已存在會話,此時無需再重複訂閱topic(訂閱關係已保存到session中,若再重複訂閱則收不到之前的離線消息),可通過全局接收來處理離線消息和之後的新消息;若isSessionPresent=false則不存在session(又或者session已超期),此時需要重新訂閱topic,且之前離線的消息都已接收不到,只能通過其他方式獲取離線消息(例如IM後端服務的全量同步服務)。
圖片截取自:mqtt-essentials-part-3-client-broker-connection-establishment
圖片截取自:mqtt-essentials-part-7-persistent-session-queuing-messages
如ClientId=1, CleanStart=false, SessionExpiry=3600s, Qos=2即指定clientId=1的會話爲持久會話,用戶在離線後3600s的的離線消息都會被Mqtt服務器保存,用戶在離線時間不超過3600s且再次以ClientId=1重新上線時,是可以收到離線期間消息的補充推送的,同時Qos=2(exactly once)保證消息只會被客戶端收到一次且一定一次。
以HiveMq客戶端代碼爲例:
注意:asyncClient.publishes全局消息接收一定要放在connect方法調用之前
package com.mx.mqtt.sys;
import com.hivemq.client.mqtt.MqttGlobalPublishFilter;
import com.hivemq.client.mqtt.datatypes.MqttQos;
import com.hivemq.client.mqtt.lifecycle.MqttClientConnectedContext;
import com.hivemq.client.mqtt.lifecycle.MqttClientConnectedListener;
import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedContext;
import com.hivemq.client.mqtt.lifecycle.MqttClientDisconnectedListener;
import com.hivemq.client.mqtt.mqtt5.Mqtt5AsyncClient;
import com.hivemq.client.mqtt.mqtt5.Mqtt5BlockingClient;
import com.hivemq.client.mqtt.mqtt5.Mqtt5Client;
import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5ConnAckException;
import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5SimpleAuth;
import com.hivemq.client.mqtt.mqtt5.message.connect.connack.Mqtt5ConnAck;
import com.mx.mqtt.jwt.JwtUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.UnsupportedEncodingException;
/**
* emqx - Session
*
* @Ahthor luohq
* @Date 2020-04-09
*/
public class EmqxOfflineClient {
/**
* 日誌
*/
private static final Logger logger = LogManager.getLogger(EmqxOfflineClient.class);
private static final String MQTT_JWT_SECRET = "xxxx";
private static final String MQTT_SERVER_HOST = "192.168.xxx.xxx";
private static final Integer MQTT_SERVER_PORT = 1883;
private static final String MQTT_CLIENT_ID = "luohq-offline";
public static final String MQTT_SUB_TOPIC = "luohq/offline";
public static final Long SESSION_EXPIRATION = 5 * 60L;
private static Boolean isSessionPresent = false;
private static Mqtt5BlockingClient client;
private static Mqtt5AsyncClient asyncClient;
public static void main(String[] args) {
/** 構建mqtt客戶端 */
buildMqtt5Client();
/** 若session不存在,則需要再訂閱主題 */
if (!isSessionPresent) {
logger.info("【CLIENT-SUB】訂閱主題:" + MQTT_SUB_TOPIC);
//訂閱主題
asyncClient.subscribeWith()
.topicFilter(MQTT_SUB_TOPIC)
.qos(MqttQos.EXACTLY_ONCE)
.send();
}
}
public static Mqtt5BlockingClient buildMqtt5Client() {
/** blocking客戶端 */
client = Mqtt5Client.builder()
.identifier(MQTT_CLIENT_ID)
.serverHost(MQTT_SERVER_HOST)
.serverPort(MQTT_SERVER_PORT)
.addConnectedListener(new MqttClientConnectedListener() {
@Override
public void onConnected(MqttClientConnectedContext context) {
logger.info("mqtt onConnected context");
}
})
.addDisconnectedListener(new MqttClientDisconnectedListener() {
@Override
public void onDisconnected(MqttClientDisconnectedContext context) {
logger.info("mqtt onDisconnected context");
}
})
//自動重連(指數級延遲重連(起始延遲1s,之後每次2倍,到2分鐘封頂) delay : 1s-> 2s -> 4s -> ... -> 2min)
.automaticReconnectWithDefaultConfig()
.buildBlocking();
asyncClient = client.toAsync();
/** Emqx JWT認證 */
String authJwt = JwtUtils.generateJwt(MQTT_CLIENT_ID, MQTT_JWT_SECRET);
Mqtt5SimpleAuth auth = Mqtt5SimpleAuth.builder()
.username(MQTT_CLIENT_ID)
.password(authJwt.getBytes())
.build();
Mqtt5ConnAck connAck = null;
/** 全局消息處理(放在connect之前) */
asyncClient.publishes(MqttGlobalPublishFilter.ALL, mqtt5Publish -> {
try {
byte[] msg = mqtt5Publish.getPayloadAsBytes();
String msgStr = new String(mqtt5Publish.getPayloadAsBytes(), "UTF-8");
logger.info("【CLIENT-RECV】" + msgStr);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
});
/** 連接邏輯 */
try {
connAck = client.connectWith()
.simpleAuth(auth)
/** cleanSession=false */
.cleanStart(false)
/** session 7天過期 */
.sessionExpiryInterval(SESSION_EXPIRATION)
/** keepalive 時長*/
//.keepAlive(60)
.send();
} catch (Mqtt5ConnAckException e) {
e.printStackTrace();
connAck = e.getMqttMessage();
}
/** 連接(普通無密碼連接) */
//Mqtt5ConnAck connAck = client.connect();
/** 檢查之前是否已存在session */
isSessionPresent = connAck.isSessionPresent();
if (connAck.isSessionPresent()) {
logger.info("session is present: " + connAck.getSessionExpiryInterval().orElse(-1));
}
logger.info(connAck.getReasonCode() + ":" + connAck.getReasonString() + ":" + connAck.getResponseInformation());
if (connAck.getReasonCode().isError()) {
logger.error("Mqtt5連接失敗!");
System.exit(-1);
}
return client;
}
}
以上的幾個核心設置:
clientId,
cleanStart=fasle,
sessionExpiry > 0,
Qos>=1,
CONNACK session present處理,
缺一不可,少一項設置便無法實現離線消息的接受。