使用websocket實現服務端推送訊息到客戶端
一、背景
現在很多web網站上都有站內訊息通知,用於給使用者及時推送站內信訊息。大多數是在網頁頭部導航欄上帶一個小鈴鐺圖示,有新的訊息時,鈴鐺會出現相應提示,用於提醒使用者檢視。例如下圖:
我們都知道,web應用都是C/S模式,客戶端通過瀏覽器發出一個請求,伺服器端接收請求後進行處理並返回結果給客戶端,客戶端瀏覽器將資訊呈現給使用者。所以很容易想到的一種解決方式就是:- Ajax輪詢:客戶端使用js寫一個定時器setInterval(),以固定的時間間隔向伺服器發起請求,查詢是否有最新訊息。
- 基於 Flash:AdobeFlash 通過自己的 Socket 實現資料交換,再利用 Flash 暴露出對應的介面給 js呼叫,從而實現實時傳輸,此方式比Ajax輪詢要高效。但在移動網際網路終端上對Flash 的支援並不好。現在已經基本不再使用。
而對於Ajax輪詢方案,優點是實現起來簡單,適用於對訊息實時性要求不高,使用者量小的場景下,缺點就是客戶端給伺服器帶來很多無謂請求,浪費頻寬,效率低下,做不到服務端的主動推送
二、websocket的出現
WebSocket 是 HTML5 開始提供的一種在單個 TCP 連線上進行全雙工通訊的協議。 WebSocket 使客戶端和伺服器之間的資料交換變得更加簡單,允許服務端主動向客戶端推送資料。WebSocket API 中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以建立長連線,並進行雙向資料傳輸。
三、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受網路波動影響較大,因為是長連線,網路差勁時,長連線會受影響
所以,具體看實際場景需求,選擇合適方案。