最近在完善畢設的路上,由於是設計一個遠程控制物聯網系統,所以服務端到硬件我選用了MQTT協議。因爲MQTT的發佈/訂閱模式很適合這種場景。接下來就來聊聊遇到的一些問題以及解決思路吧。
畢設技術棧:springboot 、swagger、springdata、shiro、JWT、redis、rabbitmq、android(語音控制遠程設備)、VUE、axios、ElementUI、arduino核心開發板、ESP32(無線接收模塊)
小前奏
既然是基於MQTT協議的,那麼前端(Vue)我就想着使用一個支持MQTT的庫直接用就好。然後:MQTT庫介紹
先安裝個MQTT庫:
npm install mqtt --save
var mqtt = require('mqtt')
var client = mqtt.connect('mqtt://localhost:1883')
client.on('connect', function () {
client.subscribe('presence', function (err) {
if (!err) {
client.publish('presence', 'Hello mqtt')
}
})
})
client.on('message', function (topic, message) {
// message is Buffer
console.log(message.toString())
client.end()
})
運行上面的代碼在vue中,瀏覽器直接報錯了,
WebSocket connection to 'ws://localhost:1883/' failed:
Connection closed before receiving a handshake response
因爲只能在node.js環境下纔可以使用,瀏覽器環境是不支持的,因爲數據包瀏覽器不支持解析,所以會報錯。那麼,該怎麼辦呢?
看下文:
一、安裝RabbitMQ
這一步去官網直接下載安裝就好了。RabbitMQ官網。
安裝rabbitMQ,首先需要安裝erlang
在這裏查看版本適配:https://www.rabbitmq.com/which-erlang.html
二、開啓插件
進入sbin目錄。
1.啓對mqtt的支持
rabbitmq-plugins enable rabbitmq_mqtt
2.開啓web管理
rabbitmq-plugins enable rabbitmq_management
web在線管理:http://127.0.0.1:15672
默認賬號密碼:guest
3.開啓 stomp
rabbitmq-plugins enable rabbitmq_web_stomp
rabbitmq-plugins enable rabbitmq_web_stomp_examples
三、安裝庫
npm install sockjs-client --save
npm install stompjs --save
四、引入庫
import SockJS from 'sockjs-client';
import Stomp from 'stompjs';
五、執行代碼
function mqttStart() {
console.log("進入mqtt初始化");
var ws = new WebSocket('ws://127.0.0.1:15674/ws');
var client = Stomp.over(ws);
var on_connect = function () {
console.log('connected');
client.subscribe('/topic/test', (msg)=> {
console.log("收到:"+msg.body)
});
};
var on_error = function () {
console.log('error');
};
//參數依次爲:用戶名,密碼,連接回調,錯誤回調,虛擬主機名
client.connect('guest', 'guest', on_connect, on_error, '/');
}
ps:如果不需要使用stomp協議,又找到可以直接在瀏覽器環境連接MQTT服務器的庫:paho.mqtt,使用這個庫的效果跟步驟三四五六一樣,但是不推薦這種用法,下面會說原因。
六、開始測試
如上代碼訂閱了test,所以我給test節點發布了一條消息,前端也成功收到了。
接下來,本文到此結束…emm…不行吧。感覺這也太敷衍了吧。是啊,我們回頭看看這種寫法有什麼不好呢?
看看這段代碼吧:
client.connect('guest', 'guest', on_connect, on_error, '/');
這太不好了吧,直接把MQTT服務分配的賬號密碼暴露在前端,肯定不行吧。對。這樣當然不行,怎麼解決呢?
直接把Websocket換成sockjs就可以了,因爲上面我們已經安裝過了Sockjs庫,所以改成如下即可:
可以看到,此時我們連接mqtt服務器無需再使用賬號密碼了,而是通過一個websocket連接 ,使用stomp協議轉化,間接連接了MQTT服務器,連接時帶上登陸後的token,後端驗證token是否正確,如若正確然後就可以握手成功了。
mqttStart() {
console.log("進入mqtt初始化");
this.ws = Stomp.over(new SockJS('http://127.0.0.1:8080/ws?token='+localStorage.token));
this.ws.heartbeat.outgoing = 0;
this.ws.heartbeat.incoming = 0;
this.ws.connect({
//用戶唯一識別信息 因爲我的項目有web以及android類型,所以這裏需要記錄是什麼類型@web
//即爲web類型
name: this.userInfo.username+"@web",
}, (frame) => {
// 專屬通知,其中user爲前綴,必填,說明該訂閱節點爲單一通知節點
this.ws.subscribe('/user/topic/reply', (msg) => {
console.log( msg.body);
});
//系統通知,其中、topic爲前綴,後面配置websocket使用了前綴topic,
this.ws.subscribe('/topic/notice', (msg) => {
console.log("notice");
console.log(msg.body);
});
});
}
七、配置後端
7.1 配置websocket
首先配置下springboot,讓其websocket配置支持sockjs
package com.correspond.mqtt.rabbitmq.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
/**
* @author Anumbrella
* 通過EnableWebSocketMessageBroker
* 開啓使用STOMP協議來傳輸基於代理(message broker)的消息,
* 此時瀏覽器支持使用@MessageMapping 就像支持@RequestMapping一樣。
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketConfig.class);
@Autowired
private MyChannelInterceptor inboundChannelInterceptor;
@Autowired
private AuthHandshakeInterceptor authHandshakeInterceptor;
@Autowired
private MyHandshakeHandler myHandshakeHandler;
// 配置消息代理,哪種路徑的消息會進行代理處理
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
//將其目的地前綴設置爲“/topic”這樣的話,設置"/topic"
// Spring就能知道所有目的地前綴爲“/topic”的消息都會發送到STOMP代理中。
registry.enableStompBrokerRelay("/topic")
.setRelayHost("localhost") // rabbitmq-host服務器地址
.setRelayPort(61613) // rabbitmq-stomp 服務器服務端口
.setClientLogin("guest") // 登陸賬戶
.setClientPasscode("guest"); // 登陸密碼
//定義一對一推送的時候前綴
registry.setUserDestinationPrefix("/user/");
//所有目的地以“/message”打頭的消息都將會路由到帶有@MessageMapping註解的方法中,而不會發布到代理隊列或主題中
//客戶端需要把消息發送到/message/xxx地址
registry.setApplicationDestinationPrefixes("/message");
}
/**
* 添加這個Endpoint,這樣在網頁中就可以通過websocket連接上服務,也就是我們配置websocket的服務地址,
* 並且可以指定是否使用socketjs
*
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*")
.setHandshakeHandler(myHandshakeHandler)
.addInterceptors(authHandshakeInterceptor) //添加攔截處理,這裏authHandshakeInterceptor 封裝的認證用戶信息
.withSockJS();
LOGGER.info("com.init rabbitmq websocket endpoint ");
}
/**
* 設置輸入消息通道的線程數,默認線程爲1,可以自己自定義線程數,最大線程數,線程存活時間
*
* @param registration
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(inboundChannelInterceptor);
registration.taskExecutor() // 線程信息
.corePoolSize(400) // 核心線程池
.maxPoolSize(800) // 最多線程池數
.keepAliveSeconds(60); // 超過核心線程數後,空閒線程超時60秒則殺死
}
/**
* 配置發送與接收的消息參數,可以指定消息字節大小,緩存大小,發送超時時間
*
* @param registration
*/
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setSendTimeLimit(15 * 1000) // 超時時間
.setSendBufferSizeLimit(512 * 1024) // 緩存空間
.setMessageSizeLimit(128 * 1024); // 消息大小
}
}
7.2 配置websocket握手連接器
由於本項目是採用前後端分離架構,採用了JWT+shiro鑑權。因爲我們要保證websocket服務器是不能被任意連接的,所以我們前端使用sockJS是還傳遞了一個Authorization參數,這個參數是用戶登錄成功後臺生成的一個token值,前端要想連接我們的sockJS服務,就必須帶着這個登錄的Authorization參數值,由我們的握手連接器去驗證,是否是合法連接。
package com.correspond.mqtt.rabbitmq.config;
import com.web.jwt.util.TokenUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
/**
* @author raven
*/
@Component
public class AuthHandshakeInterceptor implements HandshakeInterceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHandshakeInterceptor.class);
@Override
public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Map<String, Object> map) throws Exception {
LOGGER.info("===============before handshake=============");
// 比如,只有登錄後,纔可以進行websocket連接
ServletServerHttpRequest serverRequest = (ServletServerHttpRequest) serverHttpRequest;
String token = serverRequest.getServletRequest().getParameter("Authorization");
if (token != null &&!TokenUtil.verify(token) ) {
LOGGER.error("Token驗證失敗,連接失敗!");
return false;
}
return true;
}
@Override
public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) {
LOGGER.info("===============after handshake=============");
}
}
7.3 配置websocket握手處理器
握手之前驗證完之後,我們就需要準備開始握手的處理器了。包裝客戶端的信息。
package com.correspond.mqtt.rabbitmq.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
import javax.servlet.http.HttpServletRequest;
import java.security.Principal;
import java.util.Map;
/**
* @author raven
*/
@Component
public class MyHandshakeHandler extends DefaultHandshakeHandler {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHandshakeInterceptor.class);
@Override
protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
System.out.println("--------------------------------");
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletServerHttpRequest = (ServletServerHttpRequest) request;
HttpServletRequest httpRequest = servletServerHttpRequest.getServletRequest();
/**
* 這邊就獲取用戶唯一信息用來包裝
*/
final String token = httpRequest.getParameter("Authorization");
return () -> token;
}
return null;
}
}
7.4 配置管道攔截器
握手成果之後,便開始對消息管道進行攔截,因爲我們要用到Stomp代理我們的消息。
package com.correspond.mqtt.rabbitmq.config;
import com.correspond.mqtt.rabbitmq.entity.MyPrincipal;
import com.correspond.mqtt.rabbitmq.util.SocketSessionRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import java.util.LinkedList;
import java.util.Map;
/**
* @author Anumbrella
*/
@Component
//ChannelInterceptorAdapter廢棄了
public class MyChannelInterceptor implements ChannelInterceptor {
@Autowired
private SocketSessionRegistry webAgentSessionRegistry;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
/**
* 1. 判斷是否爲首次連接請求,如果已經連接過,直接返回message
* 、
*/
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
System.out.println("連接success");
Object raw = message.getHeaders().get(SimpMessageHeaderAccessor.NATIVE_HEADERS);
if (raw instanceof Map) {
Object nameObj = ((Map) raw).get("name");
if (nameObj instanceof LinkedList) {
String name = ((LinkedList) nameObj).get(0).toString();
System.out.println("name:"+name);
//設置當前訪問器的認證用戶
accessor.setUser(new MyPrincipal(name));
String sessionId = accessor.getSessionId();
// 統計用戶在線數,可通過redis來實現更好
webAgentSessionRegistry.registerSessionId(name, sessionId);
}
}
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
//點擊斷開連接,這裏會執行兩次,第二次執行的時候,message.getHeaders.size()=5,第一次是6。直接關閉瀏覽器,只會執行一次,size是5。
System.out.println("斷開連接");
MyPrincipal principal = (MyPrincipal) message.getHeaders().get(SimpMessageHeaderAccessor.USER_HEADER);
// 如果同時發生兩個連接,只有都斷開才能叫做不在線
if (message.getHeaders().size() == 5 && principal.getName() != null) {
String sessionId = accessor.getSessionId();
webAgentSessionRegistry.unregisterSessionId(principal.getName(), sessionId);
}
}
return message;
}
}
7.5 實現Principal
最後實現自己的Principal :
package com.correspond.mqtt.rabbitmq.entity;
import java.security.Principal;
/**
* @author raven
*/
public class MyPrincipal implements Principal {
private String loginName;
public MyPrincipal(String loginName) {
this.loginName = loginName;
}
@Override
public String getName() {
return loginName;
}
}
這時候,我們的初始配置就完成了。接下來看看具體使用吧。
我們既可以提供http的形式推送消息,也可以使用原生的stomp推送消息。
首先看看http形式如何發佈數據。
如下代碼。
@Controller
@RequestMapping("/push_msg")
public class SendController {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@GetMapping("/notice")
public void notice(String msg) {
//對所有用戶通知消息
messagingTemplate.convertAndSend("/topic/notice", msg);
}
@GetMapping("/user/{username}/{type}")
public void notice(@PathVariable String username, @PathVariable String type,String msg) {
messagingTemplate.convertAndSendToUser(username+"@"+type
, "/topic/reply",msg);
}
}
爲了方便演示,我現在登錄了兩個用戶,左圖是admin,右圖是test。
這時候,直接在瀏覽器內訪問 /push_msg/notice?msg=hello world!
,因爲兩個用戶已經訂閱了系統通知節點/topic/notice
,所以可以看到消息已經推送至每個用戶。
系統通知沒有問題了。我們再試試對特定用戶推送消息吧。
直接在瀏覽器訪問 /push_msg/user/admin/web?msg=林深時見鹿
,因爲兩個用戶都已經訂閱了自己獨有的通知節點/user/topic/reply
,且消息發送的路徑用戶爲admin,所以可以看到消息已經推送至admin用戶而沒有推送給test用戶。
如果我們想在前端通過Stmop發送消息呢?而不是通過HTTP,因爲這只是一個測試,就安全角度來講,直接使用HTTP推送消息也需要對該接口加密,而不是任意人都可以使用HTTP方式推送消息。
使用Sockjs,可以使用如下方式給後端推送信息。
//爲什麼要加前綴message呢?因爲7.1配置只攔截/message 開頭的消息
this.ws.send("/message/test", {}, JSON.stringify({'name': "123456"}));
可以看出來,使用註解@DestinationVariable
獲取路徑裏面的參數值
@Controller
public class ReceiveController {
@MessageMapping("/web.{name}")
@SendToUser("/topic/reply")
public String say(String message, @DestinationVariable("name") String name) throws Exception {
System.out.println("name:"+name+"用戶來消息啦");
return name+"發送:"+message;
}
}
由於我們使用了@SendToUser
註解,所以發送完成以後可以看到後臺給前端推送的消息。
如果我們想把該用戶傳來的消息發送給所有用戶,可以直接使用@SendTo("/topic/notice")
註解,該場景適用於聊天羣組消息推送。
場景一:如果我們現在想做一個聊天軟件,用戶A如何給用戶B發送消息呢?很簡單。
如下圖可見,當我們點擊發送按鈕的時候,會發送一個格式化的字符串給後臺,該字符串包含了當前輸入的消息以及和要發送給的用戶。這裏爲了方便演示,toUser寫死爲test,也就是說,現在我登錄admin用戶給test用戶發信息看看test是否會受到呢?
我們先來看看前端怎麼寫:
我們再來看看後端怎麼寫
@Controller
public class ReceiveController {
@Autowired
private SimpMessagingTemplate messagingTemplate;
@MessageMapping("toFriend")
public void toFriendMsg(String message){
JSONObject msgJSON = JSON.parseObject(message);
String username = msgJSON.getString("toUser");
String msg = msgJSON.getString("message");
messagingTemplate.convertAndSendToUser(username+"@web", "/topic/reply",
msg);
}
}
接下來看看效果圖,test用戶完全可以收到admin發送的消息,所以也就很輕鬆解決了該場景問題。
總結
本畢設現在已經到了尾聲階段,採用了前後端分離架構,加持shiro進行接口權限驗證以及JWT無session保障用戶狀態。本篇博客總結了畢設的消息推送模塊,應用如上應用場景,我們可以輕輕鬆鬆寫一個適合自己的消息推送系統。本篇博客就到這裏了,本人能力有限,如有地方書寫錯誤,請留言批評指正!