SpringBoot進階教程(七十七)WebSocket

WebSocket是一種在單個TCP連接上進行全雙工通信的協議。WebSocket使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在WebSocket API中,瀏覽器和服務器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,並進行雙向數據傳輸。

v原理

很多網站爲了實現推送技術,所用的技術都是輪詢。輪詢是在特定的時間間隔(如每1秒),由瀏覽器對服務器發出HTTP請求,然後由服務器返回最新的數據給客戶端的瀏覽器。這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向服務器發出請求,然而HTTP請求可能包含較長的頭部,其中真正有效的數據可能只是很小的一部分,顯然這樣會浪費很多的帶寬等資源。

而比較新的技術去做輪詢的效果是Comet。這種技術雖然可以雙向通信,但依然需要反覆發出請求。而且在Comet中,普遍採用的長鏈接,也會消耗服務器資源。

在這種情況下,HTML5定義了WebSocket協議,能更好的節省服務器資源和帶寬,並且能夠更實時地進行通訊。

v架構搭建

添加maven引用

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

配置應用屬性

server.port=8300
spring.thymeleaf.mode=HTML
spring.thymeleaf.cache=true
spring.thymeleaf.prefix=classpath:/web/
spring.thymeleaf.encoding: UTF-8
spring.thymeleaf.suffix: .html
spring.thymeleaf.check-template-location: true
spring.thymeleaf.template-resolver-order: 1

添加WebSocketConfig

package com.test.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * @Author chen bo
 * @Date 2023/10
 * @Des
 */
@Configuration
public class WebSocketConfig {
    /**
     * bean註冊:會自動掃描帶有@ServerEndpoint註解聲明的Websocket Endpoint(端點),註冊成爲Websocket bean。
     * 要注意,如果項目使用外置的servlet容器,而不是直接使用springboot內置容器的話,就不要注入ServerEndpointExporter,因爲它將由容器自己提供和管理。
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

添加WebSocket核心類

因爲WebSocket是類似客戶端服務端的形式(採用ws協議),那麼這裏的WebSocketServer其實就相當於一個ws協議的Controller

直接@ServerEndpoint("/imserver/{userId}")@Component啓用即可,然後在裏面實現@OnOpen開啓連接,@onClose關閉連接,@onMessage接收消息等方法。

新建一個ConcurrentHashMap用於接收當前userId的WebSocket或者Session信息,方便IM之間對userId進行推送消息。單機版實現到這裏就可以。集羣版(多個ws節點)還需要藉助 MySQL或者 Redis等進行訂閱廣播方式處理,改造對應的 sendMessage方法即可。

package com.test.util;

import com.google.gson.JsonParser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import com.google.gson.JsonObject;

/**
 * WebSocket的操作類
 * html頁面與之關聯的接口
 * var reqUrl = "http://localhost:8300/websocket/" + cid;
 * socket = new WebSocket(reqUrl.replace("http", "ws"));
 */
@Component
@Slf4j
@ServerEndpoint("/websocket/{sid}")
public class WebSocketServer {

    /**
     * 靜態變量,用來記錄當前在線連接數,線程安全的類。
     */
    private static AtomicInteger onlineSessionClientCount = new AtomicInteger(0);

    /**
     * 存放所有在線的客戶端
     */
    private static Map<String, Session> onlineSessionClientMap = new ConcurrentHashMap<>();

    /**
     * 連接sid和連接會話
     */
    private String sid;
    private Session session;

    /**
     * 連接建立成功調用的方法。由前端<code>new WebSocket</code>觸發
     *
     * @param sid     每次頁面建立連接時傳入到服務端的id,比如用戶id等。可以自定義。
     * @param session 與某個客戶端的連接會話,需要通過它來給客戶端發送消息
     */
    @OnOpen
    public void onOpen(@PathParam("sid") String sid, Session session) {
        /**
         * session.getId():當前session會話會自動生成一個id,從0開始累加的。
         */
        log.info("連接建立中 ==> session_id = {}, sid = {}", session.getId(), sid);
        //加入 Map中。將頁面的sid和session綁定或者session.getId()與session
        //onlineSessionIdClientMap.put(session.getId(), session);
        onlineSessionClientMap.put(sid, session);

        //在線數加1
        onlineSessionClientCount.incrementAndGet();
        this.sid = sid;
        this.session = session;
        sendToOne(sid, "上線了");
        log.info("連接建立成功,當前在線數爲:{} ==> 開始監聽新連接:session_id = {}, sid = {},。", onlineSessionClientCount, session.getId(), sid);
    }

    /**
     * 連接關閉調用的方法。由前端<code>socket.close()</code>觸發
     *
     * @param sid
     * @param session
     */
    @OnClose
    public void onClose(@PathParam("sid") String sid, Session session) {
        //onlineSessionIdClientMap.remove(session.getId());
        // 從 Map中移除
        onlineSessionClientMap.remove(sid);

        //在線數減1
        onlineSessionClientCount.decrementAndGet();
        log.info("連接關閉成功,當前在線數爲:{} ==> 關閉該連接信息:session_id = {}, sid = {},。", onlineSessionClientCount, session.getId(), sid);
    }

    /**
     * 收到客戶端消息後調用的方法。由前端<code>socket.send</code>觸發
     * * 當服務端執行toSession.getAsyncRemote().sendText(xxx)後,前端的socket.onmessage得到監聽。
     *
     * @param message
     * @param session
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        /**
         * html界面傳遞來得數據格式,可以自定義.
         * {"sid":"user","message":"hello websocket"}
         */
        JsonObject jsonObject = JsonParser.parseString(message).getAsJsonObject();
        String toSid = jsonObject.get("sid").getAsString();
        String msg = jsonObject.get("message").getAsString();
        log.info("服務端收到客戶端消息 ==> fromSid = {}, toSid = {}, message = {}", sid, toSid, message);

        /**
         * 模擬約定:如果未指定sid信息,則羣發,否則就單獨發送
         */
        if (toSid == null || toSid == "" || "".equalsIgnoreCase(toSid)) {
            sendToAll(msg);
        } else {
            sendToOne(toSid, msg);
        }
    }

    /**
     * 發生錯誤調用的方法
     *
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("WebSocket發生錯誤,錯誤信息爲:" + error.getMessage());
        error.printStackTrace();
    }

    /**
     * 羣發消息
     *
     * @param message 消息
     */
    private void sendToAll(String message) {
        // 遍歷在線map集合
        onlineSessionClientMap.forEach((onlineSid, toSession) -> {
            // 排除掉自己
            if (!sid.equalsIgnoreCase(onlineSid)) {
                log.info("服務端給客戶端羣發消息 ==> sid = {}, toSid = {}, message = {}", sid, onlineSid, message);
                toSession.getAsyncRemote().sendText(message);
            }
        });
    }

    /**
     * 指定發送消息
     *
     * @param toSid
     * @param message
     */
    private void sendToOne(String toSid, String message) {
        // 通過sid查詢map中是否存在
        Session toSession = onlineSessionClientMap.get(toSid);
        if (toSession == null) {
            log.error("服務端給客戶端發送消息 ==> toSid = {} 不存在, message = {}", toSid, message);
            return;
        }
        // 異步發送
        log.info("服務端給客戶端發送消息 ==> toSid = {}, message = {}", toSid, message);
        toSession.getAsyncRemote().sendText(message);
        /*
        // 同步發送
        try {
            toSession.getBasicRemote().sendText(message);
        } catch (IOException e) {
            log.error("發送消息失敗,WebSocket IO異常");
            e.printStackTrace();
        }*/
    }

}

添加controller

package com.test.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletResponse;

/**
 * @Author chen bo
 * @Date 2023/10
 * @Des
 */
@Controller
public class HomeController {
    /**
     * 跳轉到websocketDemo.html頁面,攜帶自定義的cid信息。
     * http://localhost:8300/demo/toWebSocketDemo/user
     *
     * @param cid
     * @param model
     * @return
     */
    @GetMapping("/demo/toWebSocketDemo/{cid}")
    public String toWebSocketDemo(@PathVariable String cid, Model model) {
        model.addAttribute("cid", cid);
        return "index";
    }

    @GetMapping("hello")
    @ResponseBody
    public String hi(HttpServletResponse response) {
        return "Hi";
    }
}

添加html

注意:html文件添加在application.properties配置的對應目錄中。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>聊天窗口</title>
</head>
<body>
<div>
我的用戶名:
<input type="text" th:value="${cid}" readonly="readonly" id="cid"/>
</div>
<div id="chat-windows" style="width: 400px; height: 300px;overflow: scroll;border: blue 1px solid;"></div>
<div>收消息人用戶名:<input id="toUserId" name="toUserId" type="text"></div>
<div>輸入你要說的話:<input id="contentText" name="contentText" type="text"></div>
<div>
    <button type="button" onclick="sendMessage()">發送消息</button>
</div>
</body>

<script type="text/javascript">
    var socket;
    if (typeof (WebSocket) == "undefined") {
        alert("您的瀏覽器不支持WebSocket");
    } else {
        console.log("您的瀏覽器支持WebSocket");
        //實現化WebSocket對象,指定要連接的服務器地址與端口  建立連接

        var cid = document.getElementById("cid").value;
        console.log("cid-->" + cid);
        var reqUrl = "http://localhost:8300/websocket/" + cid;
        socket = new WebSocket(reqUrl.replace("http", "ws"));
        //打開事件
        socket.onopen = function () {
            console.log("Socket 已打開");
            //socket.send("這是來自客戶端的消息" + location.href + new Date());
        };
        //獲得消息事件
        socket.onmessage = function (msg) {
            console.log("onmessage--" + msg.data);
            //發現消息進入    開始處理前端觸發邏輯
            var chatWindows = document.getElementById("chat-windows");
            var pElement = document.createElement('p')
            pElement.innerText = msg.data;
            chatWindows.appendChild(pElement);
        };
        //關閉事件
        socket.onclose = function () {
            console.log("Socket已關閉");
        };
        //發生了錯誤事件
        socket.onerror = function () {
            alert("Socket發生了錯誤");
            //此時可以嘗試刷新頁面
        }
        //離開頁面時,關閉socket
        //jquery1.8中已經被廢棄,3.0中已經移除
        // $(window).unload(function(){
        //     socket.close();
        //});
    }

    function sendMessage() {
        if (typeof (WebSocket) == "undefined") {
            alert("您的瀏覽器不支持WebSocket");
        } else {
            var toUserId = document.getElementById('toUserId').value;
            var contentText = document.getElementById('cid').value + ":" + document.getElementById('contentText').value;
            var msg = '{"sid":"' + toUserId + '","message":"' + contentText + '"}';
            console.log(msg);
            var chatWindows = document.getElementById("chat-windows");
            var chatWindows = document.getElementById("chat-windows");
            var pElement = document.createElement('p');
            pElement.innerText = "我:" + document.getElementById('contentText').value;
            chatWindows.appendChild(pElement);
            socket.send(msg);
        }
    }

</script>
</html>

1對1模擬演練

啓動項目後,在瀏覽器訪問http://localhost:8300/demo/toWebSocketDemo/{cid} 跳轉到對應頁面,其中cid是用戶名。

爲了便於1對1測試,這裏我們啓動兩個瀏覽器窗口。

http://localhost:8300/demo/toWebSocketDemo/陽光男孩

http://localhost:8300/demo/toWebSocketDemo/水晶女孩

按照要求輸入對方用戶信息之後,便可以輸入你要說的話,暢快聊起來了。

效果圖如下:

請叫我頭頭哥

當然,如果收消息人用戶名是自己的話,也可以自己給自己發送數據的。

羣發模擬演練

爲了便於羣發測試,這裏我們啓動3個瀏覽器窗口。

http://localhost:8300/demo/toWebSocketDemo/陽光男孩

http://localhost:8300/demo/toWebSocketDemo/水晶女孩

http://localhost:8300/demo/toWebSocketDemo/路人A

由於sendToAll方法中定義羣發的條件爲:當不指定 toUserid時,則爲羣發。

效果圖如下:

請叫我頭頭哥

項目架構圖如下:

請叫我頭頭哥

v源碼地址

https://github.com/toutouge/javademosecond


作  者:請叫我頭頭哥
出  處:http://www.cnblogs.com/toutou/
關於作者:專注於基礎平臺的項目開發。如有問題或建議,請多多賜教!
版權聲明:本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文鏈接。
特此聲明:所有評論和私信都會在第一時間回覆。也歡迎園子的大大們指正錯誤,共同進步。或者直接私信
聲援博主:如果您覺得文章對您有幫助,可以點擊文章右下角推薦一下。您的鼓勵是作者堅持原創和持續寫作的最大動力!

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