SpringBoot+WebSocket实现即时通讯(一)

前言

本博客姊妹篇

传统方式

背景:即时通讯过程中,解决传统网站使用HTTP轮询方式请求获取最新的数据(如每3秒请求一次)。

缺点

  • Web客户端反复发出请求消耗服务器资源
  • 请求包含较长的头部,浪费很多的带宽资源
  • 只能由Web客户端发送请求到服务端获取数据
  • 实时性不高

WebSocket

WebSocket:WebSocket是一种在单个TCP连接上进行全双工通信的协议。

优势

  • 一个Web客户端和服务端只建立一个TCP连接
  • 请求包含轻量级的头部,减少了数据传输量
  • 服务端可以主动推送数据到Web客户端
  • 实时性高

一、功能描述

  • 即时通讯:发送、接收消息
  • 用户管理:业务自己实现,暂从数据库添加
  • 好友管理:添加好友、删除好友、修改备注、好友列表等
  • 群组管理:新建群、解散群、编辑群、变更群主、拉人进群、踢出群等
  • 聊天模式:私聊、群聊
  • 消息类型:系统、文本、语音、图片、视频
  • 聊天管理:删除聊天、置顶聊天、查看聊天记录等

二、WebSocket服务

2.1 引入依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2.2 配置WebSocket扫描

java 复制代码
package com.qiangesoft.im.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * WebSocket配置类
 */
@Configuration
public class WebSocketConfig {

    /**
     * bean注册:会自动扫描带有@ServerEndpoint注解声明的Websocket Endpoint(端点),注册成为Websocket bean。
     * 注意:如果项目使用外置的servlet容器,而不是直接使用springboot内置容器的话,就不要注入ServerEndpointExporter,因为它将由容器自己提供和管理。
     */
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

2.3 WebSocket服务类

java 复制代码
package com.qiangesoft.im.core;

import com.alibaba.fastjson2.JSONObject;
import com.qiangesoft.im.core.constant.ChatTypeEnum;
import com.qiangesoft.im.core.constant.ImBodyEnum;
import com.qiangesoft.im.pojo.bo.ImMessageBO;
import com.qiangesoft.im.pojo.dto.PingDTO;
import com.qiangesoft.im.pojo.vo.ImMessageVO;
import com.qiangesoft.im.pojo.vo.PongVO;
import com.qiangesoft.im.pojo.vo.SysUserVo;
import com.qiangesoft.im.service.IImGroupUserService;
import com.qiangesoft.im.util.SpringUtil;
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.io.IOException;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;

/**
 * 聊天会话
 *
 * @author qiangesoft
 * @date 2023-08-30
 */
@Slf4j
@ServerEndpoint("/ws/im/{userId}")
@Component
public class ImWebSocketServer {

    /**
     * concurrent包的线程安全Set,用来存放每个客户端对应的session
     */
    private static final ConcurrentHashMap<Long, Session> WEBSOCKET_MAP = new ConcurrentHashMap<>();

    /**
     * 连接成功:用map存客户端对应的session
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("userId") Long userId) {
        log.info("User [{}] connection opened=====>", userId);

        // 关闭之前的
        if (WEBSOCKET_MAP.containsKey(userId)) {
            Session oldSession = WEBSOCKET_MAP.get(userId);
            close(oldSession, userId);
        }

        // 存储session
        WEBSOCKET_MAP.put(userId, session);

        // 在线人数
        log.info("User connection add 1, online num is [{}]", WEBSOCKET_MAP.size());

        // 响应
        PongVO pongVO = new PongVO();
        pongVO.setType(ImBodyEnum.PONG.getCode());
        pongVO.setContent("连接成功");
        pongVO.setTimestamp(System.currentTimeMillis());
        doSendMessage(session, pongVO);
    }

    /**
     * 收到客户端消息
     */
    @OnMessage
    public void onMessage(Session session, @PathParam("userId") Long userId, String message) {
        log.info("User [{}] send a message, content is [{}]", userId, message);

        PingDTO pingDTO = null;
        try {
            pingDTO = JSONObject.parseObject(message, PingDTO.class);
        } catch (Exception e) {
            log.error("消息解析失败");
            e.printStackTrace();
        }
        if (pingDTO == null || !ImBodyEnum.PING.getCode().equals(pingDTO.getType())) {
            sendInValidMessage(session);
            return;
        }

        // 响应
        PongVO pongVO = new PongVO();
        pongVO.setType(ImBodyEnum.PONG.getCode());
        pongVO.setContent("已收到消息~");
        pongVO.setTimestamp(System.currentTimeMillis());
        doSendMessage(session, pongVO);
    }

    /**
     * 连接关闭调用的方法
     */
    @OnClose
    public void onClose(Session session, @PathParam("userId") Long userId) {
        close(session, userId);

        // 在线人数减1
        if (!WEBSOCKET_MAP.containsKey(userId)) {
            log.info("User connection reduce 1, online num is [{}]", WEBSOCKET_MAP.size());
        }

        log.info("User [{}] connection is closed<=====", userId);
    }

    /**
     * 报错
     *
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, @PathParam("userId") Long userId, Throwable error) {
        log.info("User [{}] connection is error!", userId);
        error.printStackTrace();
    }

    /**
     * 指定的userId服务端向客户端发送消息
     */
    public static void sendMessage(ImMessageBO message) {
        String chatType = message.getChatType();
        if (ChatTypeEnum.GROUP.getCode().equals(chatType)) {
            sendGroupMessage(message);
        }

        if (ChatTypeEnum.PERSON.getCode().equals(chatType)) {
            sendPersonMessage(message);
        }
    }

    /**
     * 被挤下线
     */
    public static void offline(Long userId) {
        Session session = WEBSOCKET_MAP.get(userId);
        if (session != null) {
            // 设备下线
            PongVO pongVO = new PongVO();
            pongVO.setType(ImBodyEnum.OFFLINE.getCode());
            pongVO.setContent("设备被挤下线");
            pongVO.setTimestamp(System.currentTimeMillis());
            doSendMessage(session, pongVO);

            // 关闭
            close(session, userId);
        }
    }

    /**
     * 自定义关闭
     *
     * @param session
     * @param userId
     */
    public static void close(Session session, Long userId) {
        try {
            session.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        WEBSOCKET_MAP.remove(userId);
    }

    /**
     * 发送无效消息
     */
    private static void sendInValidMessage(Session session) {
        PongVO pongVO = new PongVO();
        pongVO.setType(ImBodyEnum.PONG.getCode());
        pongVO.setContent("无效消息");
        pongVO.setTimestamp(System.currentTimeMillis());
        doSendMessage(session, pongVO);
    }

    /**
     * 发送群组消息
     *
     * @param message
     */
    private static void sendGroupMessage(ImMessageBO message) {
        MessageHandlerService messageHandlerService = SpringUtil.getBean(MessageHandlerService.class);
        ImMessageVO messageVO = messageHandlerService.buildVo(message);

        PongVO pongVO = new PongVO();
        pongVO.setType(ImBodyEnum.MESSAGE.getCode());
        pongVO.setContent(messageVO);
        pongVO.setTimestamp(System.currentTimeMillis());

        // 发送给群成员
        IImGroupUserService groupUserService = SpringUtil.getBean(IImGroupUserService.class);
        List<Long> userIdList = groupUserService.listGroupUser(message.getTargetId()).stream().map(SysUserVo::getId).collect(Collectors.toList());
        for (Long userId : userIdList) {
            Session session = WEBSOCKET_MAP.get(userId);
            doSendMessage(session, pongVO);
        }
    }

    /**
     * 发送私聊消息
     *
     * @param message
     */
    private static void sendPersonMessage(ImMessageBO message) {
        MessageHandlerService messageHandlerService = SpringUtil.getBean(MessageHandlerService.class);
        ImMessageVO messageVO = messageHandlerService.buildVo(message);

        PongVO pongVO = new PongVO();
        pongVO.setType(ImBodyEnum.MESSAGE.getCode());
        pongVO.setContent(messageVO);
        pongVO.setTimestamp(System.currentTimeMillis());

        // 发送给好友
        Session session = WEBSOCKET_MAP.get(message.getTargetId());
        doSendMessage(session, pongVO);
    }

    /**
     * 发送消息
     *
     * @param session
     * @param message
     */
    private static void doSendMessage(Session session, PongVO message) {
        try {
            if (session != null) {
                session.getBasicRemote().sendText(JSONObject.toJSONString(message));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

三、源码地址

源码地址:https://gitee.com/qiangesoft/boot-business/tree/master/boot-business-im

后续内容见下章

相关推荐
神云瑟瑟16 小时前
spring boot拦截器获取requestBody的最佳实践
spring boot·拦截器·requestbody
暮色妖娆丶17 小时前
Spring 源码分析 BeanFactoryPostProcessor
spring boot·spring·源码
南极企鹅18 小时前
springBoot项目有几个端口
java·spring boot·后端
忧郁的Mr.Li18 小时前
SpringBoot中实现多数据源配置
java·spring boot·后端
闲人编程19 小时前
使用FastAPI和WebSocket构建高性能实时聊天系统
websocket·网络协议·网络编程·fastapi·持久化·实时聊天·codecapsule
暮色妖娆丶19 小时前
SpringBoot 启动流程源码分析 ~ 它其实不复杂
spring boot·后端·spring
Coder_Boy_19 小时前
Deeplearning4j+ Spring Boot 电商用户复购预测案例中相关概念
java·人工智能·spring boot·后端·spring
爱学英语的程序员20 小时前
面试官:你了解过哪些数据库?
java·数据库·spring boot·sql·mysql·mybatis
Java新手村20 小时前
基于 Vue 3 + Spring Boot 3 的 AI 面试辅助系统:实时语音识别 + 大模型智能回答
vue.js·人工智能·spring boot
消失的旧时光-194320 小时前
第十四课 · 实战篇:Redis 缓存系统落地指南(Spring Boot 从 0 到可用)
spring boot·redis·缓存