个人技术博客——Spring Boot整合WebSocket实现在线聊天

162204204郭丰华 2024-12-20 21:11:31
这个作业属于哪个课程FZU_SE_teacherW_4社区-CSDN社区云
这个作业要求在哪里软件工程实践总结&个人技术博客-CSDN社区
这个作业的目标总结课程收获和经验
其他参考文献《构建之法》

目录

  • 技术概述
  • 技术详述
  • 定义客户端与服务端通信的消息格式
  • 引入Spring Boot Starter包
  • 配置WebSocket服务端点
  • 鉴权、存储会话
  • 处理客户端发来的消息
  • 向客户端发送消息
  • 增强功能
  • 实现离线未读消息提醒
  • 异步实现消息的发送
  • 实现群聊功能
  • 遇到的问题和解决过程
  • WebSocket调用自动装配对象产生NPE异常
  • 前端WebSocket连接异常关闭(1002)
  • 总结
  • 参考资料

技术概述

Spring Boot:流行的Java后端开发框架,被广泛使用。

WebSocket:全双工的通信协议,使得服务器和客户端之间可以保持长连接,省去了多次通信的性能开销,可用于开发聊天室、即时通知等。

Spring Boot整合WebSocket可以为应用提供相关功能的支持。

技术详述

定义客户端与服务端通信的消息格式

Springboot整合WebSocket时,需要事先定义双方的信息格式,以此来决定客户端和服务端如何处理对方发来的信息。TimeSphere使用WebSocket作为即时聊天的服务器,因此使用以下格式的信息进行定义:

{
    "fromId":"sddl", //谁发的
    "toId":"onds", //发给谁
    "content":"你好!" //聊天内容
}

群聊信息:

{
    "chatId" : "1859181337462353922", //群聊id
    "fromId":"sddl", //谁发的
    "content":"你好!",
    "type":"group" //加上这个代表这是一条群聊消息
}

双方传输的信息中不应该包含其他违背定义的信息,缺少对应的信息也是不允许的,否则后端会抛出异常,也会导致前端的WebSocket连接异常关闭。

引入Spring Boot Starter包

和其他技术相似,Spring官方已经提供了spring-boot-starter-websocket包来简化整合流程,我们引入该包即可。

Maven:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

引入包时,将会读取spring-boot-starter-parent的版本号。如果你的工程未进行相关配置请手动指定版本号。

配置WebSocket服务端点

WebSocket有以下几个事件:

  • OnOpen:在WebSocket连接建立时触发
  • OnMessage:在收到对方发来的消息时触发
  • OnClose:在WebSocket连接关闭时触发
  • OnError:在发生异常时触发

我们可以定义这些事件的处理函数。一个WebSocket服务器的代码示例如下:

@Controller //交由Spring IOP容器管理生命周期
@ServerEndpoint("/") //使用Jakarta包中的WebSocket,注解该类为一个WebSocket端点
public class WebSocketServer {
    @OnOpen
    public void onOpen(Session session, EndpointConfig endpointConfig) throws IOException {
        
    }

    @OnClose
    public void onClose(Session session, CloseReason closeReason) throws IOException {
       
    }

    @OnError
    public void onError(Throwable throwable) {
        throwable.printStackTrace();
    }

    @OnMessage
    public void onMessage(String message, Session session) throws IOException {
       
    }
}

其中OnOpen事件当中应该对用户进行鉴权,并将用户的Session保存。OnClose事件中移除用户的Session,表明用户已经离线。OnMessage中编写业务相关逻辑。

鉴权、存储会话

应当用一个Map来存储用户的Session。使用ConcurrentHashMap防止多线程下出现问题。

鉴权可以使用token进行,如果将token放入请求头当中操作会有些复杂,因此将token放入url当中。

鉴权通过则将用户的Session存入Map当中,否则关闭当前连接。

对于WebSocket服务,不能够使用@Resource或@Autowired自动装配Spring Bean,否则调用时将会产生NullPointerException错误。

需要实现ApplicationContextAware接口并通过ApplicationContext访问其他Bean的实例。相关Bean的获取也应当在OnOpen函数当中进行。

public static final ConcurrentHashMap<String, Session> SESSION_MAP = new ConcurrentHashMap<>();

private static ApplicationContext applicationContext;

private MessageService messageService;

private UserMapper userMapper;

private GroupService groupService;

@Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        WebSocketServer.applicationContext = applicationContext;
    }

@OnOpen
    public void onOpen(Session session, EndpointConfig endpointConfig, @PathParam("satoken") String satoken) throws IOException {
    //验证token
    String userId = (String) StpUtil.getLoginIdByToken(satoken);
    if (userId == null) {
        session.getBasicRemote().sendText("{code:-1,message:\"Invalid Token\"}");
        session.close();
    }
    //装配相关bean
    this.messageService = WebSocketServer.applicationContext.getBean(MessageService.class);
    this.userMapper = WebSocketServer.applicationContext.getBean(UserMapper.class);
    this.groupService = WebSocketServer.applicationContext.getBean(GroupService.class);
    String username = userMapper.selectById(userId).getUsername();
    //将用户session存入map当中
    SESSION_MAP.put(username, session);
}

处理客户端发来的消息

相关操作在OnMessage函数当中运行。

由于我们使用的是Jakarta原生的WebSocket实现,因此所接收的数据不会像传统Spring Controller那样会被自动转换成对应的JavaBean,因此在收到客户端发来的文本信息时首先需要调用方法将json文本数据转换为对应的对象。

随后进行其他的操作。

向客户端发送消息

首先取出用户的session,调用getAsyncRemote(你也可以调用getBasicRemote,但是该方法在并发的情况下性能较差)中的sendtext方法即可向对应的用户发送消息。

聊天功能应该先取出用户想要发送信息的用户id,然后取出对应用户的session并将信息转发给该用户。

最终完整的代码示例如下。

@OnMessage
public void onMessage(String message, Session session) throws IOException {
    String type = "group";
    String heartbeat = "heartbeat";
    //将接受的信息转化为对应的对象
    MessageVO messageVO = JSON.parseObject(message, MessageVO.class);
    messageVO.setDate(DateUtil.getCurrentTime());
    //向对应的用户转发消息
    if (messageVO.getType() != null && type.equals(messageVO.getType())) {
        messageVO.setToId(groupService.getNameById(messageVO.getChatId()));
        List<TeamRecord> memberList = groupService.getGroupMembers(messageVO.getChatId());
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username", messageVO.getFromId());
        User sender = userMapper.selectOne(queryWrapper);
        for (var item : memberList) {
            User user = userMapper.selectById(item.getUserId());
            if (!item.getUserId().equals(sender.getId())) {
                if (SESSION_MAP.containsKey(item.getUserId())) {
                    SESSION_MAP.get(item.getUserId()).getAsyncRemote().sendText(JSON.toJSONString(Message.parseMessage(messageVO)));
                } else {
                    messageService.insertUnreadMessage(user.getUsername(), Message.parseMessage(messageVO));
                }
            }
        }
        messageService.insertMessage(Message.parseMessage(messageVO));
    } else if (messageVO.getType() != null && messageVO.getType().equals(heartbeat)) {
        session.getAsyncRemote().sendText("PONG");
    } else {
        if (SESSION_MAP.containsKey(messageVO.getToId())) {
            SESSION_MAP.get(messageVO.getToId()).getAsyncRemote().sendText(JSON.toJSONString(Message.parseMessage(messageVO)));
        } else {
            messageService.insertUnreadMessage(messageVO.getFromId(), Message.parseMessage(messageVO));
        }
        messageService.insertMessage(Message.parseMessage(messageVO));
    }

}

增强功能

实现离线未读消息提醒

转发时,首先判断服务器是否持有对应用户的Session,若无,则表明当前用户不在线,转而将消息存入Redis当中,待用户上线时在OnOpen事件当中取出未读消息。

异步实现消息的发送

在OnMessage发送消息会导致当前线程被阻塞,导致服务的并发能力下降。使用RabbitMQ等消息队列将消息的转发转换为异步方式可以提高并发量,用户不再等待消息发送完成即可完成本次操作。缺点是引入新的中间价会造成系统的可靠性下降,以及异步的方式可能会造成信息的丢失,需要做额外措施来进行保障。

实现群聊功能

事先定义群聊和单聊的消息格式,以在消息转发的时候做区分。群聊应该有相关实体类记录群聊内的用户id,可以放在缓存当中减少对于数据库的读取。发送消息时发送给群聊的所有成员。

遇到的问题和解决过程

WebSocket调用自动装配对象产生NPE异常

由于Spring只会给一个对象注入一次,而当客户端与WebSocket服务端连接过后,又会产生一个新对象,因此此时的相关自动装配对象将不会进行自动装配,因此为NULL,产生nullpointerexception错误。

解决方法是在OnOpen事件的处理函数当中手动获取相关bean,实现spring的ApplicationContextAware接口,并通过ApplicationContext进行获取,避免相关对象调用的时候并没有设置值。此时调用编写的service等就不会产生NPE错误。

前端WebSocket连接异常关闭(1002)

websocket链接不停的异常关闭、重新建立,虽然不会显著影响功能但是会影响到性能和用户的体验。

经排查发现websocket关闭状态码1002,且后端在此处抛出NullPointerException错误:

messageVO.setDate(DateUtil.getCurrentTime());

状态码1002表示协议错误。当WebSocket协议在通信过程中遇到无法解析的数据帧时,会返回状态码1002,表示协议错误。这种情况通常发生在数据帧的解析过程中发现了无效的数据或不符合协议规范的数据。最常见的情况是编解码问题,可能对于接受的消息没有正确解码,但是我们经过排查后发现是前端在对接websocket时增加了心跳消息的发送,而后端没有做对应的处理,导致后端处理出错,不能够处理而导致连接的关闭。我们遇到的这个问题属于沟通上的问题,没有事先沟通就增加了新的机制。

建议出现此问题时前端和后端一起排查,重新确认消息格式,并检查相关编码格式。必要时可以采用最原始的println来查看收到的是什么信息。

总结

WebSocket是为长连接而生的,非常适合运用到在线聊天这种场景。在WebSocket出现以前曾经出现过轮询等方式来替代实现聊天功能,但是这样的实现方式会产生一些不必要的性能开销。使用WebSocket不需要频繁建立关闭连接,减轻了服务器的压力。

将WebSocket整合到Spring Boot当中能够使用WebSocket实现相关业务逻辑,且Spring Boot提供了Starter来简化配置过程,使得配置更容易。借助于Spring Boot和WebSocket技术可以很容易的实现一个聊天系统。

参考资料

WebSockets :: Spring Boot

WebSockets :: Spring Framework

深入了解WebSocket协议状态码1002_websocket 1002-CSDN博客

springboot整合WebSocket遇到的问题(注入service为null)_websocket注入service为空-CSDN博客

...全文
104 回复 打赏 收藏 转发到动态 举报
AI 作业
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

239

社区成员

发帖
与我相关
我的任务
社区管理员
  • FZU_SE_teacherW
  • 助教赖晋松
  • D's Honey
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧