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: 7877
tomcat:
accept-count: 10000
threads:
max: 800
min-spare: 200
compression:
enabled: true
servlet:
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 处理程序来处理连接。
*/
@Override
public 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 edu.yzu.testspringboot002.handler;
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 java.io.IOException;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import org.springframework.web.socket.WebSocketSession;
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列表 一个用户可以加入多个群组 它是一个Map,键是用户ID,值是群组ID列表
private Map<String, List<String>> userGroups = new HashMap<>();
private Map<String,String> sessionId_userId =new HashMap<>(); //存放 sessionId 与 userId 的map
private ObjectMapper objectMapper = new ObjectMapper();
private final List<WebSocketSession> sessions = new CopyOnWriteArrayList<>();
//
public int checkUserIdAndSessionId(String sessionId,String userId ) {
// 遍历userId_SessionId(虽然在这个场景中并不需要遍历)
for (Map.Entry<String, String> entry : sessionId_userId.entrySet()) {
String storedSessionId = entry.getKey();
String storedUserId = entry.getValue();
// 检查userId是否存在
if (storedUserId.equals(userId)) {
// 如果存在,进一步判断对应的sessionId是否和参数sessionId相等
if (storedSessionId.equals(sessionId)) {
return 0; // 相等返回0
} else {
return 2; // 不等返回2
}
}
}
// 其他情况返回3(即userId不存在)
return 3;
}
/**
* afterConnectionEstablished 是一个 WebSocket API 中的回调函数,它是在建立 WebSocket 连接之后被调用的。
* 当 WebSocket 连接建立成功后,浏览器会发送一个握手请求给服务器端,如果服务器成功地接受了该请求,那么连接就会被建立起来
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
LOGGER.info("WebSocket已连接: {}", session.getId());
// 假设从WebSocket连接的属性或者请求参数中获取用户ID和群组ID列表(实际需按业务逻辑调整获取方式)
// 理想情况下 应该是从请求参数中获取userId和groupId 我们在每次连接的时候都会传递userId和groupId 但是但是 我们先不在这里传递,每次收到消息的时候再传递
// 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) {
// // 用于存储群组信息,键为群组ID,值包含在线用户会话列表和历史消息列表
// groupInfos.computeIfAbsent(groupId, k -> new GroupInfo()).addSession(session);
// }
// }
//System.out.println("groupInfos: " + groupInfos);
}
/**
* handleMessage 是 WebSocket API 中的回调函数,它是用来处理从客户端接收到的 WebSocket 消息的。
* 当客户端通过 WebSocket 连接发送消息到服务器端时,服务器端会自动调用 handleMessage 函数并传递收到的消息作为参数,你可以在该函数中处理这个消息,并根据需要向客户端发送一些响应消息。
*/
@Override
public 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();
String userId=msg.getUserId();
int flag=this.checkUserIdAndSessionId(session.getId(),userId); //检查userId和sessionId是否匹配
//flag==0 说明userId和sessionId匹配 ,不做更多的操作 直接执行下面的代码
if(flag==2){ //userId一致 但是 sessionId不一致,说明用户重新建立了session,要把原来该用户相关的session等全部删除掉
//找到这个用户下原来所有的session,然后全部删除掉
for (Map.Entry<String, String> entry : sessionId_userId.entrySet()) {
String storedSessionId = entry.getKey();
String storedUserId = entry.getValue();
if (storedUserId.equals(userId)) {
sessionId_userId.remove(storedSessionId);
}
//by userId remove all the groupInfo
List<String> groupIds = userGroups.get(userId);
if (groupIds!= null) {
for (String groupId1 : groupIds) {
GroupInfo groupInfo = groupInfos.get(groupId1);
if (groupInfo!= null) {
groupInfo.removeSession(session);
}
}
userGroups.remove(userId);
}
}
}else if(flag==3){ //userId不存在 session不存在 ,用户第一次建立连接
//do nothing 放在这里就是为了说明flag==3的情况
}
if(flag==2 || flag==3){
sessionId_userId.put(session.getId(),userId); //sessionId 与 userId 建立映射 是1对1的关系
List<String> groupIds = userGroups.computeIfAbsent(userId, k -> new ArrayList<>()); // 获取用户的群组ID列表
groupIds.add(groupId);
}
GroupInfo groupInfo = groupInfos.get(groupId);
if (groupInfo == null) {
// 如果群组信息不存在,则创建新的群组信息,并添加当前用户的WebSocketSession
groupInfo = 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<>()); // 获取用户的群组ID列表
// groupIds.add(groupId);
}else {
groupInfo.addSession(session); //addSession()方法会检查是否已经存在,如果存在就不会再添加
}
// 将消息添加到群组历史消息列表
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);
}
}
}
@Override
public 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 回调函数会被调用,你可以在该函数中执行一些资源释放、清理工作等操作,比如关闭数据库连接、清理缓存等。
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
LOGGER.info("WebSocket已断开连接: {}", session.getId());
//应该根据sessionid 出发,找到userId,且把该用户名下的所有的GroupInfo 中的 session 全部删除掉
// 移除用户会话在各个群组中的关联
//String userId = (String) session.getAttributes().get("userId");
//query userId by sessionId
String userId = sessionId_userId.get(session.getId());
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 方法用来指示服务器是否支持分段消息传输,如果支持,则可以在接收到部分消息时开始处理消息,否则需要等待接收到完整消息后才能开始处理。
*/
@Override
public boolean supportsPartialMessages() {
return false;
}
/**
* handleTransportError 是 WebSocket API 中的回调函数,它用来处理 WebSocket 传输层出现错误的情况。
*当 WebSocket 传输层出现错误,比如网络中断、协议错误等,WebSocket 会自动调用 handleTransportError 函数,并传递相应的错误信息。在该函数中,我们可以处理这些错误,比如关闭 WebSocket 连接、记录错误日志等。
*/
@Override
public 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) {
if(0==sessions.size()){
sessions.add(session);
}else {
//iterator sessions, if the session is already in the list, do not add it again,else add it
for (WebSocketSession s : sessions) {
if (s.getId().equals(session.getId())) {
return;
}
}
sessions.add(session);
}
}
// 从群组的在线用户列表中移除指定用户的WebSocketSession
public 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; // 群组ID
private String sender; // 发送者(可根据实际情况完善,比如用户ID等)
private String content; // 消息内容,文字或图片链接等
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
private String userId; // 新增字段
// 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中配置了
后台message结构:
private static class Message {
private String type; // 消息类型,如 "text" 或 "image"
private String groupId; // 群组ID
private String sender; // 发送者(可根据实际情况完善,比如用户ID等)
private String content; // 消息内容,文字或图片链接等
......
}
前端发送消息也要和这个对应:
sendMessage(message) {
this.socket.send(JSON.stringify({
type: 'message',
userId: this.userId,
groupId:this.groupId, // todo
...message,
}));
}
服务端socket每次收到消息,先执行如下流程,(这个流程可以到websocket拦截器中去执行?)。