本章介绍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日志