主题
springboot中websocket的使用
1. websocket是什么
WebSocket是一种网络传输协议,可在单个TCP连接上进行全双工通信,位于OSI模型的应用层。WebSocket协议在2011年由IETF标准化为RFC 6455,后由RFC 7936补充规范。Web IDL中的WebSocket API由W3C标准化。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就可以建立持久性的连接,并进行双向数据传输。
websocket和socket一样吗
socket是什么,"Socket" 在计算机网络中是指一种端点,允许两个程序通过网络进行通信。具体来说,socket 是网络通信的基础,它可以在同一台计算机上运行的两个程序之间,也可以在不同计算机上运行的程序之间传输数据。
而websocket是用来让服务器和浏览器进行全双工通信的一个协议,他是通过http升级上来的协议。
所以就如同java 、 JavaScript一样,完全不是一回事。
2. 协议结构解析
WebSocket 是独立的、建立在TCP上的协议。 Websocket 通过 HTTP/1.1 协议的101状态码进行握手。
为了建立Websocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(Handshaking)。
1建立连接
一个典型的Websocket握手请求如下
客户端请求
shell
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
服务端回应
shell
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
字段说明
- Connection必须设置Upgrade,表示客户端希望连接升级。
- Upgrade字段必须设置Websocket,表示希望升级到Websocket协议。
- Sec-WebSocket-Key是随机的字符串,服务器端会用这些数据来构造出一个SHA-1的信息摘要。把“Sec-WebSocket-Key”加上一个特殊字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后计算SHA-1摘要,之后进行Base64编码,将结果做为“Sec-WebSocket-Accept”头的值,返回给客户端。如此操作,可以尽量避免普通HTTP请求被误认为Websocket协议。
- Sec-WebSocket-Version 表示支持的Websocket版本。RFC6455要求使用的版本是13,之前草案的版本均应当弃用。
- Origin字段是必须的。如果缺少origin字段,WebSocket服务器需要回复HTTP 403 状态码(禁止访问)。
- 其他一些定义在HTTP协议中的字段,如Cookie等,也可以在Websocket中使用。
2 数据帧
在握手成功后,客户端和服务器可以开始交换数据,这些数据被封装在 WebSocket 帧中。每个 WebSocket 帧由以下几个部分组成:
帧头 (Frame Header):
- FIN (1 bit): 标志是否是消息的最后一个帧。
- RSV1, RSV2, RSV3 (各 1 bit): 保留位,通常为 0,除非协商了扩展。
- Opcode (4 bits): 指示帧的类型(例如文本帧、二进制帧、关闭连接、Ping、Pong)。
- Mask (1 bit): 表示是否掩码(客户端发送的消息需要掩码)。
- Payload length (7、7+16、7+64 bits): 负载数据的长度。
- Masking Key (32 bits): 如果 Mask 位为 1,则包含一个掩码键,用于掩码数据。
有效载荷 (Payload Data):
- 实际要传输的数据,可能是文本数据、二进制数据、控制帧等。
数据帧结构示例:
- 文本帧 (Text Frame): 用于传输文本数据。
mathematica
FIN | RSV1 | RSV2 | RSV3 | Opcode | Mask | Payload length | Masking Key | Payload Data
- 二进制帧 (Binary Frame): 用于传输二进制数据。
mathematica
FIN | RSV1 | RSV2 | RSV3 | Opcode | Mask | Payload length | Masking Key | Payload Data
- 控制帧 (Control Frames):
- 关闭连接 (Close Frame):用于关闭连接。
- Ping/Pong 帧:用于连接的心跳检测。
mathematica
FIN | RSV1 | RSV2 | RSV3 | Opcode | Mask | Payload length | Masking Key | Payload Data
3. 消息分片 (Message Fragmentation)
- WebSocket 支持将一条消息分片成多个帧,以适应大型消息传输。
- 第一帧包含
FIN
位设置为0
和适当的Opcode
。 - 中间帧的
Opcode
设置为0x0
(继续帧)。 - 最后一帧的
FIN
位设置为1
。
4. 掩码 (Masking)
- 由于 WebSocket 设计初衷,所有从客户端到服务器的消息必须使用掩码(即
Mask
位设置为1
)。 - 掩码键用于掩码和解码消息数据。
5. 控制帧 (Control Frames)
- 关闭帧 (Close Frame):用于优雅地关闭 WebSocket 连接。
- Ping 帧:服务器或客户端发送 Ping 帧作为心跳检测。
- Pong 帧:响应 Ping 帧,确认连接活跃。
6. 连接关闭 (Connection Close)
- 任何一方可以通过发送关闭帧来关闭 WebSocket 连接。
- 关闭帧可以包含关闭状态码和关闭原因。
示例:
js
socket.onclose = (event) => {
console.log('WebSocket connection closed:', event);
if (event.code === 1000) {
console.log('Normal closure');
} else {
console.error('Abnormal closure, code:', event.code);
}
};
总结
通过这些协议和机制,WebSocket 提供了一种高效的双向通信方式,适用于需要频繁和低延迟数据交换的应用场景。理解这些协议和数据交换机制有助于你更好地设计和实现 WebSocket 应用。
3. springboot下服务端实现
依赖引入
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>3.3.0</version>
</dependency>
配置config
java
package com.zhaoyubin.learnspringboot.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
import org.springframework.web.filter.CharacterEncodingFilter;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private HttpAuthHandler2 httpAuthHandler;
@Autowired
private WebSocketIntercepter webSocketIntercepter;
/**
* 配置字符编码
* @return
*/
@Bean
public CharacterEncodingFilter characterEncodingFilter() {
CharacterEncodingFilter filter = new CharacterEncodingFilter();
filter.setEncoding("UTF-8");
filter.setForceEncoding(true);
return filter;
}
/**
* 这个配置是通过controller的类添加Endpoint注解实现的,这个是标准的使用websocket的方式, 两种方式可以同时存在,
* @return
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
/**
* 注册 WebSocket 端点并指定其路径和允许的跨域请求。
* @param registry
*/
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(httpAuthHandler,"/socket/*")
.addInterceptors(webSocketIntercepter) //添加拦截器以处理握手请求和握手响应。
.setAllowedOrigins("*");
}
}
实现HandshakeInterceptor
HandshakeInterceptor实现对http握手的处理,包括握手前和握手后,这里可以解析自定义header。
java
package com.zhaoyubin.learnspringboot.config;
import org.apache.logging.log4j.util.Strings;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
@Component
public class WebSocketIntercepter implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
System.out.println("握手开始");
String hostName = request.getRemoteAddress().getHostName();
String sessionId = hostName+String.valueOf((int)(Math.random()*1000));
if (Strings.isNotBlank(sessionId)) {
// 放入属性域
attributes.put("session_id", sessionId);
System.out.println("用户 session_id " + sessionId + " 握手成功!");
return true;
}
System.out.println("用户登录已失效");
return false;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
System.out.println("握手完成");
}
}
实现TextWebSocketHandler,消息处理器
通过springboot的方式配置的,这个处理器在websocketconfig文件中配置生效。区别于传统的通过@ServerEndpoint注解的使用,但是这两种可以同时配置。
java
package com.zhaoyubin.learnspringboot.config;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.time.LocalDateTime;
@Component
public class HttpAuthHandler extends TextWebSocketHandler {
/**
* socket 建立成功事件
*
* @param session
* @throws Exception
*/ @Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
Object sessionId = session.getAttributes().get("session_id");
if (sessionId != null) {
// 用户连接成功,放入在线用户缓存
WsSessionManager.add(sessionId.toString(), session);
} else {
throw new RuntimeException("用户登录已经失效!");
}
}
/**
* 接收消息事件
*
* @param session
* @param message
* @throws Exception
*/ @Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 获得客户端传来的消息
String payload = message.getPayload();
Object sessionId = session.getAttributes().get("session_id");
System.out.println("server 接收到 " + sessionId + " 发送的 " + payload);
session.sendMessage(new TextMessage("server 发送给 " + sessionId + " 消息 " + payload + " " + LocalDateTime.now().toString()));
}
/**
* socket 断开连接时
*
* @param session
* @param status
* @throws Exception
*/ @Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
Object sessionId = session.getAttributes().get("session_id");
if (sessionId != null) {
// 用户退出,移除缓存
WsSessionManager.remove(sessionId.toString());
}
}
}
传统的通过@ServerEndpoint的方式
通过ServerEndpoint注解的方式就和一个普通的Controller的使用方法一样。指定一个路径("/socket/test/{params}") ,前端通过js调用就可以访问了。
java
package com.zhaoyubin.learnspringboot.controller;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.PathParam;
import jakarta.websocket.server.ServerEndpoint;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
@ServerEndpoint("/socket/test/{params}")
public class SocketController {
//把session保存起来,方便针对不同的客户端发送消息
private static ConcurrentHashMap<String, ArrayList<Session>> map = new ConcurrentHashMap();
private static Session session;
@OnOpen
public void onOpen(Session session, @PathParam("params") String params) {
System.out.println("params = " + params);
System.out.println("open");
System.out.println(session.getId());
System.out.println(session.toString());
this.session = session;
ArrayList<Session> sessions = map.get(session.getId());
if (sessions == null) {
sessions = new ArrayList<>();
}
sessions.add(session);
map.put(params, sessions);
}
@OnClose
public void onClose(Session session) {
System.out.println("close");
System.out.println(session.toString());
}
@OnMessage
public void onMessage(String message) {
System.out.println(message);
}
/**
* 给所有的客户端发送消息
* @param message
*/
public static void sendMessage(String message) {
session.getAsyncRemote().sendText(message);
}
/**
* 给指定的客户端发送消息
* @param key
* @param message
*/
public static void sendMessage(String key,String message) {
ArrayList<Session> sessions = map.get(key);
if (sessions != null) {
for (Session session : sessions) {
session.getAsyncRemote().sendText(message);
}
}
}
}
js客户端调用
WebSocket
对象提供了用于创建和管理 WebSocket 连接,以及可以通过该连接发送和接收数据的 API。
使用 WebSocket()
构造函数来构造一个 WebSocket
。
构造函数
WebSocket(url[, protocols]) 返回一个 WebSocket 对象。 语法:
var aWebSocket = new WebSocket(url [, protocols]);
参数 url 要连接的 URL;这应该是 WebSocket 服务器将响应的 URL。
protocols 可选 一个协议字符串或者一个包含协议字符串的数组。这些字符串用于指定子协议,这样单个服务器可以实现多个 WebSocket 子协议(例如,你可能希望一台服务器能够根据指定的协议(protocol)处理不同类型的交互)。如果不指定协议字符串,则假定为空字符串。
常量
这些常量是websocket的状态的表示。 可以通过WebSocket中readyState属性来获取,每次发送消息前确认连接是打开的很重要。
Constant | Value |
---|---|
WebSocket.CONNECTING | 0 |
WebSocket.OPEN | 1 |
WebSocket.CLOSING | 2 |
WebSocket.CLOSED | 3 |
如何确认连接是打开的呢? 用法:
javascript
function sendMessage(content) {
const message = {
content: content,
timestamp: new Date()
};
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
} else {
pendingMessages.push(message);
if (socket.readyState === WebSocket.CONNECTING) {
console.log('WebSocket is connecting, message queued');
} else {
console.log('WebSocket is not open, message queued');
}
}
}
属性
WebSocket.binaryType
使用二进制的数据类型连接。
WebSocket.bufferedAmount
只读 未发送至服务器的字节数。
WebSocket.extensions
只读 服务器选择的扩展。
WebSocket.onclose
用于指定连接关闭后的回调函数。
WebSocket.onerror
用于指定连接失败后的回调函数。
WebSocket.onmessage
用于指定当从服务器接受到信息时的回调函数。
WebSocket.onopen
用于指定连接成功后的回调函数。
WebSocket.protocol
只读 服务器选择的下属协议。
WebSocket.readyState
只读 当前的链接状态。
WebSocket.url
只读 WebSocket 的绝对路径。
方法
WebSocket.close([code[, reason]]) 关闭当前链接。
WebSocket.send(data) 对要传输的数据进行排队。
事件
使用 addEventListener()
或将一个事件监听器赋值给本接口的 oneventname
属性,来监听下面的事件。
一个有四个事件:
- close 当一个
WebSocket
连接被关闭时触发。 也可以通过onclose
属性来设置。 - error 当一个
WebSocket
连接因错误而关闭时触发,例如无法发送数据时。 也可以通过onerror
属性来设置 - message 当通过
WebSocket
收到数据时触发。 也可以通过onmessage
属性来设置。 - open 当一个
WebSocket
连接成功时触发。 也可以通过onopen
属性来设置。
示例
js
// Create WebSocket connection.
const socket = new WebSocket("ws://localhost:8080");
// Connection opened
socket.addEventListener("open", function (event) {
socket.send("Hello Server!");
});
// Listen for messages
socket.addEventListener("message", function (event) {
console.log("Message from server ", event.data);
});
通过 addEventListener 设置事件和 onopen、onmessage、onclose、onerror的区别
拿onopen这个属性来说明:
onopen
是 WebSocket 对象的一个属性。你可以直接将一个函数赋值给这个属性来处理 WebSocket 连接打开时的事件。
写法
js
var exampleSocket = new WebSocket("ws://example.com/socket");
exampleSocket.onopen = function(event) {
console.log("Connection opened!");
};
exampleSocket.onmessage = function (event) {
console.log(event.data);
};
exampleSocket.onclose = function(event) {
console.log("WebSocket is closed now.");
};
exampleSocket.onerror = function(event) {
console.log("WebSocket is closed now.");
};
区别
多事件监听:
onopen
: 只能有一个事件处理函数。后赋值的函数会覆盖先前赋值的函数。addEventListener
: 可以添加多个事件处理函数,所有添加的处理函数都会被调用,不会相互覆盖。
一致性:
addEventListener
是 DOM 事件处理的标准方法,适用于所有支持事件的对象(如 DOM 元素、WebSocket、XMLHttpRequest 等)。使用addEventListener
可以使你的代码在处理事件时保持一致。
websocket 传递参数
通过 url
方法一: 通过@PathParam注解,js传参类似于restful风格 @ServerEndpoint("/socket/test/{params}")
@OnOpen
public void onOpen(Session session, @PathParam("params") String params) {
方法二: 此时就可以不用@PathParma参数了
前端请求携带参数
js
var exampleSocket = new WebSocket("ws://localhost:8080/socket/test?parmas=id001");
后台解析参数通过session
java
Map<String, String> pathParameters = session.getPathParameters();
String params = pathParameters.get("params");
通过发送json
通过设置header
原生websocket存在的问题
- 不支持自动重连需要自己实现或者通过现有的库
通过websocket实现一个实时聊天系统
所涉及到的问题点
- 如何存储对离线的人发送的消息。
- 如何保证消息不丢失
- 如何做到断线自动重连
- 如何确认消息发送成功
- 用户上线时如何接受到离线时的消息