<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
/**
* WebSocket的配置信息
*/
@Configuration
public class WebSocketConfig extends ServerEndpointConfig.Configurator {
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
//获取httpsession
HttpSession session = (HttpSession) request.getHttpSession();
sec.getUserProperties().put(HttpSession.class.getName(), session);
}
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
监听器, 主要是同步httpSession, 这样就可以拿到用户信息
/**
* 监听器类:主要任务是用ServletRequest将我们的HttpSession携带过去
*/
@Component
public class RequestListener implements ServletRequestListener {
@Override
public void requestInitialized(ServletRequestEvent sre) {
//将所有request请求都携带上httpSession
((HttpServletRequest) sre.getServletRequest()).getSession();
}
public RequestListener() {}
@Override
public void requestDestroyed(ServletRequestEvent arg0) {}
}
因为项目启动加载websocket时, spring bean容器还没就绪, 通过@Resource等自动注入是null, 所以需要在项目启动时提前实例化bean给websocketservice使用
@Service
public class BeanUtils implements BeanFactoryAware {
// Spring的bean工厂
private static BeanFactory beanFactory;
@Override
public void setBeanFactory(BeanFactory factory) throws BeansException {
beanFactory=factory;
}
public static<T> T getBean(String beanName){
return (T) beanFactory.getBean(beanName);
}
}
详细的注释代码里有
@Component
@Slf4j
//todo 前端的连接地址,可以自己修改 示例为: ws://ip:端口/message/userId
@ServerEndpoint("/message/{userId}")
public class WebSocketServer {
/**
* 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。
*/
private static int onlineCount = 0;
/**
* concurrent包的线程安全Set,用来存放每个客户端对应的WebSocket对象。
* 注意, 这种方式 只适用于单节点部署,
* 如果是多接点部署, 各个节点之间的在线用户是不互通的, 需要改用其他方式.
*/
private static final ConcurrentHashMap<String, WebSocketServer> webSocketMap = new ConcurrentHashMap<>();
/**
* 与某个客户端的连接会话,需要通过它来给客户端发送数据
*/
private Session session;
/**
* 接收userId
*/
private String userId = "";
private final MessageService messageService = BeanUtils.getBean("messageService");
private final ChatListService chatListService = BeanUtils.getBean("chatListService");;
/**
* 连接关闭
* 调用的方法
*/
@OnClose
public void onClose() {
if (webSocketMap.containsKey(userId)) {
webSocketMap.remove(userId);
//从set中删除
subOnlineCount();
}
//todo 修改数据库, 用户的状态为下线
log.info("用户退出:" + userId + ",当前在线人数为:" + getOnlineCount());
//todo 测试回应,可以和前端约定返回值
//todo 如果有订阅下线提醒的逻辑 可以加在这里
sendMessage("用户下线");
}
/**
* 连接建立成
* 功调用的方法
*/
@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId, EndpointConfig config) {
//todo 用户体系接入后, 可以拿session里的user而不是用明文路径的形式传递userid, 安全性更高
// HttpSession httpSession= (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
// User user = (User) httpSession.getAttribute("user");
this.session = session;
this.userId = userId;
if (webSocketMap.containsKey(userId)) {
webSocketMap.remove(userId);
//加入set中
webSocketMap.put(userId, this);
} else {
//加入set中
webSocketMap.put(userId, this);
//在线数加1
addOnlineCount();
}
//todo 修改数据库, 用户的状态为在线
log.info("用户连接:" + userId + ",当前在线人数为:" + getOnlineCount());
//todo 测试回应,可以和前端约定返回具体什么代表上线
//todo 如果有订阅上线提醒的逻辑 可以加在这里
sendMessage("连接成功");
}
/**
* 收到客户端消息后调用的方法
*
* @param message 客户端发送过来的消息
* 目前暂定的前端带过来的结构为: {"to":"xxx","msg":"xxx"}
* todo message结构可以按具体情况自定义约定修改
**/
@OnMessage
public void onMessage(String message, Session session) {
log.info("用户消息:" + userId + ",报文:" + message);
//todo 群发消息(群聊消息)的话, message 的结构改动判断即可
//消息保存到数据库、redis
if (StringUtils.isNotBlank(message)) {
try {
//解析发送的报文
JSONObject jsonObject = JSON.parseObject(message);
//追加发送人(防止篡改)
jsonObject.put("from", this.userId);
String toUserId = jsonObject.getString("to");
//todo 这里可以效验目标id是否是正确的id
if (StringUtils.isNotBlank(toUserId)) {
Message messageEntity;
//todo 判断目标用户是否是自己的好友,
if (true) {
//消息记录保存到数据库
messageEntity = Message.builder()
.fromId(jsonObject.getLong("from"))
.toId(jsonObject.getLong("to"))
.content(jsonObject.getString("msg"))
//单聊消息固定: 1, 后续拓展群里等消息对应定义值
.type(1)
.time(new Date())
.isTransport(1)
.build();
messageService.save(messageEntity);
// 重置好友聊天列表的内容和最后时间, 展示在最上面
chatListService.updateChatList(messageEntity);
//判断用户是否在线 在线实时推送, 不在线不推送
if (webSocketMap.containsKey(toUserId)) {
//回推数据结构: {"from":"xxx", "to":"xxx","msg":"xxx"}
//todo 可以按具体情况自定义约定修改
webSocketMap.get(toUserId).sendMessage(jsonObject.toJSONString());
}
} else {
//todo 不为对方好友, 或者被拉黑等, isTransport 是否送达, 设置为0
//或者不需要保存这些记录的话, 这段删了也行
messageEntity = Message.builder()
.fromId(jsonObject.getLong("from"))
.toId(jsonObject.getLong("to"))
.content(jsonObject.getString("msg"))
//单聊消息固定: 1, 后续拓展群里等消息对应定义值
.type(1)
.time(new Date())
.isTransport(0)
.build();
messageService.save(messageEntity);
//todo 可以发送消息提醒, 请先添加对方为好友
// sendMessage("xxx");
}
} else {
log.error("请求的userId为空");
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
/**
* @param session Session
* @param error Throwable
*/
@OnError
public void onError(Session session, Throwable error) {
//todo 服务器关闭或者其他不可预料情况, 会走这里, 可以在这里对用户做强制下线处理
log.error("用户错误:" + this.userId + ",原因:" + error.getMessage());
error.printStackTrace();
}
/**
* 服务器主动推送
* todo 需求: 新用户注册, 用户开通vip等, 用户消费,礼物赠送等 场景, 调用此函数即可 发送消息
* todo 消息内容结构同上, 和前端约定即可
*/
public void sendMessage(String message) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 发送自定义消息
* 同上 "服务器主动推送" 接口
**/
public static void sendInfo(String userId, String message) {
log.info("发送消息到:" + userId + ",报文:" + message);
if (StringUtils.isNotBlank(userId) && webSocketMap.containsKey(userId)) {
webSocketMap.get(userId).sendMessage(message);
} else {
log.warn("用户" + userId + ",不在线!");
}
}
public static void sendInfo(Long userId, String message) {
sendInfo(String.valueOf(userId), message);
}
/**
* 获得此时的
* 在线人数
*
* @return int
*/
public static synchronized int getOnlineCount() {
return onlineCount;
}
/**
* 在线人
* 数加1
*/
public static synchronized void addOnlineCount() {
WebSocketServer.onlineCount++;
}
/**
* 在线人
* 数减1
*/
public static synchronized void subOnlineCount() {
WebSocketServer.onlineCount--;
}
}
@EnableWebSocket
@SpringBootApplication
@EnableWebSocket
@MapperScan("com.service.chat.dao")
public class ChatServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ChatServiceApplication.class, args);
}
}
以上为websocket功能, 以下为部分初始化接口及逻辑
只是需要试试websocket功能, 不需要试入库这些的, 到这里即可, 把上面wocketserver里的业务代码删除即可
可以用这个网站进行后端自测
http://coolaf.com/tool/chattest
ws://localhost:8080/message/111
ws://localhost:8080/message/222
功能ok
包含, 聊天记录 和 好友聊天列表
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for chat_list
-- ----------------------------
DROP TABLE IF EXISTS `chat_list`;
CREATE TABLE `chat_list` (
`id` bigint(20) NOT NULL,
`user_id` bigint(20) NOT NULL,
`friend_id` bigint(20) NOT NULL,
`un_read_num` int(11) NOT NULL,
`last_message_id` bigint(20) NULL DEFAULT NULL,
`last_message_content` varchar(5000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL,
`send_time` datetime(0) NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `user_id`(`user_id`, `friend_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for message
-- ----------------------------
DROP TABLE IF EXISTS `message`;
CREATE TABLE `message` (
`id` bigint(20) NOT NULL,
`from_id` bigint(20) NOT NULL,
`to_id` bigint(20) NOT NULL,
`content` varchar(5000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
`type` int(11) NOT NULL,
`time` datetime(0) NOT NULL,
`is_transport` int(11) NOT NULL,
PRIMARY KEY (`id`) USING BTREE,
INDEX `from_id`(`from_id`) USING BTREE,
INDEX `to_id`(`to_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
聊天列表
@Data
@ApiModel(value = "聊天列表", description = "聊天列表")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatList {
@ApiModelProperty(value = "主键id")
@TableId
private Long id;
//用户id
private Long userId;
//好友id
private Long friendId;
//消息未读数量
private int unReadNum;
//最后一条消息id
private Long lastMessageId;
//最后一条消息内容
private String lastMessageContent;
//时间(排序用)
private Date sendTime;
}
聊天记录:
@Data
@ApiModel(value = "消息", description = "消息记录表")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Message {
@ApiModelProperty(value = "主键id")
@TableId
private Long id;
//发送方
private Long fromId;
//接收方
private Long toId;
//内容
private String content;
/**
* 类型字段, 目前定义单聊消息 = 1
* todo 群聊消息, 添加好友消息, 好友请求通过, 好友请求不通过, 上线提醒, 等等等等各种类型, 可以自定义, 和前端联动即可
*/
private int type;
//时间
private Date time;
//是否送达
private int isTransport;
}
持久层采用mybatis plus 实现
@Component
public interface MessageDao extends BaseMapper<Message> {
/**
* 倒序查询两人的所有聊天记录
* todo 后续可以考虑分页
* @param userIdA
* @param userIdB
* @return
*/
@Select("SELECT * FROM message WHERE (from_id = '${userIdA}' AND to_id = '${userIdB}') OR (from_id = '${userIdB}' AND to_id = '${userIdA}') ORDER BY time DESC;")
List<Message> getMessageRecordBetweenUsers(@Param("userIdA") Long userIdA, @Param("userIdB") Long userIdB);
/**
* 查询两人最后一条聊天记录
* @param userIdA
* @param userIdB
* @return
*/
@Select("SELECT * FROM message WHERE (from_id = '${userIdA}' AND to_id = '${userIdB}') OR (from_id = '${userIdB}' AND to_id = '${userIdA}') ORDER BY time DESC LIMIT 0, 1;")
Message getLastMessage(@Param("userIdA") Long userIdA, @Param("userIdB") Long userIdB);
}
@Component
public interface ChatListDao extends BaseMapper<ChatList> {
}
包含部分逻辑:
@Component
public class ChatListService {
@Resource
private ChatListDao chatListDao;
@Resource
private MessageDao messageDao;
/**
* 新增自己的一条聊天列表
* @param currUserId
* @param friendId
*/
public void add(Long currUserId, Long friendId) {
ChatList one = getOne(currUserId, friendId);
if (Objects.isNull(one)){
//没有才新增, 有的话不增
ChatList chatList = ChatList.builder()
.userId(currUserId)
.friendId(friendId)
.unReadNum(0)
.sendTime(new Date())
.build();
//查询之前最后的一条聊天记录, 有的话填充内容
Message lastMessage = messageDao.getLastMessage(currUserId, friendId);
if (Objects.nonNull(lastMessage)){
chatList.setLastMessageId(lastMessage.getId());
chatList.setLastMessageContent(lastMessage.getContent());
chatList.setSendTime(lastMessage.getTime());
}
chatListDao.insert(chatList);
//todo 聊天列表变动 发送消息给前端
// WebSocketServer.sendInfo(currUserId, "xxx");
}
}
/**
* 删除自己的一条聊天列表
* @param currUserId
* @param friendId
*/
public void delete(Long currUserId, Long friendId) {
LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
.eq(ChatList::getUserId, currUserId)
.eq(ChatList::getFriendId,friendId);
chatListDao.delete(queryWrapper);
//todo 聊天列表变动 发送消息给前端
}
/**
* 查询自己的一条聊天列表
* @param userId
* @param friendId
* @return
*/
public ChatList getOne(Long userId, Long friendId) {
LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
.eq(ChatList::getUserId, userId)
.eq(ChatList::getFriendId,friendId);
List<ChatList> chatLists = chatListDao.selectList(queryWrapper);
return CollectionUtils.isEmpty(chatLists) ? null: chatLists.get(0);
}
/**
* 查询自己的所有聊天列表
* @param userId
* @return
*/
public List<ChatList> getChatList(Long userId) {
LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
.eq(ChatList::getUserId, userId)
.orderByDesc(ChatList::getSendTime);
return chatListDao.selectList(queryWrapper);
}
/**
* 消息已读, 未读数清0
* @param userId
* @return
*/
public void read(Long userId, Long friendId) {
LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
.eq(ChatList::getUserId, userId)
.eq(ChatList::getFriendId, friendId);
ChatList chatList = chatListDao.selectOne(queryWrapper);
if (Objects.nonNull(chatList) && chatList.getUnReadNum() != 0){
chatList.setUnReadNum(0);
chatListDao.updateById(chatList);
//todo 聊天列表变动 发送消息给前端
}
}
/**
* 更新聊天列表
* @param message 消息
*/
public void updateChatList(Message message) {
//发送者的聊天列表更新
LambdaQueryWrapper<ChatList> queryWrapper = new QueryWrapper<ChatList>().lambda()
.eq(ChatList::getUserId, message.getFromId())
.eq(ChatList::getFriendId, message.getToId());
ChatList chatList = chatListDao.selectOne(queryWrapper);
if (Objects.isNull(chatList)){
//列表没有, 新增
chatListDao.insert(ChatList.builder()
.userId(message.getFromId())
.friendId(message.getToId())
.lastMessageId(message.getId())
.lastMessageContent(message.getContent())
.sendTime(message.getTime())
//这里, 接收者是未读的, 而发送者是已读的
.unReadNum(0)
.build());
//todo 聊天列表变动 发送消息给前端
}else {
//列表有, 修改
chatList.setLastMessageId(message.getId());
chatList.setLastMessageContent(message.getContent());
chatList.setSendTime(message.getTime());
chatListDao.updateById(chatList);
//todo 聊天列表变动 发送消息给前端
}
//接收者的聊天列表更新
LambdaQueryWrapper<ChatList> queryWrapper2 = new QueryWrapper<ChatList>().lambda()
.eq(ChatList::getUserId, message.getToId())
.eq(ChatList::getFriendId, message.getFromId());
ChatList chatList2 = chatListDao.selectOne(queryWrapper2);
if (Objects.isNull(chatList2)){
//列表没有, 新增
chatListDao.insert(ChatList.builder()
//注意这里发送者和接收者的顺序
.userId(message.getToId())
.friendId(message.getFromId())
.lastMessageId(message.getId())
.lastMessageContent(message.getContent())
.sendTime(message.getTime())
//这里, 接收者是未读的, 而发送者是已读的
.unReadNum(1)
.build());
//todo 聊天列表变动 发送消息给前端 发给好友的, 要填好友的id
}else {
//列表有, 修改
chatList2.setLastMessageId(message.getId());
chatList2.setLastMessageContent(message.getContent());
chatList2.setSendTime(message.getTime());
//未读数量+1
chatList2.setUnReadNum(chatList2.getUnReadNum() + 1);
chatListDao.updateById(chatList2);
//todo 聊天列表变动 发送消息给前端 发给好友的, 要填好友的id
}
}
}
@Component
public class MessageService {
@Resource
private MessageDao messageDao;
@Resource
private ChatListService chatListService;
/**
* 1.查询聊天记录
* 2.未读数量清0
* 3.如果没有聊天列表的话, 要新增一条空的聊天列表
* @param currUserId
* @param friendId
* @return
*/
public List<Message> getMessageRecordBetweenUsers(Long currUserId, Long friendId){
List<Message> messageRecordBetweenUsers = messageDao.getMessageRecordBetweenUsers(currUserId, friendId);
chatListService.read(currUserId,friendId);
chatListService.add(currUserId,friendId);
return messageRecordBetweenUsers;
}
public Integer save(Message message) {
return messageDao.insert(message);
}
}
部分初始化接口:
@Controller
@RequestMapping("/chatList")
public class ChatListController {
@Resource
private ChatListService chatListService;
/**
查询我的聊天列表, 及最后一条消息记录, 及未读消息数量
*/
@GetMapping("/detail/{currUserId}")
public ResponseEntity detail(@PathVariable("currUserId") Long currUserId){
return ResponseEntity.ok().body(chatListService.getChatList(currUserId));
}
/**
未读数量清0,
前端点开好友聊天框时 默认清0, 不需要额外触发,
其他情况清0时 调用;
*/
@GetMapping("/read/{currUserId}/{friendId}")
public ResponseEntity read(@PathVariable("currUserId") Long currUserId,
@PathVariable("friendId") Long friendId){
chatListService.read(currUserId,friendId);
return ResponseEntity.ok().build();
}
/**
* 删除自己的一条聊天列表
* @param currUserId
* @param friendId
* @return
*/
@GetMapping("/delete/{currUserId}/{friendId}")
public ResponseEntity delete(@PathVariable("currUserId") Long currUserId,
@PathVariable("friendId") Long friendId){
chatListService.delete(currUserId,friendId);
return ResponseEntity.ok().build();
}
}
@Controller
@RequestMapping("/messageList")
public class MessageController {
@Resource
private MessageService messageService;
/**
* 点开聊天框时触发, 查询与好友的聊天记录, 并将未读数量清0
* @param currUserId
* @param friendId
* @return
*/
@GetMapping(value="/getMessageRecordBetweenUsers/{currUserId}/{friendId}")
public ResponseEntity getMessageRecordBetweenUsers(@PathVariable("currUserId") Long currUserId,
@PathVariable("friendId") Long friendId){
return ResponseEntity.ok().body(messageService.getMessageRecordBetweenUsers(currUserId, friendId));
}
}