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

后续内容见下章

相关推荐
KK溜了溜了2 小时前
JAVA-springboot log日志
java·spring boot·logback
我命由我123453 小时前
Spring Boot 项目集成 Redis 问题:RedisTemplate 多余空格问题
java·开发语言·spring boot·redis·后端·java-ee·intellij-idea
面朝大海,春不暖,花不开3 小时前
Spring Boot消息系统开发指南
java·spring boot·后端
hshpy3 小时前
setting up Activiti BPMN Workflow Engine with Spring Boot
数据库·spring boot·后端
jay神3 小时前
基于Springboot的宠物领养系统
java·spring boot·后端·宠物·软件设计与开发
不知几秋4 小时前
Spring Boot
java·前端·spring boot
howard20055 小时前
5.4.2 Spring Boot整合Redis
spring boot·整合redis
TracyCoder1235 小时前
接口限频算法:漏桶算法、令牌桶算法、滑动窗口算法
spring boot·spring·限流
饮长安千年月5 小时前
JavaSec-SpringBoot框架
java·spring boot·后端·计算机网络·安全·web安全·网络安全
考虑考虑6 小时前
Jpa中的@ManyToMany实现增删
spring boot·后端·spring