1. 整體思路
群組聊天功能實現思路
- 需要為每個群組維護一個對應的集合(可以是?
Set
?等數據結構),用來存放該群組內所有在線用戶的?WebSocketSession
。當有消息發送到群組時,遍歷該群組對應的集合,向其中的每個在線用戶發送消息。 - 在消息結構體中新增一個字段用于標識所屬群組,以便后端根據這個字段來進行消息的廣播分發。
離線用戶處理及歷史消息推送思路
- 對于離線用戶,當他們重新上線時,需要能夠識別出他們之前所在的群組(可以通過用戶登錄等操作記錄其關聯群組信息)。
- 后端要將該群組在其離線期間產生的歷史消息查詢出來(這可能涉及到數據庫操作,將群組聊天消息存儲到數據庫中以便查詢歷史記錄),然后通過?
WebSocket
?連接將這些歷史消息逐一發送給重新上線的用戶。
后端代碼修改思路
1. 群組管理與消息處理
- 群組數據結構:使用合適的數據結構(如?
Map
)來存儲群組相關信息,以群組 ID 作為鍵,對應的值可以是包含該群組內在線用戶?WebSocketSession
?列表以及群組歷史消息列表等信息的對象。 - 消息格式定義:明確消息的格式,使其能區分是文字消息還是圖片消息,并且包含必要的元數據,比如發送者、群組 ID、消息內容(文字內容或圖片鏈接等)、時間戳等。
- 消息分發邏輯:當接收到消息時,根據消息中的群組 ID,找到對應的群組在線用戶列表,然后將消息發送給這些用戶。
2. 離線用戶歷史消息處理
- 用戶與群組關聯記錄:維護用戶與所屬群組的關聯關系,比如使用?
Map
?存儲用戶 ID 和其所屬群組 ID 列表的對應關系,以便在用戶重新上線時確定需要推送哪些群組的歷史消息。 - 歷史消息存儲與查詢:將群組內的聊天消息持久化存儲(實際應用中通常是存入數據庫,這里可簡單模擬存儲結構),當離線用戶重新上線時,從存儲結構中查詢出其所屬群組的歷史消息并推送給他。
直接 springboot+websokcet,感覺比原生的websocket簡單一點。
- ?集成websokcet
- ?配置文件
- ?handler
- postman測試一下
- ?uniapp
pom添加依賴:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency>
application.yml中 端口配置(先復用應用的端口吧)
server:port: 7877tomcat:accept-count: 10000threads:max: 800min-spare: 200compression:enabled: trueservlet:context-path: /chat
配置文件(application.yml)
- 務器端口及相關配置:
server.port
?配置為?7877
,指定了 Spring Boot 應用啟動后監聽的端口號。server.tomcat.accept-count
?設置為?10000
,它表示當所有的處理線程都在使用時,能夠放到處理隊列中的連接請求數量。server.tomcat.threads.max
?設為?800
?定義了最大線程數,min-spare
?設為?200
?則是最小備用線程數,這些配置用于優化 Tomcat 處理請求的線程資源分配。server.compression.enabled
?設為?true
,開啟了服務器響應內容的壓縮功能,有助于減少網絡傳輸的數據量,提高性能。server.servlet.context-path
?配置為?/chat
,意味著應用的上下文路徑是?/chat
,后續訪問應用中的資源路徑都是基于這個上下文路徑來構建的。
WebSocket 配置類(WebSocketConfig)
- 這個類實現了?
WebSocketConfigurer
?接口,用于配置 WebSocket 相關的處理。 - 在?
registerWebSocketHandlers
?方法中,將自定義的?MyWebSocketHandler
?注冊到了 WebSocket 處理器注冊表?WebSocketHandlerRegistry
?中,并且將 WebSocket 的端點路徑設置為?/websocket
,同時允許來自任意源(setAllowedOrigins("*")
)的連接訪問該 WebSocket 端點。
package com.edwin.java.config;import com.edwin.java.config.interceptor.GroupChatInterceptor;
import com.edwin.java.util.MyWebSocketHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {/*** registerWebSocketHandlers 是一個函數或方法,通常用于在 Web 應用程序中注冊 WebSocket 處理程序。* WebSocket 是一種基于 TCP 的協議,可以實現客戶端和服務器之間的雙向通信,可以用于實時應用程序,如聊天應用、游戲、實時更新等。在 Java Web 應用程序中,可以使用 Spring 框架提供的 WebSocket 支持來處理 WebSocket 連接。* registerWebSocketHandlers 方法是 Spring WebSocket 的一個 API,它允許開發人員在應用程序中注冊 WebSocket 處理程序,并將其映射到特定的 URI。在調用 registerWebSocketHandlers 方法時,需要傳遞一個 WebSocketHandler 實例和一個 URI 路徑作為參數。當客戶端請求與該 URI 路徑對應的 WebSocket 連接時,Spring 將調用相應的 WebSocket 處理程序來處理連接。*/@Overridepublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {//參數1:注冊我們自定義的MyWebSocketHandler類//參數2:路徑【UniApp中建立連接的路徑】如:我的ip是192.168.1.8:8099則UniApp需要輸入的url是ws://192.168.1.8:8099/websocket//參數3:setAllowedOrigins("*")設置允許全部來源【在WebSocket中,瀏覽器會發送一個帶有Origin頭部的HTTP請求來請求建立WebSocket連接。服務器可以使用setAllowedOrigins方法來設置允許的來源,即允許建立WebSocket連接的域名或IP地址。這樣,服務器就可以限制建立WebSocket連接的客戶端,避免來自不信任的域名或IP地址的WebSocket連接。】registry.addHandler(new MyWebSocketHandler(), "/websocket").setAllowedOrigins("*").addInterceptors(new GroupChatInterceptor());;}}
WebSocket 處理器類(MyWebSocketHandler)
- 連接建立:
afterConnectionEstablished
?方法在 WebSocket 連接建立后被調用,會記錄連接成功的日志信息,并將對應的?WebSocketSession
?添加到?sessions
?列表中,用于后續管理連接會話。
- 消息處理:
handleMessage
?方法接收到消息時,會記錄消息內容日志,然后遍歷所有已連接的會話,嘗試向每個客戶端發送一條固定格式的消息(這里只是簡單示例性質的消息)。handleTextMessage
?方法針對文本消息做更具體的處理,會對收到的請求消息進行轉義和記錄日志,然后構造響應消息并發送回對應的客戶端會話。
- 定時消息發送:
- 通過?
@Scheduled(fixedRate = 10000)
?注解定義了一個定時任務,每隔?10000
?毫秒(即 10 秒)會遍歷所有連接會話,如果會話處于打開狀態,就向其發送一條包含當前時間的廣播消息。
- 通過?
- 連接關閉及其他:
afterConnectionClosed
?方法在 WebSocket 連接關閉時被調用,負責從?sessions
?列表中移除對應的會話,并記錄連接關閉的日志。supportsPartialMessages
?方法返回?false
,表示不支持部分消息處理。handleTransportError
?方法用于處理 WebSocket 傳輸過程中的錯誤,會記錄相應的錯誤日志。
package com.edwin.java.util;import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.*;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.springframework.web.util.HtmlUtils;import java.io.IOException;
import java.time.LocalTime;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import com.fasterxml.jackson.databind.ObjectMapper;@Component
public class MyWebSocketHandler extends TextWebSocketHandler {private static final Logger LOGGER = LoggerFactory.getLogger(MyWebSocketHandler.class);// 用于存儲群組信息,鍵為群組ID,值包含在線用戶會話列表和歷史消息列表private Map<String, GroupInfo> groupInfos = new HashMap<>();// 用于存儲用戶與群組的關聯關系,鍵為用戶ID,值為群組ID列表private Map<String, List<String>> userGroups = new HashMap<>();private ObjectMapper objectMapper = new ObjectMapper();private final List<WebSocketSession> sessions = new CopyOnWriteArrayList<>();/*** afterConnectionEstablished 是一個 WebSocket API 中的回調函數,它是在建立 WebSocket 連接之后被調用的。* 當 WebSocket 連接建立成功后,瀏覽器會發送一個握手請求給服務器端,如果服務器成功地接受了該請求,那么連接就會被建立起來*/@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {LOGGER.info("WebSocket已連接: {}", session.getId());// 假設從WebSocket連接的屬性或者請求參數中獲取用戶ID和群組ID列表(實際需按業務邏輯調整獲取方式)String userId = (String) session.getAttributes().get("userId");List<String> groupIds = (List<String>) session.getAttributes().get("groupIds");if (userId!= null && groupIds!= null) {userGroups.put(userId, groupIds);for (String groupId : groupIds) {groupInfos.computeIfAbsent(groupId, k -> new GroupInfo()).addSession(session);}}}/*** handleMessage 是 WebSocket API 中的回調函數,它是用來處理從客戶端接收到的 WebSocket 消息的。* 當客戶端通過 WebSocket 連接發送消息到服務器端時,服務器端會自動調用 handleMessage 函數并傳遞收到的消息作為參數,你可以在該函數中處理這個消息,并根據需要向客戶端發送一些響應消息。*/@Overridepublic void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {LOGGER.info("WebSocket收到的消息: {}", message.getPayload());// 將消息反序列化,假設消息是JSON格式,這里解析為Message對象(下面定義)Message msg = objectMapper.readValue(message.getPayload().toString(), Message.class);String groupId = msg.getGroupId();GroupInfo groupInfo = groupInfos.get(groupId);if (groupInfo == null) {// 如果群組信息不存在,則創建新的群組信息,并添加當前用戶的WebSocketSessiongroupInfo = new GroupInfo();groupInfo.addSession(session);groupInfos.put(groupId, groupInfo);// 同時,假設這里從WebSocket連接的屬性或者請求參數中獲取用戶ID(實際需按業務邏輯調整獲取方式)String userId = (String) session.getAttributes().get("userId");List<String> groupIds = userGroups.computeIfAbsent(userId, k -> new ArrayList<>());groupIds.add(groupId);}// 將消息添加到群組歷史消息列表groupInfo.addHistoryMessage(msg);// 向群組內所有在線用戶發送消息List<WebSocketSession> sessions = groupInfo.getSessions();for (WebSocketSession s : sessions) {try {s.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg)));} catch (IOException e) {LOGGER.error("無法發送WebSocket消息", e);}}}@Overridepublic void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {handleMessage(session, message);}@Scheduled(fixedRate = 10000)void sendPeriodicMessages() throws IOException {// 這里可擴展定時向群組推送系統消息等功能,暫不做詳細修改for (GroupInfo groupInfo : groupInfos.values()) {List<WebSocketSession> sessions = groupInfo.getSessions();for (WebSocketSession s : sessions) {if (s.isOpen()) {String broadcast = "server periodic message " + LocalDateTime.now();LOGGER.info("Server sends: {}", broadcast);s.sendMessage(new TextMessage(broadcast));}}}}// 處理用戶重新上線,推送歷史消息的方法public void handleUserReconnect(String userId, WebSocketSession session) {List<String> groupIds = userGroups.get(userId);if (groupIds!= null) {for (String groupId : groupIds) {GroupInfo groupInfo = groupInfos.get(groupId);if (groupInfo!= null) {List<Message> historyMessages = groupInfo.getHistoryMessages();for (Message historyMessage : historyMessages) {try {session.sendMessage(new TextMessage(objectMapper.writeValueAsString(historyMessage)));} catch (IOException e) {LOGGER.error("無法發送歷史消息給重新上線用戶", e);}}}}}}/*** afterConnectionClosed 是 WebSocket API 中的回調函數,它是在 WebSocket 連接關閉后被調用的。* 當客戶端或服務器端主動關閉 WebSocket 連接時,afterConnectionClosed 回調函數會被調用,你可以在該函數中執行一些資源釋放、清理工作等操作,比如關閉數據庫連接、清理緩存等。*/@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {LOGGER.info("WebSocket已斷開連接: {}", session.getId());// 移除用戶會話在各個群組中的關聯String userId = (String) session.getAttributes().get("userId");List<String> groupIds = userGroups.get(userId);if (groupIds!= null) {for (String groupId : groupIds) {GroupInfo groupInfo = groupInfos.get(groupId);if (groupInfo!= null) {groupInfo.removeSession(session);}}userGroups.remove(userId);}}/*** supportsPartialMessages 是 WebSocket API 中的方法,它用來指示 WebSocket 消息是否支持分段傳輸。* WebSocket 消息可以分段傳輸,也就是說一個消息可以被分成多個部分依次傳輸,這對于大型數據傳輸和流媒體傳輸非常有用。當消息被分成多個部分傳輸時,WebSocket 會自動將這些部分合并成完整的消息。* supportsPartialMessages 方法用來指示服務器是否支持分段消息傳輸,如果支持,則可以在接收到部分消息時開始處理消息,否則需要等待接收到完整消息后才能開始處理。*/@Overridepublic boolean supportsPartialMessages() {return false;}/*** handleTransportError 是 WebSocket API 中的回調函數,它用來處理 WebSocket 傳輸層出現錯誤的情況。*當 WebSocket 傳輸層出現錯誤,比如網絡中斷、協議錯誤等,WebSocket 會自動調用 handleTransportError 函數,并傳遞相應的錯誤信息。在該函數中,我們可以處理這些錯誤,比如關閉 WebSocket 連接、記錄錯誤日志等。*/@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {LOGGER.error("WebSocket錯誤", exception);}// 在MyWebSocketHandler類內部定義private class GroupInfo {// 存儲群組內所有在線用戶的WebSocketSession列表private List<WebSocketSession> sessions = new ArrayList<>();// 存儲群組的歷史消息列表,消息以自定義的Message對象形式存儲(前面代碼中已定義Message類)private List<Message> historyMessages = new ArrayList<>();// 添加一個用戶的WebSocketSession到群組的在線用戶列表中public void addSession(WebSocketSession session) {sessions.add(session);}// 從群組的在線用戶列表中移除指定用戶的WebSocketSessionpublic void removeSession(WebSocketSession session) {sessions.remove(session);}// 向群組的歷史消息列表中添加一條消息public void addHistoryMessage(Message message) {historyMessages.add(message);}// 獲取群組內所有在線用戶的WebSocketSession列表public List<WebSocketSession> getSessions() {return sessions;}// 獲取群組的歷史消息列表public List<Message> getHistoryMessages() {return historyMessages;}}// 定義消息類,包含必要的消息屬性,可根據實際需求擴展private static class Message {private String type; // 消息類型,如 "text" 或 "image"private String groupId; // 群組IDprivate String sender; // 發送者(可根據實際情況完善,比如用戶ID等)private String content; // 消息內容,文字或圖片鏈接等// private LocalDateTime timestamp = LocalDateTime.now(); // 時間戳// 生成必要的Getter和Setter方法(可使用Lombok簡化代碼,此處為清晰展示手動編寫)public String getType() {return type;}public void setType(String type) {this.type = type;}public String getGroupId() {return groupId;}public void setGroupId(String groupId) {this.groupId = groupId;}public String getSender() {return sender;}public void setSender(String sender) {this.sender = sender;}public String getContent() {return content;}public void setContent(String content) {this.content = content;}// public LocalDateTime getTimestamp() {
// return timestamp;
// }
//
// public void setTimestamp(LocalDateTime timestamp) {
// this.timestamp = timestamp;
// }}}
?postman驗證:
注意,路徑中要加chat ,因為application.yml中配置了
uniapp端頁面:
<template><view class="chat-room"><!-- 聊天記錄 --><scroll-view scroll-y="true" class="message-list"><view v-for="(message, index) in messages" :key="index" class="message-item"><view v-if="message.type === 'text'" class="text-message"><!-- <view class="avatar">{{ message.senderAvatar }}</view> --><view class="text">{{ message.content }}</view></view><view v-else-if="message.type === 'image'" class="image-message"><image :src="message.content" class="message-image" @click="previewImage(message.content)"></image></view></view></scroll-view><!-- 輸入框 --><view class="input-area"><input v-model="inputContent" placeholder="輸入內容" class="input" /><button @click="sendMessage">發送</button></view></view>
</template><script>
export default {data() {return {messages: [], // 聊天記錄inputContent: '', // 輸入框內容socketOpen: false, // WebSocket連接狀態socket: null, // WebSocket對象};},methods: {// 初始化WebSocket連接initWebSocket() {uni.setStorageSync('userId',1); // only testuni.setStorageSync('groupIds',2); // only testconst userId = uni.getStorageSync('userId');const groupIds = uni.getStorageSync('groupIds');console.log(groupIds);// /wechat/client/chatthis.socket = uni.connectSocket({url: 'ws://127.0.0.1:7877/chat/websocket',data: {userId: userId,groupIds: groupIds},success: () => {console.log('WebSocket連接成功');this.socketOpen = true;},fail: () => {console.error('WebSocket連接失敗');},});// 監聽WebSocket消息this.socket.onMessage((res) => {const message = JSON.parse(res.data);this.messages.push(message); // 將新消息添加到聊天記錄中this.$forceUpdate(); // 強制更新視圖,確保新消息顯示});// 監聽WebSocket連接關閉this.socket.onClose(() => {console.log('WebSocket連接關閉');this.socketOpen = false;});},// 發送消息
sendMessage() {if (this.inputContent.trim() === '') {uni.showToast({title: '請輸入內容',icon: 'none',});return;}const message = {type: 'text', // 消息類型,可以是text或image,這里發送文字消息示例,發送圖片時修改相應字段groupId: uni.getStorageSync('groupIds'), // 從本地存儲獲取當前所在群組ID(需按實際情況調整獲取方式)sender: uni.getStorageSync('userId'), // 從本地存儲獲取用戶ID(需按實際情況調整獲取方式)content: this.inputContent};// 通過WebSocket發送消息(或HTTP請求,根據后端接口決定)if (this.socketOpen) {this.socket.send({data: JSON.stringify(message)});} else {//todo}
},// 預覽圖片previewImage(url) {uni.previewImage({current: url, // 當前顯示圖片的http鏈接urls: [url], // 需要預覽的圖片http鏈接列表});},},mounted() {// 頁面加載時初始化WebSocket連接this.initWebSocket();// 可以從服務器獲取歷史聊天記錄并初始化messages數組(根據需求實現)},
};
</script><style>
.chat-room {padding: 10px;
}.message-list {height: 500px; /* 根據需要調整高度 */border-bottom: 1px solid #ccc;padding-right: 10px; /* 留出空間給滾動條 */overflow-y: auto;
}.message-item {margin-bottom: 10px;display: flex;align-items: center;
}.avatar {width: 40px;height: 40px;border-radius: 50%;margin-right: 10px;
}.text-message .text {background-color: #fff;padding: 5px 10px;border-radius: 5px;max-width: 60%; /* 根據需要調整寬度 */word-wrap: break-word; /* 防止長文本溢出 */
}.image-message .message-image {width: 100px;height: 100px;object-fit: cover;border-radius: 5px;
}.input-area {display: flex;margin-top: 10px;
}.input {flex: 1;padding: 5px;border: 1px solid #ccc;border-radius: 5px;
}button {padding: 5px 10px;margin-left: 10px;border: none;background-color: #1aad19;color: #fff;border-radius: 5px;
}
</style>
原型效果:
?
后期再補充~~~~~