如何利用springboot快速搭建一個消息推送系統

最近在完善畢設的路上,由於是設計一個遠程控制物聯網系統,所以服務端到硬件我選用了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保障用戶狀態。本篇博客總結了畢設的消息推送模塊,應用如上應用場景,我們可以輕輕鬆鬆寫一個適合自己的消息推送系統。本篇博客就到這裏了,本人能力有限,如有地方書寫錯誤,請留言批評指正!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章