前幾天寫了一篇《SpringBoot快速入門》一文,然后周末趁着有時間,在這個Springboot框架基礎上整合了WebSocket技術寫了一個網頁版聊天功能。
如果小伙伴找不到那套框架了,可以看下之前的文章找到Springboot快速入門一文
往期推薦
通過該文章可以了解服務端與客戶端之間的通信機制,以及了解相關的Http協議等技術內容。
話不多說,先來看看運行的過程:
頁面寫的十分簡單,后續也會陸續將其優化和完善。
正文
一、HTTP相關知識
HTTP協議
http是一個簡單的請求-響應協議,它通常運行在TCP之上。它指定了客戶端可能發送給服務器什么樣的消息以及得到什么樣的響應。請求和響應消息的頭以ASCII碼形式給出;而消息內容則具有一個類似MIME的格式。這個簡單模型是早期Web成功的有功之臣,因為它使開發和部署非常地直截了當
http 為短連接:客戶端發送請求都需要服務器端回送響應。請求結束后,主動釋放鏈接,因此為短連接。通常的做法是,不需要任何數據,也要保持每隔一段時間向服務器發送"保持連接"的請求。這樣可以保證客戶端在服務器端是"上線"狀態。
HTTP連接使用的是"請求-響應"方式,不僅在請求時建立連接,而且客戶端向服務器端請求后,服務器才返回數據。
二、Socket相關知識
1. 要想明白 Socket,必須要理解 TCP 連接。
① TCP 三次握手:握手過程中並不傳輸數據,在握手后服務器與客戶端才開始傳輸數據,理想狀態下,TCP 連接一旦建立,在通訊雙方中的任何一方主動斷開連接之前 TCP 連接會一直保持下去。
② Socket 是對 TCP/IP 協議的封裝,Socket 只是個接口不是協議,通過 Socket 我們才能使用 TCP/IP 協議,除了 TCP,也可以使用 UDP 協議來傳遞數據。
③ 創建 Socket 連接的時候,可以指定傳輸層協議,可以是 TCP 或者 UDP,當用 TCP 連接,該Socket就是個TCP連接,反之。
2. Socket 原理
Socket 連接,至少需要一對套接字,分為 clientSocket,serverSocket 連接分為3個步驟:
(1) 服務器監聽:服務器並不定位具體客戶端的套接字,而是時刻處於監聽狀態;
(2) 客戶端請求:客戶端的套接字要描述它要連接的服務器的套接字,提供地址和端口號,然后向服務器套接字提出連接請求;
(3) 連接確認:當服務器套接字收到客戶端套接字發來的請求后,就響應客戶端套接字的請求,並建立一個新的線程,把服務器端的套接字的描述發給客戶端。一旦客戶端確認了此描述,就正式建立連接。而服務器套接字繼續處於監聽狀態,繼續接收其他客戶端套接字的連接請求。
Socket為長連接:通常情況下Socket 連接就是 TCP 連接,因此 Socket 連接一旦建立,通訊雙方開始互發數據內容,直到雙方斷開連接。在實際應用中,由於網絡節點過多,在傳輸過程中,會被節點斷開連接,因此要通過輪詢高速網絡,該節點處於活躍狀態。
很多情況下,都是需要服務器端向客戶端主動推送數據,保持客戶端與服務端的實時同步。
若雙方是 Socket 連接,可以由服務器直接向客戶端發送數據。
若雙方是 HTTP 連接,則服務器需要等客戶端發送請求后,才能將數據回傳給客戶端。
因此,客戶端定時向服務器端發送請求,不僅可以保持在線,同時也詢問服務器是否有新數據,如果有就將數據傳給客戶端。
要弄明白 http 和 socket 首先要熟悉網絡七層:物 數 網 傳 會 表 應,如圖:
如圖
HTTP 協議:超文本傳輸協議,對應於應用層,用於如何封裝數據。
TCP/UDP 協議:傳輸控制協議,對應於傳輸層,主要解決數據在網絡中的傳輸。
IP 協議:對應於網絡層,同樣解決數據在網絡中的傳輸。
傳輸數據的時候只使用 TCP/IP 協議(傳輸層),如果沒有應用層來識別數據內容,傳輸后的協議都是無用的。
應用層協議很多 FTP,HTTP,TELNET等,可以自己定義應用層協議。
web 使用 HTTP 作傳輸層協議,以封裝 HTTP 文本信息,然后使用 TCP/IP 做傳輸層協議,將數據發送到網絡上。
三、WebSocket相關知識
WebSocket 是 HTML5 開始提供的一種在單個 TCP 連接上進行全雙工通訊的協議。
WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在 WebSocket API 中,瀏覽器和服務器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,並進行雙向數據傳輸。
在 WebSocket API 中,瀏覽器和服務器只需要做一個握手的動作,然后,瀏覽器和服務器之間就形成了一條快速通道。兩者之間就直接可以數據互相傳送。
現在,很多網站為了實現推送技術,所用的技術都是 Ajax 輪詢。輪詢是在特定的的時間間隔(如每1秒),由瀏覽器對服務器發出HTTP請求,然后由服務器返回最新的數據給客戶端的瀏覽器。這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向服務器發出請求,然而HTTP請求可能包含較長的頭部,其中真正有效的數據可能只是很小的一部分,顯然這樣會浪費很多的帶寬等資源。
HTML5 定義的 WebSocket 協議,能更好的節省服務器資源和帶寬,並且能夠更實時地進行通訊。
四、實現源碼:
1 聊天頁面chat.html
前端采用bootstrap,引入了: jquery-3.3.1.min.js、bootstrap.min.css。小伙伴可自行選擇:
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.springframework.org/schema/mvc"> <head> <meta charset="UTF-8"> <title>chat room websocket</title> <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}"> <script th:src="@{/js/jquery-3.3.1.min.js}"></script> </head> <body class="container" style="width: 60%"> <div class="form-group" style="width: 100%; margin-top: 10px;"> <div style="width: 100%; background-color: #800080; color: #ffffff;"> <label for="user_name" style="float: left; margin-left: 45%">你好:</label> <h5 id="user_name" th:text="${username}" style="width: 80%;"></h5> </div> </div> <div class="form-group" style="float: left; width: 100%;"> <label for="user_list" style="float: left;">選擇聊天用戶:</label> <select id="user_list" style="width: 15%;"></select> <span id="error_select_msg" style="color: red;"></span> </div> <div class="form-group" style="float: left; width: 100%;"> <div id="message_user" style="width: 25%; height: 450px; overflow-y: auto; position: relative; float: left;" class="form-control" readonly="readonly"> 群成員:<span id="message_user_count"></span><br/> </div> <div id="message_chat" style="font-size: 13px; width: 75%; height: 300px; overflow-y: auto; position: relative; float: left;" class="form-control" readonly="readonly"> </div> <div style="width: 75%; float: right;"> <div style="width: 100%; height: 110px;"> <textarea style="height: 100%; border-bottom: #ffffff solid 0px;" id="chat_msg" value="" class="form-control"></textarea> </div> <div style="width: 100%; float: right; border-bottom: #808080 solid 1px;"> <button style="float: right;" id="send" class="btn btn-info">發送消息</button> <button style="float: right;" id="send_all" class="btn btn-info">群發消息</button> <button style="float: right;" id="user_exit" class="btn btn-warning">退出</button> </div> </div> </div> </body> <script type="text/javascript"> $(document).ready(function() { initUserList(); let urlPrefix = 'ws://localhost:8080/net/websocket/'; let ws = null; let username = $('#user_name').text(); ws = initMsg(urlPrefix, username); // 客戶端發送對某一個客戶的消息到服務器 $('#send').click(function() { let userList = $("#user_list option:selected").val(); if (!userList) { $("#error_select_msg").html("請選擇一個用戶!"); return; } let msg = $('#chat_msg').val(); if (!msg) { alert("請輸入聊天內容!"); return; } msg = msg + "[" + userList + "]" + "----------" + username; if (ws) { ws.send(msg); //服務端發送的消息 $('#message_chat').append('<div style="width: 100%; float: right;"><span style="float: right;">' + username + ' </span><br/>'); $('#message_chat').append('<span style="float: right; font-size: 18px; font-weight: bolder;">' + msg.substring(0, msg.indexOf('[')) + '</span></div>'); $("#chat_msg").val(''); $("#error_select_msg").empty(); } }); // 客戶端群發消息到服務器 $('#send_all').click(function() { let msg = $('#chat_msg').val(); if (!msg) { alert("請輸入聊天內容!"); return; } msg = msg + "[allUsers]" + "----------" + username; if (ws) { ws.send(msg); //服務端發送的消息 $('#message_chat').append('<div style="width: 100%; float: right;"><span style="float: right;">' + username + ' 的群發消息 </span><br/>'); $('#message_chat').append('<span style="float: right; font-size: 18px; font-weight: bolder;">' + msg.replace('[allUsers]----------' + username, '') + '</span></div>'); $("#chat_msg").val(''); $("#error_select_msg").empty(); } }); // 退出聊天室 $('#user_exit').click(function() { if (ws) { ws.close(); } window.location.href = "/chat/login"; }); // 用戶下拉列表點擊事件 $("#user_list").on("change", function() { $("#error_select_msg").empty(); }); }); /** * 初始化用戶列表 */ function initUserList() { let username = $('#user_name').text(); $.ajax({ url: "/getUserList", type: "POST", data: {username: username}, success: function(data) { let result = JSON.parse(data); let html = "<option value=''>---請選擇---</option>"; for (let i = 0; i < result.length; i++) { html += "<option value='" + result[i].username + "'>" + result[i].username + "</option>"; } let userList = ""; for (let i = 0; i < result.length; i++) { userList += "<div class='select_user'>" + result[i].username + "</div>"; } $("#user_list").html(html); $("#message_user_count").text(result.length + "人"); $("#message_user").append(userList); } }); } /** * 初始化消息 * * @param urlPrefix * @param username * @returns {WebSocket} */ function initMsg(urlPrefix, username) { let url = urlPrefix + username; ws = new WebSocket(url); ws.onopen = function () { console.log("建立 websocket 連接..."); }; ws.onmessage = function(event) { //服務端發送的消息 $('#message_chat').append(event.data + '\n'); }; ws.onclose = function() { $('#message_chat').append('<div style="width: 100%; float: left;">用戶[' + username + '] 已經離開聊天室!' + '</div>'); console.log("用戶:[" + username + "]已關閉 websocket 連接..."); } return ws; } </script> </html>
2 pom.xml加入WebSocket依賴
<!-- 集成webSocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- 集成json --> <dependency> <groupId>net.sf.json-lib</groupId> <artifactId>json-lib</artifactId> <version>2.2.3</version> </dependency>
3 實現WebSocket服務端
① 創建SocketEndPoint.java核心聊天頁面實現類
該類為WebSocket的核心實現類,主要實現聊天連接、消息發送、退出聊天、異常處理等頁面聊天的核心功能。其中:
@PathParam這個注解是將請求路徑中綁定的占位符的值給取出來,作為參數條件使用。是javax.websocket.server下的一個注解。
在項目中,通過name對socket連接進行訪問控制,后台后續會將name作為唯一主鍵,小伙伴也可以通過在url里面增加ket + name的方式進行訪問控制,key作為登陸之后,服務器給用戶的令牌,通過令牌和name進行權限校驗(這里目前沒有實現,只保證name是唯一)。
SocketEndPoint.java類實現:
package cn.cansluck.utils.net; import cn.cansluck.service.IUserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.text.DateFormat; import java.util.Date; import java.util.Map; import static cn.cansluck.utils.net.SocketPool.*; import static cn.cansluck.utils.net.SocketHandler.createKey; // 注入容器 @Component // 表明這是一個websocket服務的端點 @ServerEndpoint("/net/websocket/{name}") public class SocketEndPoint { private static final Logger log = LoggerFactory.getLogger(SocketEndPoint.class); private static IUserService userService; @Autowired public void setUserService(IUserService userService){ SocketEndPoint.userService = userService; } @OnOpen public void onOpen(@PathParam("name") String name, Session session) { log.info("有新的連接:{}", session); add(createKey(name), session); for (Map.Entry<String, Session> item : sessionMap().entrySet()) { if (item.getKey().equals(name)) { SocketHandler.sendMessageAll("<div style='width: 100%; float: left;'>用戶【" + name + "】已上線</div>", name); } } log.info("在線人數:{}",count()); sessionMap().keySet().forEach(item -> log.info("在線用戶:" + item)); for (Map.Entry<String, Session> item : sessionMap().entrySet()) { log.info("12: {}", item.getKey()); } } @OnMessage public void onMessage(String message) { if (message.contains("[allUsers]")) { String userInfo = message.substring(message.indexOf("[allUsers]")).replace("[allUsers]----------", ""); SocketHandler.sendMessageAll( "<div style='width: 100%; float: left;'> " + userInfo + "群發消息</div><div style='width: 100%; font-size: 18px; font-weight: bolder; float: right;'>" + message.substring(0, message.indexOf("[")) + "</div>", userInfo); } else { String acceptUser = message.substring(message.indexOf("[") + 1, message.lastIndexOf("]")); String sendUser = message.substring(message.lastIndexOf("-") + 1, message.length()); Session userSession; for (Map.Entry<String, Session> item : sessionMap().entrySet()) { if (item.getKey().equals(acceptUser)) { userSession = item.getValue(); String userInfo = message.substring(0, message.indexOf("[")); SocketHandler.sendMessage(userSession, "<div style='width: 100%; float: left;'> " + sendUser + "</div><div style='width: 100%; font-size: 18px; font-weight: bolder; float: right;'>" + userInfo + "</div>"); } } } log.info("有新消息: {}", message); } @OnClose public void onClose(@PathParam("name") String name,Session session) { log.info("連接關閉: {}", session); remove(createKey(name)); log.info("在線人數:{}", count()); sessionMap().keySet().forEach(item -> log.info("在線用戶:" + item)); for (Map.Entry<String, Session> item : sessionMap().entrySet()){ log.info("12: {}", item.getKey()); } Date date = new Date(); DateFormat df = DateFormat.getDateTimeInstance();//可以精確到時分秒 SocketHandler.sendMessageAll("<div style='width: 100%; float: left;'>[" + df.format(date) + "] " + name + "已離開聊天室</div>", name); } @OnError public void onError(Session session, Throwable throwable) { try { session.close(); } catch (IOException e) { log.error("退出發生異常: {}", e.getMessage()); } log.info("連接出現異常: {}", throwable.getMessage()); } }
② 創建SocketPool.java在線連接池類
package cn.cansluck.utils.net; import javax.websocket.Session; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * WebSocket連接池類 * * @author Cansluck */ public class SocketPool { // 在線用戶websocket連接池 private static final Map<String, Session> ONLINE_USER_SESSIONS = new ConcurrentHashMap<>(); /** * 新增一則連接 * @param key 設置主鍵 * @param session 設置session */ public static void add(String key, Session session) { if (!key.isEmpty() && session != null){ ONLINE_USER_SESSIONS.put(key, session); } } /** * 根據Key刪除連接 * @param key 主鍵 */ public static void remove(String key) { if (!key.isEmpty()){ ONLINE_USER_SESSIONS.remove(key); } } /** * 獲取在線人數 * @return 返回在線人數 */ public static int count(){ return ONLINE_USER_SESSIONS.size(); } /** * 獲取在線session池 * @return 獲取session池 */ public static Map<String, Session> sessionMap(){ return ONLINE_USER_SESSIONS; } }
③ 創建SocketHandler.java動作處理工具類
package cn.cansluck.utils.net; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.websocket.RemoteEndpoint; import javax.websocket.Session; import java.io.IOException; import static cn.cansluck.utils.net.SocketPool.sessionMap; /** * WebSocket動作類 * * @author Cansluck */ public class SocketHandler { private static final Logger log = LoggerFactory.getLogger(SocketHandler.class); /** * 根據key和用戶名生成一個key值,簡單實現下 * @param name 發送人 * @return 返回值 */ public static String createKey(String name){ return name; } /** * 給指定用戶發送信息 * @param session session * @param msg 發送的消息 */ public static void sendMessage(Session session, String msg) { if (session == null) return; final RemoteEndpoint.Basic basic = session.getBasicRemote(); if (basic == null) return; try { basic.sendText(msg); } catch (IOException e) { log.error("消息發送異常,異常情況: {}", e.getMessage()); } } /** * 給所有的在線用戶發送消息 * @param message 發送的消息 * @param username 發送人 */ public static void sendMessageAll(String message, String username) { log.info("廣播:群發消息"); // 遍歷map,只輸出給其他客戶端,不給自己重復輸出 sessionMap().forEach((key, session) -> { if (!username.equals(key)) { sendMessage(session, message); } }); } }
④ 創建ChatController.java頁面訪問控制器類
package cn.cansluck.controller; import cn.cansluck.service.IUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestMapping; /** * 登錄頁 * * @author Cansluck */ @RequestMapping("/chat") @Controller public class ChatController { @Autowired private IUserService userService; /** * 登陸 * * @author Cansluck * @return 返回頁面 */ @RequestMapping("/login") public String login(String username, String password, ModelMap map) { if (null == username || "".equals(username)) return "login"; boolean isLogin = userService.login(username, password); if (isLogin) { map.addAttribute("username", username); return "chat"; } return "login"; } }
⑤ 創建SocketConfig.java的websocket配置類
package cn.cansluck.utils; 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; /** * WebSocket配置類 * * @author Cansluck */ @Configuration @EnableWebSocket public class SocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter(){ return new ServerEndpointExporter(); } }
以上就是一個WebSocket的簡單實現,更多的場景小伙伴可以自行在這個基礎上實現更多功能。后續會繼續完善該聊天的功能,代碼將會上傳到GitHub上供下載。有興趣的小伙伴可以一起來創作玩一下呀~后續還會將項目打包部署到我個人的騰訊雲服務器上,有興趣的可以一起來聊天呀~
GitHub項目下載地址
https://github.com/125207780/springboot-project.git
小伙伴們可以自行下載並操作,可以一起修改一起玩呀~
更多精彩敬請關注公眾號
Java極客思維
微信掃一掃,關注公眾號