文章目录
先展示一下在线聊天室的效果(需要在两个不同的浏览器中打开)
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 连接建立的步骤:
- 客户端发起握手请求:客户端通过 HTTP 请求发起 WebSocket 握手请求
- 服务器响应握手请求:服务器接收到握手请求后,如果同意升级协议,就会返回一个 HTTP 101 状态码,表示协议切换成功
- 连接建立:握手成功后,客户端和服务器之间的连接切换为 WebSocket 协议,之后双方可以通过此连接进行双向通信
3. 浏览器中与 WebSocket 相关的API
3.1 创建 WebSocket 对象
let webSocket =newWebSocket(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 连接时调用@OnOpenonClose()WebSocket 连接关闭时调用@OnClpseonError()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 种发送消息的方式:
- 通过 session.getBasicRemote() 方法获取同步消息发送的实例,然后调用其 sendXxx() 方法发送消息
- 通过 session.getAsyncRemote() 方法获取异步消息发送实例,然后调用其 sendXxx() 方法发送消息
5.5 编写配置类,扫描有 @ServerEndpoint 注解的 Bean
importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.web.socket.server.standard.ServerEndpointExporter;@ConfigurationpublicclassWebSocketConfig{@BeanpublicServerEndpointExporterserverEndpointExporter(){returnnewServerEndpointExporter();}}
5.6 编写配置类,用于获取 HttpSession 对象
importjakarta.servlet.http.HttpSession;importjakarta.websocket.HandshakeResponse;importjakarta.websocket.server.HandshakeRequest;importjakarta.websocket.server.ServerEndpointConfig;/**
* 获取HttpSession,这样的话,ChatEndpoint类就能操作HttpSession
*/publicclassGetHttpSessionConfigextendsServerEndpointConfig.Configurator{@OverridepublicvoidmodifyHandshake(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 注解中指定配置类
importcn.edu.scau.config.GetHttpSessionConfig;importcn.edu.scau.utils.MessageUtils;importcn.edu.scau.websocket.pojo.Message;importcom.alibaba.fastjson2.JSON;importjakarta.servlet.http.HttpSession;importjakarta.websocket.*;importjakarta.websocket.server.ServerEndpoint;importorg.springframework.stereotype.Component;importjava.io.IOException;importjava.util.Map;importjava.util.Set;importjava.util.concurrent.ConcurrentHashMap;@ServerEndpoint(value ="/chat", configurator =GetHttpSessionConfig.class)@ComponentpublicclassChatEndpoint{// 保存在线的用户,key为用户名,value为 Session 对象privatestaticfinalMap<String,Session> onlineUsers =newConcurrentHashMap<>();privateHttpSession httpSession;/**
* 建立websocket连接后,被调用
*
* @param session Session
*/@OnOpenpublicvoidonOpen(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);}privateSet<String>getFriends(){return onlineUsers.keySet();}privatevoidbroadcastAllUsers(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
*/@OnMessagepublicvoidonMessage(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
*/@OnClosepublicvoidonClose(Session session)throwsIOException{// 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)})exportdefault 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/exportdefaultdefineConfig({plugins:[vue()],resolve:{alias:{'@':fileURLToPath(newURL('./src',import.meta.url))}},server:{proxy:{'/api':{target:'http://localhost:7024',changeOrigin:true,rewrite:(path)=>{return path.replace('/api','')}}}}})
6.2 创建 WebSocket 对象
webSocket.value =newWebSocket('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
版权归原作者 聂 可 以 所有, 如有侵权,请联系我们删除。