Spring Boot 整合 WebSocket 使用記錄

前言

由於項目需要定時將消息從Web端推送至客戶端
通常使用的方式有:AJAX輪詢、XHR長輪詢、iframe、Comet、websocket等

部分詳情可見:https://blog.csdn.net/qq_43225978/article/details/105396640

考慮實現的難度及複雜度,最終選用WebSocket方式。

WebSocket 簡介

WebSocket 是 HTML5 開始提供的一種在單個 TCP 連接上進行全雙工通訊的協議。

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

在 WebSocket API 中,瀏覽器和服務器只需要做一個握手的動作,然後,瀏覽器和服務器之間就形成了一條快速通道。兩者之間就直接可以數據互相傳送。

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

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

在這裏插入圖片描述

瀏覽器通過 JavaScript 向服務器發出建立 WebSocket 連接的請求,連接建立以後,客戶端和服務器端就可以通過 TCP 連接直接交換數據。

當你獲取 Web Socket 連接後,你可以通過 send() 方法來向服務器發送數據,並通過 onmessage 事件來接收服務器返回的數據。

以下 API 用於創建 WebSocket 對象。

WebSocket 簡介及API
https://www.runoob.com/html/html5-websocket.html
http://www.ruanyifeng.com/blog/2017/05/websocket.html
https://developer.mozilla.org/zh-CN/docs/Web/API/WebSocket

WebSocket 客戶端(javascript前端)實現

javascript 實現

var websocket = null;
var websocket_connected_count = 0;
var onclose_connected_count = 0;
// 初始化WebSocket連接
function initWebSocket() {
    
    // 判斷當前環境是否支持websocket
    if(window.WebSocket){
        if(!websocket){
        	// 獲取協議類型
            var protocol = window.location.protocol;
            // console.info(protocol)
            // 通過訪問協議類型,判斷使用的websocket協議類型
            var ws_url = protocol=='http:'?'ws://':'wss://'
            // 獲取域名
            var host = window.location.host;
            // 獲取端口號
            var port = window.location.port;
            // 獲取項目訪問路由
            var pathName = window.location.pathname;
            // 截取項目名
            var projectName = pathName.substring(0, pathName.substr(1).indexOf('/') + 1);
            // 拼接websocket訪問地址
            ws_url += host + projectName + "/webSocket/user_1";
            // console.info(ws_url);
            // 創建websocket對象
            websocket = new WebSocket(ws_url);
        }
    }else{
        var content = '【當前瀏覽器不支持WEBSOCKET,無法獲取預警提醒消息,爲獲得良好的使用體驗,推薦您下載使用<a style="color: orange;text-decoration: underline;" target="_blank" href="https://jhyj.ahga.gov.cn/updown/41_chrome_installer.exe">Chrome瀏覽器</a>】';
        alter(content);
    }

    //連接成功建立的回調方法
    websocket.onopen = function () {
        console.log('WebSocket連接成功');
        // 成功建立連接後,重置心跳檢測
        heartCheck.reset().start();
    }
    //連接發生錯誤的回調方法
    websocket.onerror = function () {
        console.log('WebSocket連接發生錯誤');
        websocket_connected_count++;
        // 重連
        if(websocket_connected_count <= 5){
            initWebSocket();
        }
    };
    //接收到消息的回調方法
    websocket.onmessage = function (event) {
        // console.log("=====WebSocket接收到消息=====");
        // console.log(event);
        // console.log(event.data);

        // 如果獲取到消息,說明連接是正常的,重置心跳檢測
        heartCheck.reset().start();
    }
    //連接關閉的回調方法
    websocket.onclose = function () {
        console.log('WebSocket連接關閉');
    }

    //監聽窗口關閉事件,當窗口關閉時,主動去關閉websocket連接,防止連接還沒斷開就關閉窗口,server端會拋異常。
    window.onbeforeunload = function () {
        // 關閉WebSocket連接
        websocket.close();
    }

    // 心跳檢測, 每隔一段時間檢測連接狀態,如果處於連接中,就向server端主動發送消息,來重置server端與客戶端的最大連接時間,如果已經斷開了,發起重連。
    var heartCheck = {
	    // 30s 發一次心跳,比server端設置的連接時間稍微小,在接近斷開的情況下以通信的方式去重置連接時間。
        timeout: 30000,
        serverTimeoutObj: null,
        reset: function(){
            clearTimeout(this.timeout);
            clearTimeout(this.serverTimeoutObj);
            return this;
        },
        start: function(){
            var self = this;
            this.serverTimeoutObj = setInterval(function(){
                if(websocket.readyState == 1){
                    console.log("連接狀態,心跳保持連接");
                    websocket.send("ping");
                    heartCheck.reset().start();    // 如果獲取到消息,說明連接是正常的,重置心跳檢測
                }else{
                    console.log("斷開狀態,嘗試重連");
                    initWebSocket();
                }
            }, this.timeout)
        }
    }
}

window.location獲取URL中各部分

對於這樣一個URL
http://www.x2y2.com:80/fisker/post/0703/window.location.html?ver=1.0&id=6#imhere

我們可以用javascript獲得其中的各個部分
1, window.location.href
      整個URl字符串(在瀏覽器中就是完整的地址欄)
      本例返回值: http://www.x2y2.com:80/fisker/post/0703/window.location.html?ver=1.0&id=6#imhere

2,window.location.protocol
      URL 的協議部分
      本例返回值:http:

3,window.location.host
      URL 的主機部分
      本例返回值:www.x2y2.com

4,window.location.port
      URL 的端口部分
      如果採用默認的80端口(update:即使添加了:80),那麼返回值並不是默認的80而是空字符
      本例返回值:""

5,window.location.pathname
      URL 的路徑部分(就是文件地址)
      本例返回值:/fisker/post/0703/window.location.html

6,window.location.search
      查詢(參數)部分
      除了給動態語言賦值以外,我們同樣可以給靜態頁面,並使用javascript來獲得相信應的參數值
      本例返回值:?ver=1.0&id=6

來源 : https://www.cnblogs.com/chaoyuehedy/p/5708165.html

http/https與websocket的ws/wss的關係

websocket在http與https不同協議下實際上按照標準來是有如下對應關係的:
	http -> new WebSocket('ws://xxx')
	https -> new WebSocket('wss://xxx')
也就是在https下應該使用wss協議做安全鏈接,且wss下不支持ip地址的寫法,寫成域名形式

部分報錯的瀏覽器的確是因爲這個原因導致的代碼異常,即在https下把ws換成wss請求即可,看到這裏心細的也許會發現,是部分瀏覽器,實際上瀏覽器並沒有嚴格的限制http下一定使用ws,而不能使用wss,經過測試http協議下同樣可以使用wss協議鏈接,https下同樣也能使用ws鏈接,那麼出問題的是哪一部分呢
1.Firefox環境下https不能使用ws連接

2.chrome內核版本號低於50的瀏覽器是不允許https下使用ws鏈接

3.Firefox環境下https下使用wss鏈接需要安裝證書

實際上主要是問題出在Firefox以及低版本的Chrome內核瀏覽器上,於是在http與https兩種協議都支持的情況下可以做兼容處理,即在http協議下使用ws,在https協議下使用wss

可使用以下方式拼接websocket訪問地址:
var protocol = windows.location.protocol === 'https:' ? 'wss://localhost:8888'  : 'ws://localhost:8889';

來源 : https://blog.csdn.net/garrettzxd/article/details/81674251

WebSocket 服務端(java後臺)實現

Maven 依賴

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

WebSocketServer 實現

package com.xxx.xxxx.webSocket.server;


import com.alibaba.fastjson.JSONObject;
import com.xxx.xxxx.webSocket.MessageCoder.MessageDecoder;
import com.xxx.xxxx.webSocket.MessageCoder.MessageEncoder;
import com.xxx.xxxx.webSocket.config.WebSocketConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author xnz
 * @date 2020/03/31
 * @ServerEndpoint 註解是一個類層次的註解,它的功能主要是將目前的類定義成一個websocket服務器端,
 * 註解的值將被用於監聽用戶連接的終端訪問URL地址,客戶端可以通過這個URL來連接到WebSocket服務器端
 */
@Component
@ServerEndpoint( value = "/webSocket/{id}",configurator = WebSocketConfig.class, encoders = { MessageEncoder.class }, decoders = { MessageDecoder.class } )
public class WebSocketServer {
    private static Logger log = LoggerFactory.getLogger(WebSocketServer.class);

    @PostConstruct
    public void init() {
        System.out.println("[WebSocket 加載]");
    }
    /**
     * 靜態變量,用來記錄當前在線連接數。應該把它設計成線程安全的。
     */
    private static final AtomicInteger ONLINECOUNT = new AtomicInteger(0);
    /**
     * 線程安全map,實現服務端與單一客戶端通信,其中Key爲用戶標識  用來存放 與某個客戶端的連接會話,需要通過它來給客戶端發送數據
     */
    public static ConcurrentHashMap<String,Session> sessionMap = new ConcurrentHashMap<>();
    /**
     * 線程安全list,用來存放 在線客戶端賬號所屬的組織id
     */
    public static List<Long> orgIdList = new CopyOnWriteArrayList<>();
    /**
     * 對應客戶端id
     */
    private String sessionId = "";
//    private static Long currentId = null;

    /**
     * 連接建立成功調用的方法
     * @param id
     * @param session
     * @param config    用來獲取WebSocketConfig中的配置信息
     * @throws IOException
     */
    @OnOpen
    public void onOpen(@PathParam(value = "id") String id, Session session, EndpointConfig config) throws IOException {
//        currentId = (Long) config.getUserProperties().get("currentId");

        log.info("========" + id);
        // 將當前會話賬戶所屬組織id存儲
        String[] userId = id.split("_");
        orgIdList.add(Long.valueOf(userId[1]));

        id = UUID.randomUUID().toString()+id;
        // 存儲當前會話
        sessionMap.put(id,session);

        sessionId = id;
        int count = ONLINECOUNT.incrementAndGet();
        log.info("有連接加入,當前連接數爲:{}", count);
    }

    /**
     * 連接關閉調用的方法
     */
    @OnClose
    public void onClose(Session session) {
        try {
            session.close();

            sessionMap.remove(sessionId);

            String[] split = sessionId.split("_");
            orgIdList.remove(Long.valueOf(split[1]));
            int count = ONLINECOUNT.decrementAndGet();
            log.info("有連接關閉,當前連接數爲:{}", count);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 收到客戶端消息後調用的方法
     *
     * @param message 客戶端發送過來的消息
     */
    @OnMessage
    public void onMessage(String message, Session session) throws IOException, InterruptedException {
        log.info("來自客戶端 {} 的消息:{} , 當前連接數爲:{}",sessionId,message,ONLINECOUNT.get());
        //        sendMessage(session, JSON.toJSONString(message));
    }

    /**
     * 出現錯誤
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("發生錯誤,Session ID : {}",session.getId());
        error.printStackTrace();
    }

    /**
     * 指定Session發送消息,實踐表明,每次瀏覽器刷新,session會發生變化。
     * @param session
     * @param message
     */
    private static void sendMessage(Session session, Object message) {
        try {
            session.getBasicRemote().sendText(JSONObject.toJSONString(message));
//            session.getBasicRemote().sendObject(message);
        } catch (Exception e) {
            log.error("發送消息出錯:{}", e.getMessage());
            e.printStackTrace();
        }
    }

    /**
     * 通過id 獲取會話 發送消息
     *
     * @param message   發送的消息
     */
    public static void sendMessageByOrgId(Object message,Long id)  {
        try {
            Set<Map.Entry<String, Session>> entries = sessionMap.entrySet();
            for (Map.Entry<String,Session>  item : entries) {
                String sessionId = item.getKey();
                if(sessionId.contains(id)){
                    log.info("[===發送消息至===] " + sessionId);
                    Session session = item.getValue();
                    sendMessage(session,message);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

WebSocketConfig 實現

package com.xxx.xxxx.webSocket.config;

import org.apache.catalina.session.StandardSessionFacade;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

import javax.servlet.http.HttpSession;
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;

/**
 * 主要的配置類
 * 本類必須要繼承Configurator,因爲@ServerEndpoint註解中的config屬性只接收這個類型
 */
@Configuration
public class WebSocketConfig extends ServerEndpointConfig.Configurator {
    private static final Logger log = LoggerFactory.getLogger(WebSocketConfig.class);

    /**
     * 修改握手,就是在握手協議建立之前修改其中攜帶的內容
     * @param sec
     * @param request
     * @param response
     */
    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
        /*如果沒有監聽器,那麼這裏獲取到的HttpSession是null*/
        StandardSessionFacade ssf = (StandardSessionFacade) request.getHttpSession();
        if (ssf != null) {
            HttpSession session = (HttpSession) request.getHttpSession();
            sec.getUserProperties().put("session", session);
            log.info("獲取到的SessionID:{}",session.getId());
//            User currentUser = (User) session.getAttribute("currentUser");
//            log.info("獲取當前用戶currentId:{}",currentUser.getId());
//            sec.getUserProperties().put("currentId", currentUser.getId());
        }else{
            System.out.println("modifyHandshake 獲取到null session");
        }
        super.modifyHandshake(sec, request, response);
    }
    /**
     *  注入ServerEndpointExporter,
     *  這個bean會自動註冊使用了@ServerEndpoint註解聲明的Websocket endpoint
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

MessageDecoder 實現


package com.xxx.xxxx.webSocket.MessageCoder;

import com.alibaba.fastjson.JSON;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.websocket.DecodeException;
import javax.websocket.Decoder;
import javax.websocket.EndpointConfig;


public class MessageDecoder implements Decoder.Text<String>{

    private static Logger log = LoggerFactory.getLogger(MessageDecoder.class);

    @Override
    public String decode(String jsonMessage) throws DecodeException {
        log.info("MessageDecoder decode");
        return JSON.parseObject(jsonMessage, String.class);
    }

    @Override
    public boolean willDecode(String jsonMessage) {
        if(StringUtils.isBlank(jsonMessage))
            return false;
        try {
            JSON.parseObject(jsonMessage);
            return true;
        } catch (Exception e) {
            log.info("Message not jsonString");
            return false;
        }
    }

    @Override
    public void init(EndpointConfig endpointConfig) {
        log.info("MessageDecoder init");
    }

    @Override
    public void destroy() {
        log.info("MessageDecoder destroy");
    }
}


MessageEncoder 實現



package com.xxx.xxxx.webSocket.MessageCoder;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.websocket.EncodeException;
import javax.websocket.Encoder;
import javax.websocket.EndpointConfig;

public class MessageEncoder implements Encoder.Text<String>{

    private static Logger log = LoggerFactory.getLogger(MessageEncoder.class);

    @Override
    public String encode(String s) throws EncodeException {
        return null;
    }

    @Override
    public void init(EndpointConfig endpointConfig) {
        log.info("MessageEncoder init");
    }

    @Override
    public void destroy() {
        log.info("MessageEncoder destroy");
    }
}

其他 WebSocket 客戶端(javascript前端)及服務端(java後臺)實現
https://www.cnblogs.com/freud/p/8397934.html
https://www.cnblogs.com/xdp-gacl/p/5193279.html
https://blog.csdn.net/Doctor_LY/article/details/81362718
spring boot Websocket(使用筆記):https://www.cnblogs.com/bianzy/p/5822426.html

問題

1. WebSocket服務端需要獲取到用戶使用數據庫的用戶信息登錄後的HttpSession獲取個人資料信息

在開發過程中想在 WebSocket服務端需要獲取到用戶使用數據庫的用戶信息登錄後的HttpSession獲取個人資料信息,通過搜索最後在WebSocketConfig類中的modifyHandshake方法中使用ServerEndpointConfig類sec.getUserProperties().put("currentId", currentUser.getId());方法,然後在onOpen方法中使用config.getUserProperties().get("currentId");獲取。
詳情可見上述實現類。

參考:
https://www.cnblogs.com/smallfa/p/9285844.html
https://www.cnblogs.com/coder163/p/8605645.html

2. 項目打jar包報異常

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'serverEndpointExporter' defined in org.lwt.WebsocketServerTestApplication: Invocation of init method failed; nested exception is java.lang.IllegalStateException: javax.websocket.server.ServerContainer not available
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1745) ~[spring-beans-5.1.4.RELEASE.jar:5.1.4.RELEASE]
	...

原因:
WebSocket是servlet容器所支持的,所以需要加載servlet容器:
webEnvironment參數爲springboot指定ApplicationContext類型。
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT 表示內嵌的服務器將會在一個隨機的端口啓動。

解決方式

  1. 添加註解
@SpringBootTest(classes = WebsocketServerTestApplication.class, webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
  1. 把pom裏的test依賴刪掉,刪除測試類
<!--<dependency>-->
	<!--<groupId>org.springframework.boot</groupId>-->
	<!--<artifactId>spring-boot-starter-test</artifactId>-->
<!--</dependency>-->
  1. 使用war包時,springboot項目,去除內置tomcat的時候會把websocket的包也給刪除掉,此時手動添加tomcat-embed-websocket包
<!--去除內嵌tomcat-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-tomcat</artifactId>
    <scope>provided</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.apache.tomcat.embed/tomcat-embed-websocket -->
<!--websocket依賴包-->
<dependency>
    <groupId>org.apache.tomcat.embed</groupId>
    <artifactId>tomcat-embed-websocket</artifactId>
    <version>8.5.23</version>
</dependency>

參考:
Error creating bean with name ‘serverEndpointExporter’ defined in class path —https://blog.csdn.net/kxj19980524/article/details/88751114
springboot整合websocket後運行測試類報錯:javax.websocket.server.ServerContainer not available —https://blog.csdn.net/fggdgh/article/details/87185555

3. nginx轉發無法連接

nginx轉發需要配置nginx使nginx支持websocket連接:

server {
      listen   80;
      server_name 域名;
      location / {
      	# 代理轉發地址
        proxy_pass   http://127.0.0.1:8080/;
        # 表明使用http版本爲1.1
     proxy_http_version 1.1;
     # 超時設置 表明連接成功以後等待服務器響應的時候,如果不配置默認爲60s;
        proxy_read_timeout   3600s;
        # 啓用支持websocket連接
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
      }
}

其中重要的是這兩行,它表明是websocket連接進入的時候,進行一個連接升級將http連接變成websocket的連接。

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

來源 : Nginx 支持websocket的配置 — https://blog.csdn.net/weixin_37264997/article/details/80341911

3. Nginx代理webSocket時60s自動斷開 或者 WebSocket發生EOFException異常

如果你的前端心跳間隔設置的大於60s,並且沒有配置nginx超時時間,那麼就會出現這個問題

解決方式

1. 將心跳間隔調小,小於nginx默認超時時間60s

2. 將nginx超時時間設置大一點

proxy_read_timeout   3600s;

借鑑 : Nginx代理webSocket時60s自動斷開, 怎麼保持長連接 — https://blog.csdn.net/cm786526/article/details/79939687

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