最近领导让做一个多人聊天功能,小编抠脑壳硬是半天没写出来个所以然,于是网上找了个多人聊天室Demo改出来一款基于SpringBoot实现的实用性较强的聊天室功能(也适用于单人聊天)。
思想:总共用两个连接,一个用于聊天室消息监听,一个用于监听聊天室列表未读消息。
调用顺序:先连接聊天室列表,再连接聊天室。
当有用户在聊天室发送消息时,消息正常推送,同步调用聊天室列表推送消息,通过所有房间在线人数和用户所属聊天室关系过滤,找出对应聊天室需要推送未读消息的在线用户。然后推送未读消息。逻辑大概是这样。
下面看实现:
pom引入的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>jakarta.websocket</groupId>
<artifactId>jakarta.websocket-api</artifactId>
<version>1.1.2</version>
<scope>compile</scope>
</dependency>
WebSocket配置
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
/**
* 这个bean的注册,用于扫描带有@ServerEndpoint的注解成为websocket,如果你使用外置的tomcat就不需要该配置文件
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
/**
* 注入service
* @param complainService
*/
@Autowired
public void setChatRoomMessageService(ChatRoomMessageService chatRoomMessageService){
WebSocketUtil.chatRoomMessageService = chatRoomMessageService;
}
}
聊天传参
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* @author ywb
* @date 2024/7/29 16:14
*/
@Data
public class SocketMsg {
@Schema(description = "聊天类型 0 全局广播 1 单聊 2 群聊")
private int type;
@Schema(description = "发送者id")
private Long userId;
@Schema(description = "发送者")
private String userName;
@Schema(description = "接收者")
private String receiveUser;
@Schema(description = "房间id")
private Long roomId;
@Schema(description = "消息")
private String content;
@Schema(description = "消息类型(IMAGE-照片,VIDEO-视频,VOICEMAIL-语音,TITLE-文字)")
private ChatRoomMessageTypeEnum messageType;
}
聊天列表和详情公用的参数
import lombok.Data;
import javax.websocket.Session;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* @author ywb
* @date 2024/8/2 9:07
*/
@Data
public abstract class WebSocketShareParam {
/**
* 存放Session集合,方便推送消息 (jakarta.websocket)
*/
public static ConcurrentHashMap<String, Session> sessionMap = new ConcurrentHashMap<>();
/**
* 存放房间用户集合,方便推送消息 (jakarta.websocket)
*/
public static HashMap<Long, List<Long>> groupSessionMap = new HashMap<>();
public static ChatRoomMessageService chatRoomMessageService;
}
聊天室连接
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONUtil;
import javax.annotation.Resource;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import com.google.gson.Gson;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
/**
* @author ywb
* @date 2024/7/29
* @description WebSocket工具类
*/
@Slf4j
@Component
@ServerEndpoint("/websocket/{userId}/{roomId}")
public class WebSocketUtil extends WebSocketShareParam{
private Long userId;
private Session session;
private Long roomId;
/**
* 群聊:向指定房间ID推送消息
*/
public synchronized static void groupMessage(SocketMsg socketMsg) {
// 存储房间号和用户信息
Long roomId = socketMsg.getRoomId();
// 判断是否有这个房间
List<Long> strings = groupSessionMap.get(roomId);
if (ObjectUtils.isEmpty(strings)) {
List<Long> users = new ArrayList<>();
users.add(socketMsg.getUserId());
groupSessionMap.put(roomId, users);
} else {
// 这里应该写接口,先添加房间ID,简易写法直接传过来
List<Long> users = groupSessionMap.get(roomId);
Long sendOutUser = socketMsg.getUserId();
boolean contains = users.contains(sendOutUser);
if (!contains) {
users.add(sendOutUser);
}
}
// 发送给接收者
if (roomId != null) {
//保存历史消息
Long messageId = chatRoomMessageService.sendMessage(socketMsg);
ChatRoomMessageDTO dto = chatRoomMessageService.getNewMessages(messageId);
JSON json = JSONUtil.parse(dto);
// 发送给接收者
log.info("发送消息对象:"+json.toString());
// 此时要判断房间有哪些人,把这些消息定向发给处于此房间的用户
List<Long> roomUser = groupSessionMap.get(roomId);
for (Long userId : roomUser) {
// 接收消息的用户
Session receiveUser = sessionMap.get(roomId + "_" + userId);
receiveUser.getAsyncRemote().sendObject(json.toString());
}
WebSocketListUtil.countUnreadMessage(roomId);
} else {
// 发送消息的用户
log.info(socketMsg.getUserName() + " 私聊的用户 " + socketMsg.getReceiveUser() + " 不在线或者输入的用户名不对");
Session sendOutUser = sessionMap.get(roomId + "_" + socketMsg.getUserName());
// 将系统提示推送给发送者
sendOutUser.getAsyncRemote().sendObject("系统消息:对方不在线或者您输入的用户名不对");
}
}
/**
* 私聊:向指定客户端推送消息
*/
public synchronized static void privateMessage(SocketMsg socketMsg) {
Long roomId = socketMsg.getRoomId();
// 接收消息的用户
Session receiveUser = sessionMap.get(roomId + "_" + socketMsg.getReceiveUser());
// 发送给接收者
if (receiveUser != null) {
// 发送给接收者
log.info(socketMsg.getUserName() + " 向 " + socketMsg.getReceiveUser() + " 发送了一条消息:" + socketMsg.getContent());
receiveUser.getAsyncRemote().sendObject(socketMsg.getUserName() + ":" + socketMsg.getContent());
} else {
// 发送消息的用户
log.info(socketMsg.getUserName() + " 私聊的用户 " + socketMsg.getReceiveUser() + " 不在线或者输入的用户名不对");
Session sendOutUser = sessionMap.get(roomId + "_" + socketMsg.getUserName());
// 将系统提示推送给发送者
sendOutUser.getAsyncRemote().sendObject("系统消息:对方不在线或者您输入的用户名不对");
}
}
/**
* 聊天室里面推送消息
*/
public synchronized static void roomMessage(String room,List<Long> orDefault,String message) {
for (Long userId : orDefault){
Session session = sessionMap.get(room + userId);
ChatRoomMessageDTO dto = new ChatRoomMessageDTO();
dto.setType(ChatRoomMessageTypeEnum.SYSTEM);
dto.setContent(message);
session.getAsyncRemote().sendObject( JSONUtil.parse(dto).toString());
}
log.info("系统接收了一条消息:" + message);
}
/**
* 聊天室监听:连接成功
*
* @param session
* @param userId 连接的用户id
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") Long userId, @PathParam("roomId") Long roomId) {
this.userId = userId;
this.session = session;
this.roomId = roomId;
String room = roomId +"_";
String name = room + userId;
List<Long> orDefault = groupSessionMap.getOrDefault(roomId, new ArrayList<>());
if (!orDefault.contains(userId)){
orDefault.add(userId);
groupSessionMap.put(roomId, orDefault);
}
if (!sessionMap.containsKey(name)) {
sessionMap.put(name, session);
// 在线数加1
String tips = userId + " 加入聊天室。当前聊天室人数为" + orDefault.size();
log.info(tips);
roomMessage(room,orDefault,tips);
}
}
/**
* 聊天室监听: 连接关闭
*/
@OnClose
public void onClose() {
String room = roomId +"_";
String name = room + userId;
List<Long> list = groupSessionMap.get(roomId);
list.remove(userId);
// 连接关闭后,将此websocket从set中删除
sessionMap.remove(name);
String tips = userId + " 退出聊天室。当前聊天室人数为" + list.size();
log.info(tips);
roomMessage(room,list,tips);
}
/**
* 监听:收到客户端发送的消息
* @param message 发送的信息(json格式,里面是 SocketMsg 的信息)
*/
@OnMessage
public void onMessage(String message) {
if (JSONUtil.isTypeJSONObject(message)) {
SocketMsg socketMsg = JSONUtil.toBean(message, SocketMsg.class);
if (socketMsg.getType() == 2) {
// 群聊,需要找到发送者和房间ID
groupMessage(socketMsg);
} else if (socketMsg.getType() == 1) {
// 单聊,需要找到发送者和接受者
privateMessage(socketMsg);
} else {
// 全局广播群发消息
// publicMessage(socketMsg.getContent());
// 聊天室广播群发消息
List<Long> list = groupSessionMap.get(roomId);
roomMessage(roomId +"_",list,socketMsg.getContent());
}
}
}
/**
* 监听:发生异常
* @param error
*/
@OnError
public void onError(Throwable error) {
log.info("userName为:" + userId + ",发生错误:" + error.getMessage());
error.printStackTrace();
}
}
聊天室列表未读消息连接
import cn.hutool.json.JSONUtil;
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.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* @author ywb
* @date 2024/8/2 9:04
*/
@Slf4j
@Component
@ServerEndpoint("/websocket/list/{userId}")
public class WebSocketListUtil extends WebSocketShareParam{
private Long userId;
private Session session;
/**
* 存放聊天室列表Session集合,推送消息
*/
public static ConcurrentHashMap<String, Session> roomListsessionMap = new ConcurrentHashMap<>();
//用户所有的房间信息
private static Map<Long, List<Long>> roomIdMap = new HashMap<>();
/**
* 固定前缀
*/
private static final String ROOM_PREFIX = "ROOM_";
/**
* 聊天室监听:连接成功
*
* @param session
* @param userId 连接的用户id
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") Long userId) {
this.userId = userId;
this.session = session;
List<Long> roomIds = chatRoomMessageService.selectRoomIds(userId);
roomIdMap.put(userId, roomIds);
String name = ROOM_PREFIX + userId;
if (!roomListsessionMap.containsKey(name)) {
roomListsessionMap.put(name, session);
// 在线数加1
String tips = userId + " 进入聊天室列表。当前聊天室列表人数为" + roomIdMap.size();
log.info(tips);
roomListMessage(tips);
}
}
/**
* 聊天室监听: 连接关闭
*/
@OnClose
public void onClose() {
String name = ROOM_PREFIX + userId;
roomIdMap.remove(userId);
// 连接关闭后,将此websocket从set中删除
roomListsessionMap.remove(name);
String tips = userId + " 退出聊天室列表。当前聊天室列表人数为" + roomListsessionMap.size();
log.info(tips);
roomListMessage(tips);
}
/**
* 监听:收到客户端发送的消息
*
* @param param 房间id
*/
@OnMessage
public void onMessage(String param) {
if (JSONUtil.isTypeJSONObject(param)) {
Long roomId = JSONUtil.parse(param).getByPath("roomId", Long.class);
// groupMessage(roomId);
countUnreadMessage(roomId);
}
}
/**
* 统计在线用户roomId的未读消息总数
*/
public synchronized static void countUnreadMessage(Long roomId) {
//聊天室列表在线用户,至少有1个(消息发送者)
Enumeration<String> enumeration = roomListsessionMap.keys();
List<Long> userIds = new ArrayList<>();
while(enumeration.hasMoreElements()) {
Long userId = Long.parseLong(enumeration.nextElement().replace(ROOM_PREFIX, ""));
userIds.add(userId);
}
//统计房间对应在线用户的未读消息
List<ChatRoomListDTO> unreadMessage = chatRoomMessageService.countUnreadMessageCount(userIds,roomId);
for (ChatRoomListDTO dto : unreadMessage){
Long userId = dto.getUserId();
Session receiveUser = roomListsessionMap.get(ROOM_PREFIX + userId);
if (receiveUser != null){
receiveUser.getAsyncRemote().sendObject(JSONUtil.parse(dto).toString());
}
}
}
/**
* 统计在线用户roomId的未读消息+1
* 群聊:向指定用户列表添加未读消息(过滤了当前在聊天室的用户,如果不过来这部分用户,可以省略判断,直接发送消息)
*/
public synchronized static void groupMessage(Long roomId) {
//聊天室列表在线用户,至少有1个(消息发送者)
Enumeration<String> enumeration = roomListsessionMap.keys();
//聊天室内在线用户
List<Long> orDefault = groupSessionMap.get(roomId);
//过滤出所有不在该房间的在线用户未读消息+1
while(enumeration.hasMoreElements()) {
Long userId = Long.getLong(enumeration.nextElement().replace(ROOM_PREFIX, ""));
if (orDefault.contains(userId)){
continue;
}
//用户所有的聊天室
List<Long> roomIds = roomIdMap.get(userId);
if (roomIds.contains(roomId)){
// 接收消息的用户
Session receiveUser = null;
for (Long room : roomIds){
if (sessionMap.get(room + "_" + userId) != null){
receiveUser = roomListsessionMap.get(ROOM_PREFIX + userId);
break;
}
}
if (receiveUser == null) continue;
receiveUser.getAsyncRemote().sendText(userId + " 的房间 【 " + roomId + " 】 新增了一条未读消息。" );
}
}
}
public synchronized static void roomListMessage( String message) {
for (Long userId : roomIdMap.keySet()){
Session session = roomListsessionMap.get(ROOM_PREFIX + userId);
session.getAsyncRemote().sendText( message);
log.info("聊天室列表收了一条消息:" + message);
}
}
/**
* 监听:发生异常
*
* @param error
*/
@OnError
public void onError(Throwable error) {
log.info("userName为:" + userId + ",发生错误:" + error.getMessage());
error.printStackTrace();
}
}
这里 —> 参考链接
前端对接可以参考链接实现,小编前端是个菜鸟,就不班门弄斧了。
最后我们来说一下连接测试
小编使用的是Apifox进行的后端测试(没有的自行百度下载)
注意:Apifox 版本号需 ≥ 2.2.32 才能管理 WebSocket 接口
如图点击新建WebSocket接口进入后输入:ws://localhost:8080/websocket/{userId}/{roomId}
进行连接测试。
注意参数修改:
port -> 修改自己的端口号为项目端口号
userId -> 用户id 可以使用userName测试更直观,但是可能存在重名情况
roomId -> 聊天室id
至此,功能实现。