【Java分享客棧】SpringBoot整合WebSocket+Stomp搭建群聊專案

福隆苑居士發表於2022-04-05

前言

前兩週經常有大學生小夥伴私信給我,問我可否有償提供畢設幫助,我說暫時沒有這個打算,因為工作實在太忙,現階段無法投入到這樣的領域內,其中有兩個小夥伴又問到我websocket該怎麼使用,想給自己的專案中加入這樣的技術。

剛好我所在的公司有做問診服務,裡面就使用了websocket實現聊天通訊,就在閒暇之餘專門把部分程式碼摘取出來,做了一個簡單的demo分享給他們了,之後想想這塊可以再豐富一下,就花時間又做了一個更完整的小專案出來,且加了詳細的註釋說明,分享給對websocket感興趣的小夥伴們。


案例展示

聊天案例演示.gif


技術棧

考慮到不同群體對vue等前端技術的接受程度,本案例採用了HTML+CSS+JQuery來實現,程式碼直接複製到vue專案中也是一樣的,只是賦值和取值的方式改變而已,很多Java程式設計師其實對於一門簡單案例的學習不喜歡牽扯太多前端技術,而是單純學習想知道的這門技術就好,太多其他的引入反而影響跟蹤除錯,而原始的HTML+JS方式更有利於我們學習和理解,只需要右鍵HTML頁面在瀏覽器開啟進行F12除錯即可。

技術 版本
Java 1.8
SpringBoot 2.3.12.RELEASE
WebSocket 2.3.12.RELEASE
Hutools 5.8.0.M1
SockJS 1.6.0
StompJS 1.7.1

實現過程

1、引入依賴

<!-- websocket -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

<!-- Hutools工具類 -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.0.M1</version>
</dependency>

2、訂閱常量類

後面的websocket配置類會用到這幾個常量

stomp端點地址: 連線websocket時的字尾地址,比如127.0.0.1:8888/websocket。

websocket字首:前端調服務端訊息介面時的URL都加上了這個字首,比如預設是/send,變成/app/send。

點對點代理地址:如果websocket配置類中設定了代理路徑,一般點對點訂閱路徑喜歡用/queue。

廣播代理地址:如果websocket配置類中設定了代理路徑,一般廣播訂閱路徑喜歡用這個/topic。

package com.simple.ws.constants;

/**
 * <p>
 * websocket常量
 * </p>
 *
 * @author 福隆苑居士,公眾號:【Java分享客棧】
 * @since 2022-04-02 10:11
 */
public class WsConstants {

   // stomp端點地址
   public static final String WEBSOCKET_PATH = "/websocket";

   // websocket字首
   public static final String WS_PERFIX = "/app";

   // 訊息訂閱地址常量
   public static final class BROKER {
      // 點對點訊息代理地址
      public static final String BROKER_QUEUE = "/queue/";
      // 廣播訊息代理地址
      public static final String BROKER_TOPIC = "/topic";
   }
}

3、WebSocket配置類

核心內容講解:

1)、@EnableWebSocketMessageBroker:用於開啟stomp協議,這樣就能支援@MessageMapping註解,類似於@requestMapping一樣,同時前端可以使用Stomp客戶端進行通訊;

2)、registerStompEndpoints實現:主要用來註冊端點地址、開啟跨域授權、增加攔截器、宣告SockJS,這也是前端選擇SockJS的原因,因為spring專案本身就支援;

3)、configureMessageBroker實現:主要用來設定客戶端訂閱訊息的路徑(可以多個)、點對點訂閱路徑字首的設定、訪問服務端@MessageMapping介面的字首路徑、心跳設定等;

package com.simple.ws.config;

import com.simple.ws.constants.WsConstants;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;

/**
 * <p>
 * websocket核心配置類
 * </p>
 *
 * @author 福隆苑居士,公眾號:【Java分享客棧】
 * @since 2022/4/1 22:57
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {

   /**
    * 註冊stomp端點
    *
    * @param registry stomp端點註冊物件
    */
   @Override
   public void registerStompEndpoints(StompEndpointRegistry registry) {
      registry.addEndpoint(WsConstants.WEBSOCKET_PATH)
            .setAllowedOrigins("*")
            .withSockJS();
   }

   /**
    * 配置訊息代理
    *
    * @param registry 訊息代理註冊物件
    */
   @Override
   public void configureMessageBroker(MessageBrokerRegistry registry) {

      // 配置服務端推送訊息給客戶端的代理路徑
      registry.enableSimpleBroker(WsConstants.BROKER.BROKER_QUEUE, WsConstants.BROKER.BROKER_TOPIC);
      
      // 定義點對點推送時的字首為/queue
      registry.setUserDestinationPrefix(WsConstants.BROKER.BROKER_QUEUE);
      
      // 定義客戶端訪問服務端訊息介面時的字首
      registry.setApplicationDestinationPrefixes(WsConstants.WS_PERFIX);
   }
}

特別說明:如果對於配置類中這幾個路徑的設定看不明白,沒關係,後面的前端部分你一看就懂了。


4、訊息介面

說明:

1)、訊息介面使用@MessageMapping註解,前面講的配置類@EnableWebSocketMessageBroker註解開啟後才能使用這個;

2)、這裡稍微提一下,真正線上專案都是把websocket服務做成單獨的閘道器形式,提供rest介面給其他服務呼叫,達到共用的目的,本專案因為不涉及任何資料庫互動,所以直接用@MessageMapping註解,後續完整IM專案接入具體業務後會做一個獨立的websocket服務,敬請關注哦!

package com.simple.ws.controller;

import com.simple.ws.constants.WsConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

/**
 * <p>
 * 訊息介面
 * </p>
 *
 * @author 福隆苑居士,公眾號:【Java分享客棧】
 * @since 2022-04-02 12:00
 */
@RestController
@RequestMapping("/api")
@Slf4j
public class MsgController {

   private final SimpMessagingTemplate messagingTemplate;

   public MsgController(SimpMessagingTemplate messagingTemplate) {
      this.messagingTemplate = messagingTemplate;
   }

   /**
    * 傳送廣播訊息
    * -- 說明:
    *       1)、@MessageMapping註解對應客戶端的stomp.send('url');
    *       2)、用法一:要麼配合@SendTo("轉發的訂閱路徑"),去掉messagingTemplate,同時return msg來使用,return msg會去找@SendTo註解的路徑;
    *       3)、用法二:要麼設定成void,使用messagingTemplate來控制轉發的訂閱路徑,且不能return msg,個人推薦這種。
    *
    * @param msg 訊息
    */
   @MessageMapping("/send")
   public void sendAll(@RequestParam String msg) {

      log.info("[傳送訊息]>>>> msg: {}", msg);

      // 傳送訊息給客戶端
      messagingTemplate.convertAndSend(WsConstants.BROKER.BROKER_TOPIC, msg);
   }
   

5、前端專案結構

很簡單,就是HTML+CSS和幾個js檔案,sockjs和stompjs就是和服務端通訊的實現,可以從GitHub官網下載,而websocket.js是我們自己封裝的和服務端通訊的內容。

111.png


6、Stomp客戶端使用

頁面結構樣式這裡就省略不講了,直接開始正文。

stompjs,是對websocket原生使用的一層封裝,提供了更簡單的呼叫方法。

這裡先看看我們自己封裝的websocket.js的實現:

1)、宣告URL

也就是服務端配置類的端點地址

var url = "http://127.0.0.1:8888/websocket"; // 改成自己的服務端地址

2)、建立websocket連線

stompClient = Stomp.over(socket)就是覆蓋了sockjs使用自己的客戶端來操作ws;

進入stompClient.connect()中就代表連線成功,可以進行自己的業務處理比如廣播通知某人上線等等,重要的是連線成功後要宣告訂閱列表,這樣服務端轉發的訊息才會根據這些訂閱地址傳送過來,否則收不到;

最後就是有一個回撥可以捕獲異常情況,在裡面可以做一些操作比如重連等等。

/**
 * 連線
 */
function connect() {
    userId =  GetUrlParam("userId");
    var socket = new SockJS(url, null, { timeout: 15000});
    stompClient = Stomp.over(socket); // 覆蓋sockjs使用stomp客戶端
    stompClient.connect({}, function (frame) {

        console.log('frame: ' + frame)

        // 連線成功後廣播通知
        sendNoticeMsg(userId, "in");

        /**
         * 訂閱列表,訂閱路徑和服務端發訊息路徑一致就能收到訊息。
         * -- /topic: 服務端配置的廣播訂閱路徑
         * -- /queue/: 服務端配置的點對點訂閱路徑
         */
        stompClient.subscribe("/topic", function (response) {
                showMsg(response.body);
        });

        stompClient.subscribe("/queue/" + userId + "/topic", function (response) {
                showMsg(response.body);
        });

        // 異常時進行重連
        }, function (error) {
            console.log('connect error: ' + error)
            if (reConnectCount > 10) {
                    console.log("溫馨提示:您的連線已斷開,請退出後重新進入。")
                    reConnectCount = 0;
            } else {
                    wsReconnect && clearTimeout(wsReconnect);
                    wsReconnect = setTimeout(function () {
                            console.log("開始重連...");
                            connect();
                            console.log("重連完畢...");
                            reConnectCount++;
                    }, 1000);
            }
        }
    )
}

3)、斷開websocket連線

斷開很簡單,但要注意一點,不要根據關閉視窗或瀏覽器的事件來控制斷開,這是一個誤區,首先瀏覽器相容性差異較大,傳統的js在監聽視窗關閉事件的相容性上是很差的,這個可以自己試驗就知道了,有些瀏覽器可以有些不可以;


其次,可以參考QQ,你自己在退出一個群聊的時候實際上你就單純是關閉了,並沒有離線,而是你退出QQ時才真正離線,所以真正控制這個斷開方法的位置應該是點選退出按鈕時,這一點不要理解錯了。

/**
 * 斷開
 */
function disconnect() {
    if (stompClient != null) {
        // 斷開連線時進行廣播通知
        sendNoticeMsg(userId, "out");
        // 斷開連線
        stompClient.disconnect(function(){
                // 有效斷開的回撥
                console.log(userId + "斷開連線....")
        });
    }
}

4)、訊息滾動到底部

這個沒什麼說的,在進入頁面以及傳送訊息後渲染頁面時使用即可。

// 訊息視窗滾動到底部
function scrollBotton(){
    var div = document.getElementById("content");
    div.scrollTop = div.scrollHeight;
}

5)、聊天訊息渲染到頁面

這裡就是單純的JQuery操作了,注意的一點是這裡加了個type判斷是系統訊息還是聊天訊息,在本案例中,系統訊息就是某人上下線的提示,聊天訊息就是傳送出來的內容。

在vue這樣的框架中,這部分的操作其實會很簡單。

/**
 * 聊天訊息渲染到頁面中
 */
function showMsg(obj) {
    obj = JSON.parse(obj);
    var userId = obj.userId;
    var sendTime = obj.sendTime;
    var info = obj.info;
    var type = obj.type;

    if (1 === type) {
        // 聊天訊息
        console.log("聊天訊息...")
        var msgHtml = "<div class=\"msg\" id=\"msg\">" + 
                          "  <div class=\"first-line\">" + 
                          "	   <div class=\"userName\" id=\"userName\">" + userId + "</div>" +  
                          "	   <div class=\"sendTime\" id=\"sendTime\">" + sendTime + "</div>" + 
                          "  </div>" + 
                          "  <div class=\"second-line\">" + 
                          "    <div class=\"sendMsg\" id=\"sendMsg\">" + info + "</div>" + 
                          "  </div>" + 
                          "</div>";

        // 渲染到頁面	
        $("#content").html($("#content").html() + "\n" + msgHtml);

} else if (2=== type) {
        // 系統訊息
        console.log("系統訊息...")
        var msgHtml = "<div class=\"notice\">" + 
                                "<div class=\"notice-info\">" + info + "</div>" + 
                          "</div>";

        // 渲染到頁面	
        $("#content").html($("#content").html() + "\n" + msgHtml);
    }

    // 訊息視窗滾動到底部
    scrollBotton();
}

6)、傳送群聊訊息

這裡傳遞的obj定義了一個訊息體,就是一個物件,真正專案中也是這般使用,而不是單純傳遞一個文字;


stompClient.send中的url,其中/app是服務端配置類中設定的ApplicationDestinationPrefixes,而/send就是controller介面中@MessageMapping("/send")的路徑,兩個加在一起就是這裡前端傳送的路徑,少一個或多一個斜槓都會導致服務端收不到訊息。

/**
* 傳送群聊訊息
* -- 這裡我們傳遞訊息體物件 
* 	{
*         "userId": userId, // 傳送者
*         "sendTime": sendTime, // 傳送時間
*         "info": info, // 傳送內容
*         "type": 1  // 訊息型別,1-聊天訊息,2-系統訊息
*       }
*/
function sendAll(obj) {
    stompClient.send("/app/send", {}, JSON.stringify(obj));
}

7)、傳送系統訊息

就是傳遞type=2即可,info做了下判斷返回不同的訊息內容。

/**
 * 傳送系統通知訊息
 * @param userId 使用者id 
 */
function sendNoticeMsg(userId, action) {
    var obj = {
        "userId": userId,
        "sendTime": new Date().Format("HH:mm:ss"),
        "info": "in" === action ? userId + "進入房間" : userId + "離開房間",
        "type": 2
    }
    sendAll(obj);
}

7、聊天頁發訊息

index.html就是聊天主頁面,直接呼叫我們前面封裝好的websocket.js方法即可。

主要步驟為:進入頁面時建立websocket連線 --> 獲取登入使用者資訊 --> 監聽按鈕點選事件和鍵盤事件 --> 傳送websocket訊息 --> 清空文字框內容

這樣,一旦傳送訊息成功,服務端就可以看到接收到的訊息體並根據傳送路徑進行轉發,前端websocket.js中訂閱列表中的路徑一旦和服務端轉發的路徑匹配上,就會收到訊息,我們把訊息渲染到頁面上即可。

這個過程其實也就是websocket全雙工通訊的原理

<script>

    $(function() {
        // 啟動websocket
        connect();

        // 獲取使用者資訊
        getUser();

        // 訊息視窗滾動到底部
        scrollBotton();

        // 監聽鍵盤Enter鍵,要用keyup,否則無法清除換行符。
        $("#send-info").keyup(function(e) {
            var eCode = e.keyCode ? e.keyCode : e.which ? e.which : e.charCode;
            if (eCode == 13){
                $("#send-btn").click();
            }
        });

        // 監聽傳送按鈕點選事件
        $("#send-btn").click(function() {
            send();
        });

        // 監聽退出按鈕點選事件
        $("#exit-btn").click(function() {
            layer.confirm('你確定要退出嗎?', {
                time: 0, // 不自動關閉
                btn: ['確定退出', '再玩玩'],
                yes: function(index){
                    layer.close(index);
                    disconnect();
                    window.location.href = 'login.html';
                }
            });
        });


    });

    // 獲取使用者資訊
    function getUser() {
        var userId =  GetUrlParam("userId");
        $("#userId").text(userId);
    }

    // 傳送廣播訊息,這裡定義一個type:1-聊天訊息,2-系統訊息。
    function send() {
        var userId = $("#userId").text().trim();
        var sendTime = new Date().Format("HH:mm:ss");
        var info = $("#send-info").val().replace("\n", "");
        var msg = {
            "userId": userId,
            "sendTime": sendTime,
            "info": info,
            "type": 1
        }
        // 傳送訊息
        sendAll(msg);

        // 清空文字域內容
        $("#send-info").val("");
    }

</script>

避坑指南

1)、版本問題,經本人專門花時間測試,SpringBoot2.4.0以下版本才能整合SockJS和StompJS成功,以上的版本都不行,會報 Main site uses: "1.6.0", the iframe: "1.0.0" 這樣的錯誤,將StompJS換成低版本也不行,所以這裡整合時用了SpringBoot2.3.12.RELEASE版本,但這個沒關係,websocket服務一般都是單獨做成一個服務的,如果是微服務,你的其他業務服務使用高版本的SpringBoot就行了;

2)、監聽視窗關閉事件不可取,這個在前面已經講過了,瀏覽器相容性差,我試過好幾個瀏覽器監聽效果都各不相同甚至完全無效,其次本身這樣操作也不合理,我們只要保證退出時觸發斷開事件即可,無需在這樣的事情上浪費時間,可以參考QQ;

3)、服務端編寫訊息介面時推薦使用SimpMessagingTemplate來控制傳送,而不是@SendTo註解,因為前者更符合程式設計師開發思路,後期獨立websocket服務暴露rest介面時也更簡單;

4)、配置類中其實還有很多其他配置項,比如心跳配置、攔截器配置等,本案例沒有加入進來,因為我自己公司的專案中其實使用過心跳,但後來又去掉了,因為對這塊瞭解不深入的話貿然使用容易出現稀奇古怪的問題。

講個趣事,我們前端工程師當初就因為心跳這塊除錯了挺久,上線後依然會出現時好時壞的情況,因為他之前也沒做過websocket都是現學的,而且線上環境和測試環境差異難明,包含程式缺陷、網路環境因素等等,後來我們決定去掉心跳檢測,之後兩年也沒出任何問題。

所以有時候保證專案穩定性反而更有用,但處於學習的角度而言,心跳檢測是一定需要的,否則所有的socket框架也不會專門提供這樣的方案了。


總結

SpringBoot+websocket的實現其實不難,你可以使用原生的實現,也就是websocket本身的OnOpen、OnClosed等等這樣的註解來實現,以及對WebSocketHandler的實現,類似於netty的那種使用方式,而且原生的還提供了對websocket的監聽,服務端能更好的控制及統計。

但根據我個人的經驗而言,真實專案中還是使用Stomp實現的居多,因為獨立服務更方便,便於後期搭建叢集環境做橫向擴充套件,且內建的方法也很簡單,既然如此,我們還是以主流實現方式為準來學習吧。


原始碼

連結: https://pan.baidu.com/s/1D34kJ1TO4evQlvUwHiN7eg?pwd=ht71
提取碼: ht71


後續會根據本案例進行優化,設計具體的業務表,實現群聊、單聊、心跳檢測,同時前端以vue3來搭建,實現一個完整的IM應用,有興趣的可以關注下本人以獲取最新資訊哦~



本人原創文章純手打,覺得有一滴滴幫助的話,就請點個贊和推薦吧,鞠躬~


相關文章