使用 WebSocket 实现一个简易的在线聊天室(SpringBoot + Vue3,附源代码)

6 篇文章 0 订阅
1 篇文章 0 订阅

先展示一下在线聊天室的效果(需要在两个不同的浏览器中打开)
在这里插入图片描述

0. 问题引入

在这里插入图片描述

相信大家在很多网站看到过以上效果:当收到一封新的邮件时,未读邮件图标右上角上的总数就会加一

大家有没有想过,服务器是如何主动地将消息实时推送给客户端的呢

1. 常见的消息推送方式

在这里插入图片描述

1.1 轮询

在这里插入图片描述

1.1.1 轮询的概念

客户端以固定的时间间隔(例如每秒或每几分钟)向服务器发送 HTTP 请求,服务器接收到请求后,处理请求并返回数据给客户端

1.1.2 轮询的优点

  • 实现简单:轮询是一种相对简单的获取服务器更新的方法,易于理解和实现
  • 兼容性:由于轮询基于标准的 HTTP 请求和响应,因此它兼容几乎所有的网络服务器和客户端

1.1.3 轮询的缺点

  • 数据更新不及时:客户端必须等待下一次轮询间隔才能接收到新数据,这可能导致数据更新不及时
  • 资源浪费:频繁的轮询可能会浪费服务器和客户端的资源,尤其是在没有新数据的情况下,大部分请求都是无效的

1.2 长轮询

在这里插入图片描述

1.2.1 长轮询的概念

长轮询是一种改进的轮询技术,客户端向服务器发送 HTTP 请求。服务器收到请求后,会阻塞请求,直到有新数据或者达到指定的超时时间才会返回结果

  • 如果有新数据,服务器会立即返回结果并关闭连接
  • 如果没有新数据,服务器会在超时后关闭连接
  • 客户端收到响应或连接超时后,会再次发起新的请求

1.2.2 长轮询的优点

  • 实时性提升:长轮询可以更快地接收到服务器的更新,因为它减少了客户端在两次请求之间的等待时间
  • 减少了无效请求:与定时轮询相比,长轮询减少了在没有数据更新时的无效请求次数,因为服务器仅在数据准备好时才发送响应

1.2.3 长轮询的缺点

  • 资源占用:虽然长轮询减少了请求次数,但它可能会长时间占用服务器资源,因为服务器需要保持连接打开直到有新数据出现或超时
  • 兼容性和复杂性:长轮询的实现比简单的轮询复杂,需要服务器端编写额外的逻辑

1.3 WebSocket

本文的重点,下面会详细介绍

1.4 SSE

SSE(Server-Send Event):服务器发送事件,主要用于服务器向客户端推送实时更新(不需要客户端主动请求

  • SSE 会在服务器和客户端之间打开一个单向通道
  • 服务端返回的不再是一次性的数据包,而是text/event-stream类型的数据流信息
  • 服务器有数据发生变更时会将数据以流的形式传输给客户端

SSE 仅支持从服务器到客户端的单向通信,客户端无法通过 SSE 发送数据到服务器

2. 什么是 WebSocket

2.1 补充:全双工和半双工的概念

全双工(Full Duplex):允许数据在两个方向上同时传输

半双工(Half Duplex):允许数据在两个方向上传输,但是同一个时间段内只允许一个方向传输

2.2 WebSocket 的概念

WebSocket 是一种基于 TCP 的网络通信协议,允许在客户端和服务器建立全双工的通信通道

这意味着客户端和服务器可以在任何时候互相发送消息,不需要像传统的 HTTP 请求那样等待响应

WebSocket 非常适合于需要实时更新数据的应用场景,如在线游戏、实时聊天、实时数据推送

2.3 WebSocket 的原理

WebSocket 协议会在客户端和服务器之间建立一条持久的连接通道,连接建立后,双方可以在任意时间通过这个通道发送数据,每次请求无需重新建立连接

WebSocket 的数据传输是双向的,这意味着服务器可以主动向客户端推送数据,而不仅仅是响应客户端的请求


WebSocket 连接建立的步骤:

  1. 客户端发起握手请求:客户端通过 HTTP 请求发起 WebSocket 握手请求
  2. 服务器响应握手请求:服务器接收到握手请求后,如果同意升级协议,就会返回一个 HTTP 101 状态码,表示协议切换成功
  3. 连接建立:握手成功后,客户端和服务器之间的连接切换为 WebSocket 协议,之后双方可以通过此连接进行双向通信

在这里插入图片描述

3. 浏览器中与 WebSocket 相关的API

3.1 创建 WebSocket 对象

let webSocket = new WebSocket(URL)

URL说明:

  • 格式:协议://ip地址:端口/访问路径
  • 协议:协议名称为ws

3.2 与 WebSocket 对象有关的事件

事件事件处理函数描述
openwebSocket.onopen连接建立时触发
messagewebSocket.onmessage客户端接收到服务器发送的数据时触发
closewebSocket.onclose连接关闭时触发

3.3 WebSocket 对象提供的方法

方法名称描述
send()发生数据给服务端

4. 时序图

在这里插入图片描述

5. 搭建 WebSocket 服务端

后端环境:

  • SpringBoot:3.0.2
  • JDK:17.0.7

服务端占用的端口为7024


以下只是简略的步骤,详细实现步骤请参考源代码

5.1 EndPoint

Tomcat 从 7.0.5 版本开始支持 WebSocket ,并且实现了 Java WebSocket 规范

Java Websocket 应用由一系列的 Endpoint 组成,Endpoint 是一个 java 对象,代表 WebSocket 连接的一端,对于服务器端,我们可以理解为处理具体 WebSocket 消息的接口


我们可以通过两种方式定义 Endpoint :

  • 第一种是编程式,即自定义一个类,继承 Endpoint 类并实现 Endpoint 类中的某些方法
  • 第二种是注解式,即自定义一个类,在这个类上添加 @ServerEndpoint 注解***(推荐使用)***

通常情况下,对于使用了 @ServerEndpoint 注解的类,Spring 会将其作为单例管理,这意味着无论有多少用户连接到这个 WebSocket 端点,Spring 容器中只有一个该类的实例

这种方式有利于资源管理和性能优化,因为不需要为每一个新的连接创建一个新的端点实例

对于大多数情况而言,这种设计是有利的,因为 WebSocket 端点通常需要处理多个用户的连接和消息,并且共享一些状态或数据(例如广播消息给所有连接的客户端)

通过单例模式管理端点类可以确保所有客户端共享同一个实例,这样可以更容易地实现全局广播或其他跨会话的功能


Endpoint 接口中明确定义了与生命周期相关的方法,各个方法如下:

方法描述注解
onOpen()开启一个新的 WebSocket 连接时调用@OnOpen
onClose()WebSocket 连接关闭时调用@OnClpse
onError()WebSocket 连接出现异常时调用@OnError

5.2 准备工作(导入 Maven 依赖)

WebSocket

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

Web

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

fastjson2

<dependency>
    <groupId>com.alibaba.fastjson2</groupId>
    <artifactId>fastjson2</artifactId>
    <version>2.0.50</version>
</dependency>

5.3 服务端如何接收客户端发送的数据

编程式:

  • 通过添加 MessageHandler 消息处理器来接收消息

注解式:

  • 在定义 Endpoint 时,通过@OnMessage注解指定接收消息的方法

本文使用的是注解式

5.4 服务端如何推送数据给客户端

发送消息由 RemoteEndpoint 完成,其实例由 Session(不是 HttpSession ,是 WebSocketSession)维护

有 2 种发送消息的方式:

  1. 通过 session.getBasicRemote() 方法获取同步消息发送的实例,然后调用其 sendXxx() 方法发送消息
  2. 通过 session.getAsyncRemote() 方法获取异步消息发送实例,然后调用其 sendXxx() 方法发送消息

5.5 编写配置类,扫描有 @ServerEndpoint 注解的 Bean

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
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

}

5.6 编写配置类,用于获取 HttpSession 对象

import jakarta.servlet.http.HttpSession;
import jakarta.websocket.HandshakeResponse;
import jakarta.websocket.server.HandshakeRequest;
import jakarta.websocket.server.ServerEndpointConfig;

/**
 * 获取HttpSession,这样的话,ChatEndpoint类就能操作HttpSession
 */
public class GetHttpSessionConfig extends ServerEndpointConfig.Configurator {

    @Override
    public void modifyHandshake(ServerEndpointConfig serverEndpointConfig, HandshakeRequest request, HandshakeResponse response) {
        // 获取 HttpSession 对象
        HttpSession httpSession = (HttpSession) request.getHttpSession();

        // 将 httpSession 对象保存起来,存到 ServerEndpointConfig 对象中
        // 在 ChatEndpoint 类的 onOpen 方法就能通过 EndpointConfig 对象获取在这里存入的数据
        serverEndpointConfig.getUserProperties().put(HttpSession.class.getName(), httpSession);
    }

}

5.7 在 @ServerEndPoint 注解中指定配置类

import cn.edu.scau.config.GetHttpSessionConfig;
import cn.edu.scau.utils.MessageUtils;
import cn.edu.scau.websocket.pojo.Message;
import com.alibaba.fastjson2.JSON;
import jakarta.servlet.http.HttpSession;
import jakarta.websocket.*;
import jakarta.websocket.server.ServerEndpoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

@ServerEndpoint(value = "/chat", configurator = GetHttpSessionConfig.class)
@Component
public class ChatEndpoint {

    // 保存在线的用户,key为用户名,value为 Session 对象
    private static final Map<String, Session> onlineUsers = new ConcurrentHashMap<>();

    private HttpSession httpSession;

    /**
     * 建立websocket连接后,被调用
     *
     * @param session Session
     */
    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        this.httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());

        String user = (String) this.httpSession.getAttribute("currentUser");
        if (user != null) {
            onlineUsers.put(user, session);
        }

        // 通知所有用户,当前用户上线了
        String message = MessageUtils.getMessage(true, null, getFriends());
        broadcastAllUsers(message);
    }


    private Set<String> getFriends() {
        return onlineUsers.keySet();
    }

    private void broadcastAllUsers(String message) {
        try {
            Set<Map.Entry<String, Session>> entries = onlineUsers.entrySet();

            for (Map.Entry<String, Session> entry : entries) {
                // 获取到所有用户对应的 session 对象
                Session session = entry.getValue();

                // 使用 getBasicRemote() 方法发送同步消息
                session.getBasicRemote().sendText(message);
            }
        } catch (Exception exception) {
            exception.printStackTrace();
        }
    }

    /**
     * 浏览器发送消息到服务端时该方法会被调用,也就是私聊
     * 张三  -->  李四
     *
     * @param message String
     */
    @OnMessage
    public void onMessage(String message) {
        try {
            // 将消息推送给指定的用户
            Message msg = JSON.parseObject(message, Message.class);

            // 获取消息接收方的用户名
            String toName = msg.getToName();
            String tempMessage = msg.getMessage();

            // 获取消息接收方用户对象的 session 对象
            Session session = onlineUsers.get(toName);
            String currentUser = (String) this.httpSession.getAttribute("currentUser");
            String messageToSend = MessageUtils.getMessage(false, currentUser, tempMessage);

            session.getBasicRemote().sendText(messageToSend);
        } catch (Exception exception) {
            exception.printStackTrace();
        }
    }

    /**
     * 断开 websocket 连接时被调用
     *
     * @param session Session
     */
    @OnClose
    public void onClose(Session session) throws IOException {
        // 1.从 onlineUsers 中删除当前用户的 session 对象,表示当前用户已下线
        String user = (String) this.httpSession.getAttribute("currentUser");
        if (user != null) {
            Session remove = onlineUsers.remove(user);
            if (remove != null) {
                remove.close();
            }

            session.close();
        }

        // 2.通知其他用户,当前用户已下线
        // 注意:不是发送类似于 xxx 已下线的消息,而是向在线用户重新发送一次当前在线的所有用户
        String message = MessageUtils.getMessage(true, null, getFriends());
        broadcastAllUsers(message);
    }

}

6. 搭建 WebSocket 客户端

前端使用的技术栈:Vue3 + Axios + ElementPlus

以下只是简略的步骤,详细实现步骤请参考源代码

6.1 通过代理解决跨域问题

因为项目采用的是前后端分离的开发模式,所以跨域问题是不可避免的

如果不知道怎么解决跨域问题,可以参考我的另一篇文章:Vue3项目(由Vite构建)中通过代理解决跨域问题

当然,如果你有 nginx 的基础,也可以使用 nginx 代理解决跨域问题

6.1.1 创建一个 axios 实例

向后端发送登录请求需要使用这个 axios 实例

import axios from 'axios'

const request = axios.create({
  baseURL: '/api',
  timeout: 60000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

request.interceptors.request.use(

)

request.interceptors.response.use(response => {
  if (response.data) {
    return response.data
  }
  return response
}, (error) => {
  return Promise.reject(error)
})

export default request

6.1.2 编写代理规则

vite.config.js

import {fileURLToPath, URL} from 'node:url'

import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue()
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  },
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:7024',
        changeOrigin: true,
        rewrite: (path) => {
          return path.replace('/api', '')
        }
      }
    }
  }
})

6.2 创建 WebSocket 对象

webSocket.value = new WebSocket('ws://localhost:7024/chat')

6.3 为 WebSocket 对象绑定事件

webSocket.value.onopen = onOpen

// 接收到服务端推送的消息后触发
webSocket.value.onmessage = onMessage

webSocket.value.onclose = onClose

7. 消息格式

7.1 客户端 -> 服务端

{
  "toName": "张三",
  "message": "你好"
}

7.2 服务端 -> 客户端

系统消息格式:

{
  "system": true,
  "fromName": null,
  "message": ["李四", "王五"]
}

推送给某一个用户的消息格式:

{
  "system": false,
  "fromName": "张三",
  "message": "你好"
}

8. 完整的源代码

备注:

  • 好友列表中显示的是当前在线的所有用户
  • 自定义用户名,登录密码为123456

WebSocket 服务端:在线聊天室-服务端

WebSocket 客户端:在线聊天室-客户端


视频教程:在线聊天室

项目的实际意义不大,但是可以让小白初步了解 WebSocket

实现基于 WebSocket聊天室单聊和群聊,可以分为以下几个步骤: 1. 创建 SpringBoot 项目:使用 SpringBoot 创建一个后端项目,添加 WebSocket 依赖。 2. 配置 WebSocket:在 SpringBoot 项目中添加配置类,开启 WebSocket 支持,并注册 WebSocket 处理器。 3. 创建前端项目:使用 Vue 创建一个前端项目,安装 WebSocket 库。 4. 实现单聊和群聊功能:前端和后端通过 WebSocket 进行通信,前端发送消息到后端,后端将收到的消息进行处理,然后再将消息发送给前端。 下面是一个简单的示例代码: 后端代码: 1. 添加 WebSocket 依赖 ``` <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> ``` 2. WebSocket 配置类 ``` @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new WebSocketHandler(), "/ws").setAllowedOrigins("*"); } } ``` 3. WebSocket 处理器 ``` @Component public class WebSocketHandler extends TextWebSocketHandler { private static final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { sessions.put(session.getId(), session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { sessions.remove(session.getId()); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 处理收到的消息 String payload = message.getPayload(); JSONObject jsonObject = JSONObject.parseObject(payload); String type = jsonObject.getString("type"); String content = jsonObject.getString("content"); String from = jsonObject.getString("from"); String to = jsonObject.getString("to"); if("chat".equals(type)) { // 单聊 WebSocketSession toSession = sessions.get(to); if(toSession != null && toSession.isOpen()) { TextMessage textMessage = new TextMessage(content); toSession.sendMessage(textMessage); } } else if("group".equals(type)) { // 群聊 for(WebSocketSession session1 : sessions.values()) { if(session1.isOpen()) { TextMessage textMessage = new TextMessage(content); session1.sendMessage(textMessage); } } } } } ``` 前端代码: 1. 安装 WebSocket 库 ``` npm install --save sockjs-client npm install --save stompjs ``` 2. 连接 WebSocket ``` import SockJS from 'sockjs-client' import Stomp from 'stompjs' let stompClient = null; function connect() { const socket = new SockJS('/ws'); stompClient = Stomp.over(socket); stompClient.connect({}, function (frame) { console.log('Connected: ' + frame); stompClient.subscribe('/topic/chat', function (message) { // 接收到消息 console.log(message); }); }); } connect(); ``` 3. 发送消息 ``` function sendChatMessage() { const message = { type: 'chat', content: 'hello', from: 'user1', to: 'user2' }; stompClient.send('/app/chat', {}, JSON.stringify(message)); } ``` 以上代码仅为示例,具体实现还需要根据具体需求进行修改。
评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

聂 可 以

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值