springboot2整合websocket,實現服務端推送訊息到客戶端

飛馳的蝸牛發表於2019-11-17

使用websocket實現服務端推送訊息到客戶端

一、背景

現在很多web網站上都有站內訊息通知,用於給使用者及時推送站內信訊息。大多數是在網頁頭部導航欄上帶一個小鈴鐺圖示,有新的訊息時,鈴鐺會出現相應提示,用於提醒使用者檢視。例如下圖:

阿里雲推送
我們都知道,web應用都是C/S模式,客戶端通過瀏覽器發出一個請求,伺服器端接收請求後進行處理並返回結果給客戶端,客戶端瀏覽器將資訊呈現給使用者。所以很容易想到的一種解決方式就是:

  • Ajax輪詢:客戶端使用js寫一個定時器setInterval(),以固定的時間間隔向伺服器發起請求,查詢是否有最新訊息。
  • 基於 Flash:AdobeFlash 通過自己的 Socket 實現資料交換,再利用 Flash 暴露出對應的介面給 js呼叫,從而實現實時傳輸,此方式比Ajax輪詢要高效。但在移動網際網路終端上對Flash 的支援並不好。現在已經基本不再使用。

而對於Ajax輪詢方案,優點是實現起來簡單,適用於對訊息實時性要求不高,使用者量小的場景下,缺點就是客戶端給伺服器帶來很多無謂請求,浪費頻寬,效率低下,做不到服務端的主動推送

Http模式

二、websocket的出現

WebSocket 是 HTML5 開始提供的一種在單個 TCP 連線上進行全雙工通訊的協議。 WebSocket 使客戶端和伺服器之間的資料交換變得更加簡單,允許服務端主動向客戶端推送資料。WebSocket API 中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以建立長連線,並進行雙向資料傳輸。

websocket

三、springboot整合websocket

springboot整合websocket作為服務端,非常簡單,以下以springboot 2.2.0版本為例:

1.引入maven依賴

   <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
    </dependency>
複製程式碼

2.建立webSocket配置類

package com.learn.demo.config;

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

@Configuration
public class WebSocketConfig {

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

複製程式碼

3.建立webSocket端點

package com.learn.demo.ws;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Component
@ServerEndpoint(value = "/testWebSocket/{id}")
public class WebSocketProcess {

    /*
     * 持有每個webSocket物件,以key-value儲存到執行緒安全ConcurrentHashMap,
     */
    private static ConcurrentHashMap<Long, WebSocketProcess> concurrentHashMap = new ConcurrentHashMap<>(12);
    
    /**
     * 會話物件
     **/
    private Session session;
    

    /*
     * 客戶端建立連線時觸發 
     * */
    @OnOpen
    public void onOpen(Session session, @PathParam("id") long id) {
        //每新建立一個連線,就把當前客戶id為key,this為value儲存到map中
        this.session = session;
        concurrentHashMap.put(id, this);
        log.info("Open a websocket. id={}", id);
    }

    /**
     * 客戶端連線關閉時觸發
     **/
    @OnClose
    public void onClose(Session session, @PathParam("id") long id) {
        //客戶端連線關閉時,移除map中儲存的鍵值對
        concurrentHashMap.remove(id);
        log.info("close a websocket, concurrentHashMap remove sessionId= {}", id);
    }

    /**
     * 接收到客戶端訊息時觸發
     */
    @OnMessage
    public void onMessage(String message, @PathParam("id") String id) {
        log.info("receive a message from client id={},msg={}", id, message);
    }

    /**
     * 連線發生異常時候觸發
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("Error while websocket. ", error);
    }

    /**
    * 傳送訊息到指定客戶端 
    *  @param id 
    *  @param message
    * */
    public void sendMessage(long id, String message) throws Exception {
       //根據id,從map中獲取儲存的webSocket物件
        WebSocketProcess webSocketProcess = concurrentHashMap.get(id);
        if (!ObjectUtils.isEmpty(webSocketProcess)) {
            //當客戶端是Open狀態時,才能傳送訊息
            if (webSocketProcess.session.isOpen()) {
                webSocketProcess.session.getBasicRemote().sendText(message);
            } else {
                log.error("websocket session={} is closed ", id);
            }
        } else {
             log.error("websocket session={} is not exit ", id);
        }
    }

    /**
     * 傳送訊息到所有客戶端 
     * 
     * */
    public void sendAllMessage(String msg) throws Exception {
        log.info("online client count={}", concurrentHashMap.size());
        Set<Map.Entry<Long, WebSocketProcess>> entries = concurrentHashMap.entrySet();
        for (Map.Entry<Long, WebSocketProcess> entry : entries) {
            Long cid = entry.getKey();
            WebSocketProcess webSocketProcess = entry.getValue();
            boolean sessionOpen = webSocketProcess.session.isOpen();
            if (sessionOpen) {
                webSocketProcess.session.getBasicRemote().sendText(msg);
            } else {
                log.info("cid={} is closed,ignore send text", cid);
            }
        }
    }

}
複製程式碼

@ServerEndpoint(value = "/testWebSocket/{id}")註解,宣告並建立了webSocket端點,並且指明瞭請求路徑為 "/testWebSocket/{id}",id為客戶端請求時攜帶的引數,用於服務端區分客戶端使用。

4.建立controller,用於模擬服務端訊息傳送

package com.learn.demo.controller;

import com.learn.demo.ws.WebSocketProcess;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/ws")
public class WebSocketController {
    
   /**
    *注入WebSocketProcess 
    **/
    @Autowired
    private WebSocketProcess webSocketProcess;

    /**
     * 向指定客戶端發訊息
     * @param id
     * @param msg
     */
    @PostMapping(value = "sendMsgToClientById")
    public void sendMsgToClientById(@RequestParam long id, @RequestParam String text){
        try {
            webSocketProcess.sendMessage(id,text);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

   /**
     * 發訊息到所有客戶端
     * @param msg
     */
    @PostMapping(value = "sendMsgToAllClient")
    public void sendMsgToAllClient( @RequestParam String text){
        try {
            webSocketProcess.sendAllMessage(text);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

複製程式碼

四、HTML客戶端程式碼

HTML5 提供了對websocket的支援,並且提供了相關api,可以直接使用

1.WebSocket 建立

//url就是服務端的websocket端點路徑, protocol 是可選的,指定了可接受的子協議
var Socket = new WebSocket(url, [protocol] );
複製程式碼

2.WebSocket 事件

事件 事件處理程式 描述
open Socket.onopen 連線建立時觸發
message Socket.onmessage 客戶端接收服務端資料時觸發
error Socket.onerror 通訊發生錯誤時觸發
close Socket.onclose 連線關閉時觸發

3.WebSocket 方法

方法 描述
Socket.send() 使用連線傳送資料
Socket.close() 關閉連線

以下是簡單的例子,我們使用隨機數,模擬客戶端ID

<!DOCTYPE html>
<html>
	<head>
		<meta charset="UTF-8">
		<title>websocket測試</title>
		<script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
	</head>
	<body>
	   <div id="content"></div>
	</body>
	 <script type="text/javascript">
	 	$(function(){
	 		var ws;
	 		//檢測瀏覽器是否支援webSocket
	 		if("WebSocket" in window){
	 			    $("#content").html("您的瀏覽器支援webSocket!");
	 			    //模擬產生clientID
	 	 			let clientID = Math.ceil(Math.random()*100);	 			
		 			
		 			//建立 WebSocket 物件,注意請求路徑!!!!
		 			ws = new WebSocket("ws://127.0.0.1:8080/testWebSocket/"+clientID);
		 			
		 			//與服務端建立連線時觸發
		 			ws.onopen = function(){
		 				 $("#content").append("<p>與服務端建立連線建立成功!您的客戶端ID="+clientID+"</p>");
		 				 
		 				 //模擬傳送資料到伺服器
		 				 ws.send("你好服務端!我是客戶端 "+clientID);
		 			}
		 			
		 			//接收到服務端訊息時觸發
		 			ws.onmessage = function (evt) { 
		 				let received_msg = evt.data; 
		 				 $("#content").append("<p>接收到服務端訊息:"+received_msg+"</p>"); 
		 			};
		 			
		 			//服務端關閉連線時觸發
		 			ws.onclose = function() { 
		 				console.error("連線已經關閉.....")
		 			};
	 		}else{
	 			$("#content").html("您的瀏覽器不支援webSocket!");
	 		}
	 	})
	 </script>
</html>

複製程式碼

五、模擬測試

1.首先啟動服務端,springboot預設埠8080,觀察是否有報錯。

Tomcat started on port(s): 8080 (http) with context path ''
2019-11-10 16:31:35.496  INFO 16412 --- [  restartedMain] com.learn.demo.DemoApplication           : Started DemoApplication in 3.828 seconds (JVM running for 6.052)
2019-11-10 16:31:45.006  INFO 16412 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2019-11-10 16:31:45.006  INFO 16412 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2019-11-10 16:31:45.011  INFO 16412 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : Completed initialization in 5 ms
複製程式碼

2.開啟html介面,這裡同時開啟了三個介面做模擬使用

在這裡插入圖片描述
服務端可以看到日誌,證明websocket連線已經成功建立

 [nio-8080-exec-1] com.learn.demo.ws.WebSocketProcess       : Open a websocket. id=26
2019-11-10 16:31:45.076  INFO 16412 --- [nio-8080-exec-1] com.learn.demo.ws.WebSocketProcess       : Receive a message from client id=26,msg=你好服務端!我是客戶端 26
2019-11-10 16:31:46.338  INFO 16412 --- [nio-8080-exec-2] com.learn.demo.ws.WebSocketProcess       : Open a websocket. id=62
2019-11-10 16:31:46.338  INFO 16412 --- [nio-8080-exec-2] com.learn.demo.ws.WebSocketProcess       : Receive a message from client id=62,msg=你好服務端!我是客戶端 62
2019-11-10 16:31:48.052  INFO 16412 --- [nio-8080-exec-3] com.learn.demo.ws.WebSocketProcess       : Open a websocket. id=79
2019-11-10 16:31:48.059  INFO 16412 --- [nio-8080-exec-4] com.learn.demo.ws.WebSocketProcess       : Receive a message from client id=79,msg=你好服務端!我是客戶端 79
複製程式碼

3.向所有客戶端推送訊息,這裡使用postman做測試,請求服務端的sendMsgToAllClient介面

在這裡插入圖片描述
可以看到,剛剛開啟的三個html介面上,都及時接受到了服務端傳送的訊息。
在這裡插入圖片描述
3.向指定客戶端推送訊息,請求服務端的sendMsgToClientById介面
在這裡插入圖片描述
可以看到客戶端ID=79的,收到了我們的推送訊息,其它的沒變化。
在這裡插入圖片描述

六.總結

每項技術帶來優點的同時,同時也會附帶缺點,目前來看websocket的一些小問題:

  • websocket連結斷開後,不會主動重連,需要手動重新整理網頁或者自己實現斷線重連機制
  • 低版本瀏覽器對websocket支援不太好,如IE8
  • 服務端持有了一個所有websocket物件的集合Map,使用者線上量大的時候,佔用記憶體大,當然這個可以優化程式碼
  • websocket受網路波動影響較大,因為是長連線,網路差勁時,長連線會受影響

所以,具體看實際場景需求,選擇合適方案。

相關文章