Java 整合WebSocket實現實時通訊
去年獨立負責開發了一個小程式拼單的功能,要求兩個及兩個以上的裝置同時線上點單時,單個裝置加入購物車的商品要實時顯示在所有裝置的購物車上面,最後再由拼單發起人進行結算和支付。當時小程式額外還加了一個拼單發起人可以向參與人發起群收款功能,這個功能以後再介紹。
剛寫程式碼的時候用PHP整合Swoole寫過視訊直播的聊天功能,所以當時看到拼單購物車共享功能就想到實時性要求肯定很高,腦袋裡就浮現出了Websocket,但技術選型因為某些原因被否決了,最後我只能採用每秒輪詢購物車版本號的方式來實現這個功能了。但是在面對實時性要求很高的功能,我堅信Websocket依然是很好的選擇。
這篇文章就將Websocket整合到SpringBoot中,簡單實現聊天房間線上使用者和訊息列表。
首先在SpringBoot的pom.xml檔案裡面加入Websocket整合包:
<dependencies> ... <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> ... </dependencies>
然後建立一個配置檔案,注入ServerEndpointExporter,簡單來說就是讓SpringBoot識別Websocket的註解
package com.demo.www.config.websocket; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; /** * WebSocket服務配置 * @author AnYuan */ @Configuration public class WebsocketConfig { /** * 注入一個ServerEndpointExporter * 該Bean會自動註冊使用@ServerEndpoint註解申明的websocket endpoint */@Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }
再做一個前置事情,這邊構建一個統一的訊息模版類,用來統一接受和傳送訊息的資料欄位和型別:
package com.demo.www.config.websocket; import lombok.Data; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; /** * 訊息模版 * @author AnYuan */ @Data public class WebsocketMsgDTO { /** * 傳送訊息使用者 */ private String uid; /** * 接收訊息使用者 */ private String toUId; /** * 訊息內容 */ private String content; /** * 訊息時間 */ private String dateTime; /** * 使用者列表 */ private List<String> onlineUser; /** * 統一訊息模版 * @param uid 傳送訊息使用者 * @param content 訊息內容 * @param onlineUser 線上使用者列表 */ public WebsocketMsgDTO(String uid, String content, List<String> onlineUser) { this.uid = uid; this.content = content; this.onlineUser = onlineUser; this.dateTime = localDateTimeToString(); } /** * 獲取當前時間 * @return String 12:00:00 */ private String localDateTimeToString() { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss"); return dateTimeFormatter.format( LocalDateTime.now()); } }
最後上服務端邏輯程式碼:@ServerEndpoint(value="") 這個是Websocket服務url字首,{uid}類似於ResutFul風格的引數
package com.demo.www.config.websocket; import com.alibaba.fastjson.JSONObject; import lombok.extern.slf4j.Slf4j; import org.apache.logging.log4j.util.Strings; import org.springframework.stereotype.Component; import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; /** * WebSocketServer服務 * @author AnYuan */ @ServerEndpoint(value = "/webSocket/{uid}") @Component @Slf4j public class WebSocketServer { /** * 機器人發言名稱 */ private static final String SPOKESMAN_ADMIN = "機器人"; /** * concurrent包的執行緒安全Set * 用來存放每個客戶端對應的Session物件 */ private static final ConcurrentHashMap<String, Session> SESSION_POOLS = new ConcurrentHashMap<>(); /** * 靜態變數,用來記錄當前線上連線數。 * 應該把它設計成執行緒安全的。 */ private static final AtomicInteger ONLINE_NUM = new AtomicInteger(); /** * 獲取線上使用者列表 * @return List<String> */ private List<String> getOnlineUsers() { return new ArrayList<>(SESSION_POOLS.keySet()); } /** * 使用者建立連線成功呼叫 * @param session 使用者集合 * @param uid 使用者標誌 */ @OnOpen public void onOpen(Session session, @PathParam(value = "uid") String uid) { // 將加入連線的使用者加入SESSION_POOLS集合 SESSION_POOLS.put(uid, session); // 線上使用者+1 ONLINE_NUM.incrementAndGet(); sendToAll(new WebsocketMsgDTO(SPOKESMAN_ADMIN, uid + " 加入連線!", getOnlineUsers())); } /** * 使用者關閉連線時呼叫 * @param uid 使用者標誌 */ @OnClose public void onClose(@PathParam(value = "uid") String uid) { // 將加入連線的使用者移除SESSION_POOLS集合 SESSION_POOLS.remove(uid); // 線上使用者-1 ONLINE_NUM.decrementAndGet(); sendToAll(new WebsocketMsgDTO(SPOKESMAN_ADMIN, uid + " 斷開連線!", getOnlineUsers())); } /** * 服務端收到客戶端資訊 * @param message 客戶端發來的string * @param uid uid 使用者標誌 */ @OnMessage public void onMessage(String message, @PathParam(value = "uid") String uid) { log.info("Client:[{}], Message: [{}]", uid, message); // 接收並解析前端訊息並加上時間,最後根據是否有接收使用者,區別傳送所有使用者還是單個使用者 WebsocketMsgDTO msgDTO = JSONObject.parseObject(message, WebsocketMsgDTO.class); msgDTO.setDateTime(localDateTimeToString()); // 如果有接收使用者就傳送單個使用者 if (Strings.isNotBlank(msgDTO.getToUId())) { sendMsgByUid(msgDTO); return; } // 否則傳送所有人 sendToAll(msgDTO); } /** * 給所有人傳送訊息 * @param msgDTO msgDTO */ private void sendToAll(WebsocketMsgDTO msgDTO) { //構建json訊息體 String content = JSONObject.toJSONString(msgDTO); // 遍歷傳送所有線上使用者 SESSION_POOLS.forEach((k, session) -> sendMessage(session, content)); } /** * 給指定使用者傳送資訊 */ private void sendMsgByUid(WebsocketMsgDTO msgDTO) { sendMessage(SESSION_POOLS.get(msgDTO.getToUId()), JSONObject.toJSONString(msgDTO)); } /** * 傳送訊息方法 * @param session 使用者 * @param content 訊息 */ private void sendMessage(Session session, String content){ try { if (Objects.nonNull(session)) { // 使用Synchronized鎖防止多次傳送訊息 synchronized (session) { // 傳送訊息 session.getBasicRemote().sendText(content); } } } catch (IOException ioException) { log.info("傳送訊息失敗:{}", ioException.getMessage()); ioException.printStackTrace(); } } /** * 獲取當前時間 * @return String 12:00:00 */ private String localDateTimeToString() { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss"); return dateTimeFormatter.format( LocalDateTime.now()); } }
以上啟動後,就開啟了一個WebSocket服務了,只需要前端接入就可以了。這裡提一下,[獲取當前時間]的方法兩個地方用到了,建議用一個時間工具類將其提取出來,這裡為了減少檔案就寫了兩次。
那接下來我們簡單寫一下前端樣式和Js程式碼,前端樣式是我在網上下載後進行修改的,重點是Js部分, 下面將建立一個Admin使用者,一個user使用者,同時連線這個WebSocket服務,實現展現線上使用者和通告列表的功能。
第一個檔案:admin.html
<!DOCTYPE html> <html> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <head> <title>Admin Hello WebSocket</title> <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css" rel="stylesheet"> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.2.1/jquery.js"></script> <script src="app.js"></script> <style> body { background-color: #f5f5f5; } #main-content { max-width: 940px; padding: 2em 3em; margin: 0 auto 20px; background-color: #fff; border: 1px solid #e5e5e5; -webkit-border-radius: 5px; -moz-border-radius: 5px; border-radius: 5px; } </style> </head> <body> <div id="main-content" class="container"> <div class="row"> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <input id="userId" value="Admin" hidden> <label for="connect">建立連線通道:</label> <button id="connect" class="btn btn-default" type="submit">Connect</button> <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect </button> </div> </form> </div> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <label>釋出新公告</label> <input type="text" id="content" class="form-control" value="" placeholder="發言框.."> </div> <button id="send" class="btn btn-default" type="submit">釋出</button> </form> </div> </div> <div class="row" style="margin-top: 30px"> <div class="col-md-12"> <table id="userlist" class="table table-striped"> <thead> <tr> <th>實時線上使用者列表<span id="onLineUserCount"></span></th> </tr> </thead> <tbody id='online'> </tbody> </table> </div> <div class="col-md-12"> <table id="conversation" class="table table-striped"> <thead> <tr> <th>遊戲公告內容</th> </tr> </thead> <tbody id="notice"> </tbody> </table> </div> </div> </div> </body> </html>
第二個檔案:user.html
<!DOCTYPE html> <html> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> <head> <title>User1 Hello WebSocket</title> <link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css" rel="stylesheet"> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.2.1/jquery.js"></script> <script src="app.js"></script> <style> body { background-color: #f5f5f5; } #main-content { max-width: 940px; padding: 2em 3em; margin: 0 auto 20px; background-color: #fff; border: 1px solid #e5e5e5; -webkit-border-radius: 5px; -moz-border-radius: 5px; border-radius: 5px; } </style> </head> <body> <div id="main-content" class="container"> <div class="row"> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <input id="userId" value="user1" hidden> <label for="connect">建立連線通道:</label> <button id="connect" class="btn btn-default" type="submit">Connect</button> <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect </button> </div> </form> </div> </div> <div class="row" style="margin-top: 30px"> <div class="col-md-12"> <table id="userlist" class="table table-striped"> <thead> <tr> <th>實時線上使用者列表<span id="onLineUserCount"></span></th> </tr> </thead> <tbody id='online'> </tbody> </table> </div> <div class="col-md-12"> <table id="conversation" class="table table-striped"> <thead> <tr> <th>遊戲公告內容</th> </tr> </thead> <tbody id="notice"> </tbody> </table> </div> </div> </div> </body> </html>
最後重點的Js檔案:app.js。 將其與admin.html、user.html放在同一個目錄下即可引用
var socket; function setConnected(connected) { $("#connect").prop("disabled", connected); $("#disconnect").prop("disabled", !connected); if (connected) { $("#conversation").show(); } else { $("#conversation").hide(); } $("#notice").html(""); }
// WebSocket 服務操作 function openSocket() { if (typeof (WebSocket) == "undefined") { console.log("瀏覽器不支援WebSocket"); } else { console.log("瀏覽器支援WebSocket"); //實現化WebSocket物件,指定要連線的伺服器地址與埠 建立連線 if (socket != null) { socket.close(); socket = null; } // ws 為websocket連線標識,localhost:9999 為SpringBoot的連線地址,webSocket 為後端配置的字首, userId 則是引數 socket = new WebSocket("ws://localhost:9999/webSocket/" + $("#userId").val()); //開啟事件 socket.onopen = function () { console.log("websocket已開啟"); setConnected(true) }; //獲得訊息事件 socket.onmessage = function (msg) { const msgDto = JSON.parse(msg.data); console.log(msg) showContent(msgDto); showOnlineUser(msgDto.onlineUser); }; //關閉事件 socket.onclose = function () { console.log("websocket已關閉"); setConnected(false) removeOnlineUser(); }; //發生了錯誤事件 socket.onerror = function () { setConnected(false) console.log("websocket發生了錯誤"); } } } //2、關閉連線 function disconnect() { if (socket !== null) { socket.close(); } setConnected(false); console.log("Disconnected"); } function sendMessage() { if (typeof (WebSocket) == "undefined") { console.log("您的瀏覽器不支援WebSocket"); } else { var msg = '{"uid":"' + $("#userId").val() + '", "toUId": null, "content":"' + $("#content").val() + '"}'; console.log("向服務端傳送訊息體:" + msg); socket.send(msg); } } // 訂閱的訊息顯示在客戶端指定位置 function showContent(serverMsg) { $("#notice").html("<tr><td>" + serverMsg.uid + ": </td> <td>" + serverMsg.content + "</td><td>" + serverMsg.dateTime + "</td></tr>" + $("#notice").html()) } //顯示實時線上使用者 function showOnlineUser(serverMsg) { if (null != serverMsg) { let html = ''; for (let i = 0; i < serverMsg.length; i++) { html += "<tr><td>" + serverMsg[i] + "</td></tr>"; } $("#online").html(html); $("#onLineUserCount").html(" ( " + serverMsg.length + " )"); } } //顯示實時線上使用者 function removeOnlineUser() { $("#online").html(""); $("#onLineUserCount").html(""); } $(function () { $("form").on('submit', function (e) { e.preventDefault(); }); $("#connect").click(function () { openSocket(); }); $("#disconnect").click(function () { disconnect(); }); $("#send").click(function () { sendMessage(); }); });
開啟admin.html和user.html頁面:
分別點選Connect連線Websocket服務,然後使用admin頁面的[釋出新通告]進行訊息釋出:
這裡只實現了管理員群發訊息,沒有實現一對一的聊天,可以通過修改使用者列表的樣式,增加一對一聊天的功能。在app.js裡,傳送訊息時指定傳送物件欄位 [toUId] 就可以實現一對一聊天了。
到此SpringBoot整合Websocket就已經完成了,如果要實現聊天或者拼單等一些實時性要求比較高的功能,可以通過更改WebSocketServer檔案的邏輯,接入業務程式碼。
最後建議將WebSocket服務與業務程式碼執行環境分開,使用不同的容器,最後再根據流量大小決定是否需要擴容。