1. 程式人生 > >WebSoket初探並於SpringBoot整合

WebSoket初探並於SpringBoot整合

一、WebSocket

1.1 HTTP與WebSocket

WebSocket 是一種網路通訊協議。RFC6455 定義了它的通訊標準。WebSocket 是 HTML5 開始提供的一種在單個 TCP 連線上進行全雙工通訊的協議。

我們知道,HTTP 協議是一種無狀態的、無連線的、單向的應用層協議。它採用了請求/響應模型。通訊請求只能由客戶端發起,服務端對請求做出應答處理

這種通訊模型有一個弊端:HTTP 協議無法實現伺服器主動向客戶端發起訊息。這就註定瞭如果伺服器有連續的狀態變化,客戶端要獲知就非常麻煩。大多數 Web 應用程式將通過輪詢請求。輪詢的效率低,非常浪費資源(因為必須不停連線,或者 HTTP 連線始終開啟)。

為了解決HTTP的這一痛點,WebSocket就被髮明出來,它的最大特點就是,伺服器可以主動向客戶端推送資訊,客戶端也可以主動向伺服器傳送資訊,是真正的雙向平等對話。

WebSocket具有以下特點:

(1)建立在 TCP 協議之上,伺服器端的實現比較容易。

(2)與 HTTP 協議有著良好的相容性。預設埠也是80和443,並且握手階段採用 HTTP 協議,因此握手時不容易遮蔽,能通過各種 HTTP 代理伺服器。

(3)資料格式比較輕量,效能開銷小,通訊高效。

(4)可以傳送文字,也可以傳送二進位制資料。

(5)沒有同源限制,客戶端可以與任意伺服器通訊。

(6)協議識別符號是ws(如果加密,則為wss),伺服器網址就是 URL。

1.2 WebSocket客戶端

WebSocket被HTML5所支援,因此建立一個WebSocket客戶端十分簡單:

var ws= null;

if ('WebSocket' in window) {
    ws = new WebSocket("ws://localhost:8080/ws");
} else if ('MozWebSocket' in window) {
    ws = new MozWebSocket("ws://localhost:8080/ws");
} else {
    alert('您的瀏覽器不支援WebSocket,請更換瀏覽器');
}

以上程式碼中的第一個引數 url, 指定連線的 URL。第二個引數 protocol 是可選的,指定了可接受的子協議。

通過呼叫readyState屬性,可以獲取當前狀態,具有以下幾種取值:

常量名 數值 含義
WebSocket.CONNECTING 0 正在連線
WebSocket.OPEN 1 連線成功,可以通訊
WebSocket.CLOSING 2 連線正在關閉
WebSocket.CLOSED 3 連線已經關閉,或者開啟連線失敗
switch (ws.readyState) {
  case WebSocket.CONNECTING:
    // do something
    break;
  case WebSocket.OPEN:
    // do something
    break;
  case WebSocket.CLOSING:
    // do something
    break;
  case WebSocket.CLOSED:
    // do something
    break;
  default:
    // this never happens
    break;
}

WebSocket具有以下幾個回撥方法:

//連線發生錯誤的回撥方法
ws.onerror = function(){
};

//連線成功建立的回撥方法
ws.onopen = function(event){
};

//接收到訊息的回撥方法
ws.onmessage = function(event){
    console.log(event.data);
    ws.send(event.data);
};

//連線關閉的回撥方法
ws.onclose = function(){
    ws.close();
};

通過呼叫send()close()方法傳送訊息和關閉連線。

二、與SpringBoot整合

SpringBoot版本:2.0.5.RELEASE,相關程式碼如下:

2.1 HelloWorld

2.1.1 匯入依賴

如果我們使用SpringBoot內建的Tomcat容器,那麼我們直接使用SpringBoot提供的WebSocket包即可,匯入:

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

需要注意spring-boot-starter屬於高階元件,已經包含spring-boot-starter-websocketspring-boot-starter-web,因此注意不要重複導包。

2.1.2 建立 WebSocket Endpoint

首先要注入ServerEndpointExporter,這個bean會自動註冊使用了@ServerEndpoint註解宣告的Websocket endpoint。

@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

然後就可以編寫具體的WebSocket操作類了:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArraySet;

@ServerEndpoint(value = "/ws")
@Component
public class WebSocketServer {
    private Logger log  = LoggerFactory.getLogger(this.getClass());

    /**
     * 用來存放每個客戶端對應的MyWebSocket物件
     */
    private static CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<>();

    /**
     * 與某個客戶端的連線會話,需要通過它來給客戶端傳送資料
     */
    private Session session;

    /**
     * 連線建立成功
     * @author jitwxs
     * @since 2018/10/10 9:44
     */
    @OnOpen
    public void onOpen(Session session) {
        this.session = session;
        webSocketSet.add(this);
        log.info("【WebSocket】客戶端:{} 加入連線!當前線上人數為:{}", session.getId(), webSocketSet.size());

        sendMessage("已接受您的連線請求");
    }

    /**
     * 連線關閉
     * @author jitwxs
     * @since 2018/10/10 9:45
     */
    @OnClose
    public void onClose() {
        webSocketSet.remove(this);
        log.info("【WebSocket】客戶端:{} 關閉連線!當前線上人數為:{}", this.session.getId(), webSocketSet.size());

    }

    /**
     * 收到客戶端訊息
     * @author jitwxs
     * @since 2018/10/10 9:45
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("【WebSocket】收到來自客戶端:{} 的訊息,訊息內容:{}", session.getId(), message);
        sendMessage("收到訊息:" + message);
    }

    /**
     * 發生錯誤
     * @author jitwxs
     * @since 2018/10/10 9:46
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.info("【WebSocket】客戶端:{} 發生錯誤,錯誤資訊:", session.getId(), error);
    }


    /**
     * 對當前客戶端傳送訊息
     * @author jitwxs
     * @since 2018/10/10 9:49
     */
    public void sendMessage(String message) {
        this.session.getAsyncRemote().sendText(message);
//        this.session.getBasicRemote().sendText(message);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        WebSocketServer that = (WebSocketServer) o;
        return Objects.equals(session, that.session);
    }

    @Override
    public int hashCode() {
        return Objects.hash(session);
    }
}

使用@ServerEndpoint註解制定了WebSocket的路徑,通過@Compontent註解加入Spring容器,通過@Open@OnClose@OnMessage@OnError註解處理相應的WebSocket請求。

這裡注意下session.getAsyncRemote()session.getBasicRemote()的區別:

getAsyncRemote()為同步,getBasicRemote()為非同步大部分情況下,推薦使用getAsyncRemote()

由於getBasicRemote()的同步特性,並且它支援部分訊息的傳送即sendText(xxx,boolean isLast), isLast的值表示是否一次傳送訊息中的部分訊息,對於如下情況:

 session.getBasicRemote().sendText(message, false); 
 session.getBasicRemote().sendBinary(data);
 session.getBasicRemote().sendText(message, true); 

由於同步特性,第二行的訊息必須等待第一行的傳送完成才能進行,而第一行的剩餘部分訊息要等第二行傳送完才能繼續傳送,所以在第二行會丟擲IllegalStateException異常。如果要使用getBasicRemote()同步傳送訊息,則避免儘量一次傳送全部訊息,使用部分訊息來發送。

2.1.3 編寫頁面

然後寫一個簡單的頁面來測試下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Index</title>
</head>
<body>

<input id="text" type="text"/>
<button onclick="send()">Send</button>
<button onclick="closeWebSocket()">Close</button>
<div id="message"></div>

<script>
    var ws= null;

    // 建立連線
    if ('WebSocket' in window) {
        ws = new WebSocket("ws://localhost:8080/ws");
    } else if ('MozWebSocket' in window) {
        ws = new MozWebSocket("ws://localhost:8080/ws");
    } else {
        alert('您的瀏覽器不支援WebSocket,請更換瀏覽器');
    }

    //連線發生錯誤的回撥方法
    ws.onerror = function(){
        setMessageInnerHTML("error");
    };

    //連線成功建立的回撥方法
    ws.onopen = function(){
        setMessageInnerHTML("open");
    };

    //接收到訊息的回撥方法
    ws.onmessage = function(event){
        setMessageInnerHTML(event.data);
    };

    //連線關閉的回撥方法
    ws.onclose = function(){
        setMessageInnerHTML("close");
    };

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

    //將訊息顯示在網頁上
    function setMessageInnerHTML(innerHTML) {
        document.getElementById('message').innerHTML = innerHTML + '<br/>';
    }

    //關閉連線
    function closeWebSocket(){
        ws.close();
    }

    //傳送訊息
    function send(){
        let message = document.getElementById('text').value;
        ws.send(message);
    }
</script>
</body>
</html>

2.1.4 測試

當頁面載入完畢時,建立連線:

客戶端傳送訊息 + 服務端回覆:

客戶端主動關閉連線:

2.2 心跳包檢測

在使用Websocket連線建立數分鐘後(一說是10分鐘),會自動斷開連線,所以就需要一種機制來檢測客戶端和服務端是否處於正常連線的狀態。這就是心跳包,還有心跳說明連線正常,沒有心跳說明連線端開。

實現效果是客戶端連線後與服務端通過心跳包檢測連線狀態。當客戶端超過一定時間收不到服務端的心跳包,客戶端認為與服務端連線斷開,關閉連線,並不停的嘗試重連。

修改後臺的onMessage()方法,當收到客戶端的心跳包時,響應心跳包:

@OnMessage
public void onMessage(String message, Session session) {
    log.info("【WebSocket】收到來自客戶端:{} 的訊息,訊息內容:{}", session.getId(), message);

    // 如果客戶端傳送心跳包,返回心跳包
    if("ping".equals(message)) {
        sendMessage("pong");
    } else {
        sendMessage("收到訊息:" + message);
    }
}

前臺程式碼如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket Heart</title>
</head>
<body>

<button onclick="closeWebSocket()">主動斷開連線</button>

<script>
    var ws = null, wsUrl = "ws://localhost:8080/ws1";
    var lockReconnect = false;  //避免ws重複連線

    createWebSocket(wsUrl);

    // 建立連線
    function createWebSocket(url) {
        if ('WebSocket' in window) {
            ws = new WebSocket(url);
        } else if ('MozWebSocket' in window) {
            ws = new MozWebSocket(url);
        } else {
            alert('您的瀏覽器不支援WebSocket,請更換瀏覽器');
        }
        initEventHandle();
    }

    // 初始化相關回調函式
    function initEventHandle() {
        //連線成功建立的回撥方法
        ws.onopen = function(){
            console.log("客戶端連線建立");
            //心跳檢測重置
            heartCheck.reset().start();
        };

        //接收到訊息的回撥方法
        ws.onmessage = function(event){
            console.log("客戶端收到訊息啦:" +event.data);
            //拿到任何訊息都說明當前連線是正常的,重置心跳
            heartCheck.reset().start();
        };

        //連線關閉的回撥方法
        ws.onclose = function(){
            console.log("客戶端連線關閉");
            // 重連WebSocket
            reconnect(wsUrl);
        };

        //連線發生錯誤的回撥方法
        ws.onerror = function(){
            console.log("客戶端連線錯誤");
            // 重連WebSocket
            reconnect(wsUrl);
        };
    }

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

    // 重連WebSocket
    function reconnect(url) {
        if (lockReconnect)
            return;
        lockReconnect = true;

        //沒連線上會一直重連,設定延遲避免請求過多
        setTimeout(function () {
            createWebSocket(url);
            lockReconnect = false;
        }, 2000);
    }


    //心跳檢測
    var heartCheck = {
        timeout: 10000,        // 10s發一次心跳
        timeoutObj: null,
        serverTimeoutObj: null,
        reset: function () { //心跳包重置
            clearTimeout(this.timeoutObj);
            clearTimeout(this.serverTimeoutObj);
            return this;
        },
        start: function () {
            var self = this;
            this.timeoutObj = setTimeout(function () {
                // 向後臺傳送心跳
                ws.send("ping");
                self.serverTimeoutObj = setTimeout(function () { //如果超過一定時間還沒重置,說明後端主動斷開了
                    // 執行ws.close()會回撥onclose,然後執行其中的reconnet。如果直接執行reconnect 會觸發onclose導致重連兩次
                    ws.close();
                }, self.timeout)
            }, this.timeout)
        }
    };

    //關閉連線
    function closeWebSocket(){
        ws.close();
    }
</script>
</body>
</html>