WebSocket是一種在單個TCP連接上進行全雙工通信的協議。WebSocket使得客戶端和伺服器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在WebSocket API中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,併進行雙向數據傳輸。 ...
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
其他參考/學習資料:
- https://www.cnblogs.com/xswz/p/10314351.html
- https://www.cnblogs.com/xuwenjin/p/12664650.html
- https://blog.csdn.net/qq_42402854/article/details/130948270
- https://www.cnblogs.com/zhangxinhua/p/11341292.html
作 者:請叫我頭頭哥
出 處:http://www.cnblogs.com/toutou/
關於作者:專註於基礎平臺的項目開發。如有問題或建議,請多多賜教!
版權聲明:本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文鏈接。
特此聲明:所有評論和私信都會在第一時間回覆。也歡迎園子的大大們指正錯誤,共同進步。或者直接私信我
聲援博主:如果您覺得文章對您有幫助,可以點擊文章右下角【推薦】一下。您的鼓勵是作者堅持原創和持續寫作的最大動力!