为什么需要 WebSocket?
HTTP 的局限性
- 无状态短连接:每次请求需要重新建立 TCP 连接(HTTP/1.1 的 Keep-Alive 只能有限复用)
- 单向通信:必须由客户端发起请求,服务器才能响应(无法实现服务器主动推送)
- 高开销:每次请求携带完整 HTTP 头部(Cookie、User-Agent 等冗余信息)
- 实时性差:轮询(Polling)和长轮询(Long Polling)有显著延迟
传统实时方案缺陷
| 方案 | 延迟 | 服务器压力 | 适用场景 | 缺点分析 |
|---|---|---|---|---|
| 短轮询 | 高 | 极高 | 简单数据更新 | 无效请求多,资源浪费严重 |
| 长轮询 | 中 | 高 | 邮件通知 | 连接保持消耗资源 |
| SSE | 低 | 中 | 股票行情 | 仅支持服务器到客户端推送 |
| WebSocket | 极低 | 低 | 所有实时场景 | 实现复杂度较高 |
业务需求驱动
现代 Web 应用对实时性的要求越来越高:
- 金融交易系统需要毫秒级行情推送
- 在线协作工具需要实时同步用户操作
- 物联网仪表盘需要实时展示设备状态
WebSocket 的诞生
历史演进

设计目标
- 基于 TCP:可靠传输层保障
- 兼容 HTTP:80/443 端口,通过 Upgrade 机制握手
- 轻量级:帧头最小仅 2 字节
- 全双工:突破 HTTP 单向限制、
WebSocket 是什么?
WebSocket 是基于 TCP 的一种新的应用层网络协议。它实现了浏览器与服务器全双工通信,即允许服务器主动发送信息给客户端。因此,在 WebSocket 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,客户端和服务器之间的数据交换变得更加简单。
WebSocket 的特点
- 建立在 TCP 协议之上;
- 与 HTTP 协议有着良好的兼容性:默认端口也是 80(ws) 和 443(wss,运行在 TLS 之上),并且握手阶段采用 HTTP 协议;
- 较少的控制开销:连接创建后,ws 客户端、服务端进行数据交换时,协议控制的数据包头部较小,而 HTTP 协议每次通信都需要携带完整的头部;
- 可以发送文本,也可以发送二进制数据;
- 没有同源限制,客户端可以与任意服务器通信;
- 协议标识符是 ws(如果加密,则为 wss),服务器网址就是 URL;
- 支持扩展:ws 协议定义了扩展,用户可以扩展协议,或者实现自定义的子协议(比如支持自定义压缩算法等);
WebSocket 与 HTTP、TCP
HTTP、WebSocket 等协议都是处于 OSI 模型的最高层:应用层。而 IP 协议工作在网络层,TCP 协议工作在传输层。
HTTP、WebSocket 等应用层协议,都是基于 TCP 协议来传输数据的,因此其连接和断开,都要遵循 TCP 协议中的三次握手和四次挥手 ,只是在连接之后发送的内容不同,或者是断开的时间不同。
WebSocket 协议详解
入门例子
在正式介绍协议细节前,先来看一个简单的例子
服务端
pom.xml 中添加 WebSocket 依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>3.4.5</version>
</dependency>
代码如下,监听8080端口。当有新的连接请求到达时,打印日志,同时向客户端发送消息。当收到到来自客户端的消息时,同样打印日志。
创建 WebSocket 配置类
java
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new MyWebSocketHandler(), "/ws")
.setAllowedOrigins("*"); // 允许跨域
}
}
创建 WebSocket 处理器
java
public class MyWebSocketHandler extends TextWebSocketHandler {
private final CopyOnWriteArrayList<WebSocketSession> sessions = new CopyOnWriteArrayList<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
sessions.add(session);
System.out.println("新连接建立: " + session.getId());
session.sendMessage(new TextMessage("欢迎连接到WebSocket服务器!"));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
System.out.println("收到来自客户端 " + session.getId() + " 的消息: " + message.getPayload());
// 可以在这里处理消息并回复
session.sendMessage(new TextMessage("已收到你的消息: " + message.getPayload()));
}
@Override
public void afterConnectionClosed(WebSocketSession session, org.springframework.web.socket.CloseStatus status) throws Exception {
sessions.remove(session);
System.out.println("连接关闭: " + session.getId());
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
System.err.println("连接 " + session.getId() + " 发生错误:");
exception.printStackTrace();
}
}
客户端
代码如下,向8080端口发起WebSocket连接。连接建立后,打印日志,同时向服务端发送消息。接收到来自服务端的消息后,同样打印日志。
javascript
<!DOCTYPE html>
<html>
<head>
<title>WebSocket 客户端</title>
</head>
<body>
<script>
// 创建WebSocket连接
const socket = new WebSocket("ws://localhost:8080/ws");
// 连接建立时
socket.onopen = function(event) {
console.log("连接已建立");
// 向服务器发送消息
socket.send("你好,服务器!");
};
// 接收到消息时
socket.onmessage = function(event) {
console.log("收到服务器消息: " + event.data);
};
// 连接关闭时
socket.onclose = function(event) {
console.log("连接已关闭");
};
// 发生错误时
socket.onerror = function(error) {
console.error("WebSocket错误: ", error);
};
</script>
</body>
</html>
运行结果
服务端输出:
javascript
新连接建立: 123f164e-26cf-04d1-4ee3-4a91fac2a7f0
收到来自客户端 123f164e-26cf-04d1-4ee3-4a91fac2a7f0 的消息: 你好,服务
客户端输出:
javascript
连接已建立
收到服务器消息: 欢迎连接到WebSocket服务器!
收到服务器消息: 已收到你的消息: 你好,服务器!
如何建立连接
在 WebSocket 开始通信之前,通信双方需要先进行握手,WebSocket复用了HTTP的握手通道。具体指的是,客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。
利用 HTTP 完成握手有什么好处呢?一是可以让 WebSocket 和 HTTP 基础设备兼容(运行在 80 端口 或 443 端口),二是可以复用 HTTP 的 Upgrade 机制,完成升级协议的协商过程。
客户端:申请协议升级
首先,客户端发起协议升级请求。可以看到,采用的是标准的HTTP报文格式,且只支持GET方法。

重点请求首部意义如下:
- Connection: Upgrade:表示要升级协议
- Upgrade: websocket:表示要升级到websocket协议。
- Sec-WebSocket-Version: 13:表示websocket的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader,里面包含服务端支持的版本号。
- Sec-WebSocket-Key:与后面服务端响应首部的Sec-WebSocket-Accept是配套的,提供基本的防护,比如恶意的连接,或者无意的连接。
服务端:响应协议升级
服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。

Sec-WebSocket-Accept的计算
Sec-WebSocket-Accept根据客户端请求首部的Sec-WebSocket-Key计算出来。
计算公式为:
- 将Sec-WebSocket-Key跟258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
- 通过SHA1计算出摘要,并转成base64字符串。
验证下前面的返回结果:
java
public class WebSocketAcceptCalculator {
private static final String MAGIC_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
public static String calculateAcceptKey(String secWebSocketKey) {
try {
// 1. 拼接 key 和 magic GUID
String input = secWebSocketKey + MAGIC_GUID;
// 2. 计算 SHA-1 哈希
MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
byte[] sha1Hash = sha1.digest(input.getBytes(StandardCharsets.UTF_8));
// 3. Base64 编码
return Base64.getEncoder().encodeToString(sha1Hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-1 algorithm not available", e);
}
}
public static void main(String[] args) {
String secWebSocketKey = "ZVdgW7a1Xa/MQb5tVybRfA==";
String acceptKey = calculateAcceptKey(secWebSocketKey);
System.out.println("Sec-WebSocket-Accept: " + acceptKey);
}
}
输出:
java
Sec-WebSocket-Accept: b3XPpwH2dyaFUWISfKSLl29dn7Y=
数据帧格式
客户端、服务端数据的交换,离不开数据帧格式的定义。因此,在实际讲解数据交换之前,我们先来看下WebSocket的数据帧格式。
WebSocket客户端、服务端通信的最小单位是帧(frame),由1个或多个帧组成一条完整的消息(message)。
- 发送端:将消息切割成多个帧,并发送给服务端;
- 接收端:接收消息帧,并将关联的帧重新组装成完整的消息;
数据帧格式概览
下面给出了WebSocket数据帧的统一格式。熟悉TCP/IP协议的同学对这样的图应该不陌生。
- 从左到右,单位是比特。比如FIN、RSV1各占据1比特,opcode占据4比特。
- 内容包括了标识、操作代码、掩码、数据、数据长度等。
lua
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
: Payload Data :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
数据帧格式详解
- FIN(1bit):如果是1,表示这是消息的最后一个分片,如果是0,表示不是消息的最后一个分片。
- RSV1-3(各1bit):通常为0。当采用扩展时标志位可以非0
- Opcode(4bit):操作代码,Opcode的值决定如何解析后续的数据载荷。可选的操作代码如下:
- 0x0:延续帧, 表示本次数据传输采用了数据分片
- 0x1:文本帧
- 0x2:二进制帧
- 0x8:连接断开
- 0x9:ping操作
- 0xA:pong操作
- Mask(1bit):1表示有掩码(从客户端向服务端发送数据时,需要对数据进行掩码操作)
- Payload length:数据载荷的长度,单位是字节。
- 0-126: 直接表示长度
- 126: 后2字节表示长度
- 127: 后8字节表示长度
- Masking-key:4字节(当Mask=1时存在)
- Payload data:实际数据
掩码算法
掩码键(Masking-key)是由客户端挑选出来的32位的随机数。
算法:数据每个字节(i)与Masking-key的第j(j=i%4)个字节进行异或运算。
数据传递
一旦WebSocket客户端、服务端建立连接后,后续的操作都是基于数据帧的传递。
WebSocket根据opcode来区分操作的类型。比如0x8表示断开连接,0x0-0x2表示数据交互。
数据分片
WebSocket的每条消息可能被切分成多个数据帧。当WebSocket的接收方收到一个数据帧时,会根据FIN的值来判断,是否已经收到消息的最后一个数据帧。
FIN=1表示当前数据帧为消息的最后一个数据帧,此时接收方已经收到完整的消息,可以对消息进行处理。FIN=0,则接收方还需要继续监听接收其余的数据帧。
此外,opcode在数据交换的场景下,表示的是数据的类型。0x01表示文本,0x02表示二进制。而0x00比较特殊,表示延续帧(continuation frame),顾名思义,就是完整消息对应的数据帧还没接收完。
数据分片例子
客户端向服务端两次发送消息,服务端收到消息后回应客户端,这里主要看客户端往服务端发送的消息。
第一条消息
FIN=1, 表示是当前消息的最后一个数据帧。服务端收到当前数据帧后,可以处理消息。opcode=0x1,表示客户端发送的是文本类型。
第二条消息
- FIN=0,opcode=0x1,表示发送的是文本类型,且消息还没发送完成,还有后续的数据帧。
- FIN=0,opcode=0x0,表示消息还没发送完成,还有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。
- FIN=1,opcode=0x0,表示消息已经发送完成,没有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。服务端可以将关联的数据帧组装成完整的消息。
java
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!
连接保持+心跳
WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。
但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用心跳来实现。
- 发送方->接收方:ping
- 接收方->发送方:pong
ping、pong的操作,对应的是WebSocket的两个控制帧,opcode分别是0x9、0xA。
Sec-WebSocket-Key/Accept
Sec-WebSocket-Key/Sec-WebSocket-Accept在主要作用在于提供基础的防护,减少恶意连接、意外连接。
作用大致归纳如下:
- 避免服务端收到非法的websocket连接(比如http客户端不小心请求连接websocket服务,此时服务端可以直接拒绝连接)
- 确保服务端理解websocket连接。因为ws握手阶段采用的是http协议,因此可能ws连接是被一个http服务器处理并返回的,此时客户端可以通过Sec-WebSocket-Key来确保服务端认识ws协议。(并非百分百保险,比如总是存在那么些无聊的http服务器,光处理Sec-WebSocket-Key,但并没有实现ws协议。。。)
- 用浏览器里发起ajax请求,设置header时,Sec-WebSocket-Key以及其他相关的header是被禁止的。这样可以避免客户端发送ajax请求时,意外请求协议升级(websocket upgrade)
- 可以防止反向代理(不理解ws协议)返回错误的数据。比如反向代理前后收到两次ws连接的升级请求,反向代理把第一次请求的返回给cache住,然后第二次请求到来时直接把cache住的请求给返回(无意义的返回)。
- Sec-WebSocket-Key主要目的并不是确保数据的安全性,因为Sec-WebSocket-Key、Sec-WebSocket-Accept的转换计算公式是公开的,而且非常简单,最主要的作用是预防一些常见的意外情况(非故意的)。
WebSocket 扩展
WebSocket 扩展是通过 WebSocket 握手阶段 的 Sec-WebSocket-Extensions 头部进行协商的。客户端和服务端通过该头部声明支持的扩展,并最终协商出一个或多个共同支持的扩展用于后续通信。
扩展协商流程
客户端发起扩展支持声明
客户端在握手请求的 Sec-WebSocket-Extensions 头部中列出支持的扩展及其参数。例如:
http
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits, x-extension-example
- 此例中,客户端支持两个扩展:
- permessage-deflate(WebSocket 压缩扩展),参数为 client_max_window_bits。
- 自定义扩展 x-extension-example。
服务端响应确认扩展
服务端从客户端支持的扩展中选择一个或多个,通过响应头部的 Sec-WebSocket-Extensions 返回最终使用的扩展及参数:
http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15
- 服务端确认使用 permessage-deflate 扩展,并设置参数 client_max_window_bits=15。
- 未选择的扩展(如 x-extension-example)将被忽略。
扩展的使用方式
协商成功后,扩展会在 数据帧 的传输过程中生效。不同的扩展对数据帧的处理方式不同:
压缩扩展(如 permessage-deflate)
- 客户端发送数据:先压缩数据,再通过 WebSocket 帧发送。
- 服务端接收数据:解压数据后传递给应用层。
- 双向生效:压缩和解压过程对双方透明。
自定义扩展
扩展可能修改帧的:
- Payload 数据(如加密、压缩)。
- 帧头字段(如扩展标志位)。
多个扩展按 Sec-WebSocket-Extensions 头部中的顺序依次处理(如先压缩再加密)。
若扩展协商失败,连接仍可建立,但需回退到无扩展模式。
安全性增强
启用WSS(WebSocket Secure)
场景 :防止中间人攻击,确保数据传输加密。
Spring Boot配置示例:
java
# application.yml
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: yourpassword
key-store-type: PKCS12
前端连接:
javascript
const socket = new WebSocket("wss://yourdomain.com/ws");
防护措施
- Origin校验:防止跨站劫持(CSWSH)
java
@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(myHandler(), "/ws")
.setAllowedOrigins("https://trusted-domain.com"); // 严格限制来源
}
}
- 帧大小限制:防御DoS攻击
java
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setMaxTextMessageBufferSize(8192); // 限制单帧8KB
container.setMaxBinaryMessageBufferSize(8192);
return container;
}
认证强化
- JWT校验示例
java
private void handleAuthMessage(WebSocketSession session, String token) {
try {
Claims claims = Jwts.parserBuilder()
.setSigningKey(jwtSecret)
.build()
.parseClaimsJws(token)
.getBody();
if (claims.getExpiration().before(new Date())) {
session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Token已过期"));
}
} catch (JwtException e) {
session.close(CloseStatus.NOT_ACCEPTABLE.withReason("无效Token"));
}
}
性能优化
压缩扩展(permessage-deflate)
服务端启用压缩:
java
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
container.setPerMessageDeflateEnabled(true); // 开启压缩
container.setCompressionLevel(6); // 压缩级别(1-9)
return container;
}
客户端请求头:
javascript
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15
协议扩展实践
STOMP子协议
场景 :实现发布-订阅模式(如聊天室)。
服务端配置:
java
@Configuration
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic"); // 内置Broker
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/stomp").setAllowedOrigins("*");
}
}
客户端订阅:
javascript
const stompClient = new StompJs.Client({ brokerURL: 'ws://localhost:8080/stomp' });
stompClient.subscribe('/topic/updates', (message) => {
console.log("收到广播消息: " + message.body);
});
在项目中使用 WebSocket

WebSocketConfig
Spring WebSocket核心配置,负责 WebSocket 的端点注册、处理器映射和全局配置。
java
@Configuration // 标记为Spring配置类
@EnableWebSocket // 启用Spring WebSocket功能
public class WebSocketConfig implements WebSocketConfigurer {
/**
* WebSocket处理器映射表
* Key: WebSocket连接路径
* Value: 对应的处理器实例
*/
private final Map<String, TextWebSocketHandler> handlers;
/**
* WebSocket认证拦截器
* 用于处理连接建立时的身份验证
*/
private final WebSocketAuthInterceptor authInterceptor;
/**
* 构造函数(自动注入依赖)
* @param handlerList 所有TextWebSocketHandler类型的Bean集合
* @param authInterceptor 认证拦截器实例
*/
@Autowired
public WebSocketConfig(List<TextWebSocketHandler> handlerList,
WebSocketAuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
// 将处理器列表转换为路径->处理器的映射
this.handlers = handlerList.stream()
.collect(Collectors.toMap(
// 键映射函数:根据处理器类型确定对应的WebSocket路径
handler -> {
if (handler instanceof DataWebSocketHandler) {
return "/ws/api";
}
return null;
},
// 值映射函数:直接使用处理器实例
handler -> handler
));
}
/**
* 注册WebSocket处理器
* @param registry WebSocket处理器注册中心
*/
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 遍历所有已映射的处理器
handlers.forEach((path, handler) ->
registry.addHandler(handler, path) // 注册处理器和对应路径
.addInterceptors(authInterceptor) // 添加认证拦截器
.setAllowedOriginPatterns("*") // 允许所有来源跨域访问
);
}
}
DataWebSocketHandler
WebSocket业务核心组件,处理连接生命周期、用户认证、消息路由、主题订阅,并提供定时/即时数据推送能力
java
@Component
@Slf4j
public class DataWebSocketHandler extends TextWebSocketHandler {
// 定义有效的订阅主题集合
private static final Set<String> VALID_TOPICS = Set.of("OVERVIEW");
// 认证超时时间(秒)
private static final long AUTH_TIMEOUT_SECONDS = 10;
@Resource
private WebSocketSessionManager sessionManager;
@Resource
private ObjectMapper objectMapper;
// 消息处理器映射表(类型 -> 处理器)
private final Map<String, MessageHandler> messageHandlers = new ConcurrentHashMap<>();
// 定时任务执行器(用于认证超时控制)
private final ScheduledExecutorService scheduler = new ScheduledThreadPoolExecutor(1);
// 消息处理函数式接口
@FunctionalInterface
private interface MessageHandler {
void handle(WebSocketSession session, Map<String, Object> message) throws IOException;
}
/**
* 初始化消息处理器
*/
@PostConstruct
public void init() {
messageHandlers.put("AUTH", this::handleAuthMessage); // 认证处理
messageHandlers.put("SUBSCRIBE", this::handleSubscribeMessage); // 订阅处理
messageHandlers.put("HEARTBEAT", this::handleHeartbeat); // 心跳处理
}
/**
* 新连接建立时触发
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) {
TraceNoUtils.newTraceNo();
// 注册新会话
sessionManager.registerSession(session);
log.info("新WebSocket连接建立: sessionId={}", session.getId());
// 设置认证超时任务
scheduler.schedule(() -> {
if (!sessionManager.isAuthenticated(session)) {
try {
log.warn("连接 {} 未在 {} 秒内完成认证,即将关闭", session.getId(), AUTH_TIMEOUT_SECONDS);
session.close(CloseStatus.NOT_ACCEPTABLE);
} catch (IOException e) {
}
}
}, AUTH_TIMEOUT_SECONDS, TimeUnit.SECONDS);
}
/**
* 处理收到的文本消息
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
try {
// 解析JSON消息
Map<String, Object> requestMap = objectMapper.readValue(
message.getPayload(),
new TypeReference<>() {}
);
// 根据消息类型路由到对应的处理器
String type = (String) requestMap.get("type");
MessageHandler handler = messageHandlers.get(type);
handler.handle(session, requestMap);
} catch (Exception e) {
log.error("处理消息出错: {}", message.getPayload(), e);
sendErrorResponse(session, "消息处理错误: " + e.getMessage());
session.close(CloseStatus.SERVER_ERROR);
}
}
/**
* 处理认证消息
*/
private void handleAuthMessage(WebSocketSession session, Map<String, Object> message) throws IOException {
log.info("处理认证消息:sessionId={}, message={}", session.getId(), message);
// 检查重复认证
if (sessionManager.isAuthenticated(session)) {
sendErrorResponse(session, "已认证,请勿重复认证");
return;
}
// Token验证
String token = (String) message.get("token");
if (StringUtils.isBlank(token)) {
sendErrorResponse(session, "认证失败: token不能为空");
session.close(CloseStatus.NOT_ACCEPTABLE);
return;
}
// 认证会话
sessionManager.authenticateSession(session, new UserInfo())));
sendSuccessResponse(session, "认证成功", "AUTH");
}
/**
* 处理订阅消息
*/
private void handleSubscribeMessage(WebSocketSession session, Map<String, Object> message) throws IOException {
log.info("处理订阅消息:sessionId={}, message={}", session.getId(), message);
// 验证主题有效性
String topic = (String) message.get("topic");
if (!VALID_TOPICS.contains(topic)) {
sendErrorResponse(session, "无效订阅主题: " + topic);
return;
}
// 订阅主题
sessionManager.subscribeTopic(session, topic);
// 立即推送一次数据
try {
pushDataToSession(session, topic);
} catch (Exception e) {
log.error("订阅后立即推送数据失败,主题: {}, 会话ID: {}", topic, session.getId(), e);
}
sendSuccessResponse(session, "订阅成功: " + topic, "SUBSCRIBE");
}
/**
* 处理心跳消息
*/
private void handleHeartbeat(WebSocketSession session, Map<String, Object> message) {
try {
if (!session.isOpen()) {
log.warn("会话 {} 已关闭,忽略心跳", session.getId());
return;
}
// 更新活跃时间
sessionManager.updateLastActiveTime(session);
sendSuccessResponse(session, "心跳已接收", "HEARTBEAT");
} catch (IOException e) {
}
}
/**
* 向会话推送主题数据
*/
private void pushDataToSession(WebSocketSession session, String topic) throws Exception {
if ("OVERVIEW".equals(topic)) {
// 实际项目应替换为真实数据获取逻辑
String jsonResponse = objectMapper.writeValueAsString("hello world");
sessionManager.sendToSession(session, "OVERVIEW", jsonResponse);
log.info("向会话 {} 推送数据成功", session.getId());
}
}
/**
* 定时推送数据(每分钟执行一次)
*/
@Scheduled(fixedRateString = "${websocket.push.events.interval:60000}")
public void pushOverviewData() {
// 获取所有订阅者
Set<WebSocketSession> subscribedSessions = sessionManager.getSubscribedSessions("OVERVIEW");
if (subscribedSessions.isEmpty()) {
log.info("当前没有订阅OVERVIEW的会话");
return;
}
// 批量推送
subscribedSessions.forEach(session -> {
try {
pushDataToSession(session, "OVERVIEW");
} catch (Exception ignored) {
}
});
}
}
WebSocketSessionManager
WebSocket会话全生命周期管理器,含认证、心跳、订阅和推送功能
java
@Slf4j
@Component
public class WebSocketSessionManager {
// 存储所有会话 (sessionId -> WebSocketSession)
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
// 存储已认证的会话及对应的用户信息 (WebSocketSession -> UserInfo)
private final Map<WebSocketSession, UserInfo> authenticatedSessions = new ConcurrentHashMap<>();
// 存储每个会话的最后活跃时间 (WebSocketSession -> LastActiveTime)
private final Map<WebSocketSession, LocalDateTime> lastActiveTimes = new ConcurrentHashMap<>();
// 存储用户ID到会话的映射 (userId -> WebSocketSession)
private final Map<String, WebSocketSession> userSessions = new ConcurrentHashMap<>();
// 存储会话订阅的主题 (WebSocketSession -> Set<topic>)
private final Map<WebSocketSession, Set<String>> sessionTopics = new ConcurrentHashMap<>();
// 存储每个主题的参数 (WebSocketSession -> (topic -> (paramName -> paramValue)))
private final Map<WebSocketSession, Map<String, Map<String, Object>>> topicParams = new ConcurrentHashMap<>();
// 心跳超时时间(秒) - 建议通过@Value从配置读取
private static final long HEARTBEAT_TIMEOUT = 120;
/**
* 注册新连接
* @param session 新建立的WebSocket会话
*/
public void registerSession(WebSocketSession session) {
sessions.put(session.getId(), session);
lastActiveTimes.put(session, LocalDateTime.now());
log.info("注册新会话: {}", session.getId());
}
/**
* 认证会话(实现单用户单连接)
* @param session 待认证的会话
* @param userInfo 用户信息
*/
public void authenticateSession(WebSocketSession session, UserInfo userInfo) {
// 移除该用户之前的会话(实现新连接踢掉旧连接)
WebSocketSession previousSession = userSessions.put(userInfo.getPhone(), session);
if (previousSession != null && !previousSession.getId().equals(session.getId())) {
log.info("用户 {} 的新会话 {} 已建立,关闭旧会话 {}",
userInfo.getPhone(), session.getId(), previousSession.getId());
removeSession(previousSession);
closeSessionQuietly(previousSession);
}
authenticatedSessions.put(session, userInfo);
session.getAttributes().put("user", userInfo);
log.info("会话认证成功: {}, 用户: {}", session.getId(), userInfo.getPhone());
}
/**
* 更新会话活跃时间(心跳检测用)
* @param session 需要更新的会话
*/
public void updateLastActiveTime(WebSocketSession session) {
lastActiveTimes.put(session, LocalDateTime.now());
log.trace("会话 {} 的活跃时间已更新", session.getId());
}
/**
* 移除会话(清理所有相关数据)
* @param session 待移除的会话
*/
public void removeSession(WebSocketSession session) {
sessions.remove(session.getId());
authenticatedSessions.remove(session);
lastActiveTimes.remove(session);
userSessions.values().remove(session);
sessionTopics.remove(session);
topicParams.remove(session);
log.info("移除会话: {}", session.getId());
}
/**
* 检查会话是否超时
* @param session 待检查的会话
* @param timeoutSeconds 超时阈值(秒)
* @return 是否已超时
*/
public boolean isSessionExpired(WebSocketSession session, long timeoutSeconds) {
LocalDateTime lastActive = lastActiveTimes.get(session);
if (lastActive == null) {
return true; // 从未活跃过,视为超时
}
long inactiveSeconds = Duration.between(lastActive, LocalDateTime.now()).getSeconds();
return inactiveSeconds > timeoutSeconds;
}
/**
* 检查会话是否已认证
* @param session 待检查的会话
* @return 是否已认证
*/
public boolean isAuthenticated(WebSocketSession session) {
return authenticatedSessions.containsKey(session);
}
/**
* 订阅主题
* @param session 订阅会话
* @param topic 主题名称
*/
public void subscribeTopic(WebSocketSession session, String topic) {
// 获取或创建会话的主题集合
Set<String> topics = sessionTopics.computeIfAbsent(session, k -> ConcurrentHashMap.newKeySet());
topics.add(topic);
log.info("会话 {} 订阅主题: {}", session.getId(), topic);
}
/**
* 检查会话是否订阅了指定主题
* @param session 待检查的会话
* @param topic 主题名称
* @return 是否已订阅
*/
public boolean isSubscribedToTopic(WebSocketSession session, String topic) {
Set<String> topics = sessionTopics.get(session);
return topics != null && topics.contains(topic);
}
/**
* 获取主题参数
* @param session 目标会话
* @param topic 主题名称
* @param paramName 参数名
* @return 参数值
*/
public Object getTopicParam(WebSocketSession session, String topic, String paramName) {
Map<String, Map<String, Object>> sessionParams = topicParams.get(session);
if (sessionParams != null) {
Map<String, Object> topicParamMap = sessionParams.get(topic);
if (topicParamMap != null) {
return topicParamMap.get(paramName);
}
}
return null;
}
/**
* 定时检查心跳超时(每分钟执行一次)
* 自动清理超时会话
*/
@Scheduled(fixedRate = 60000)
public void checkHeartbeatTimeout() {
sessions.values().forEach(session -> {
if (isSessionExpired(session, HEARTBEAT_TIMEOUT)) {
removeSession(session);
closeSessionQuietly(session);
}
});
}
/**
* 安全关闭会话(忽略异常)
* @param session 待关闭的会话
*/
private void closeSessionQuietly(WebSocketSession session) {
try {
if (session.isOpen()) {
session.close();
}
} catch (IOException e) {
log.error("关闭会话失败", e);
}
}
/**
* 获取订阅了特定主题的所有活跃会话
* @param topic 主题名称
* @return 订阅该主题的会话集合
*/
public Set<WebSocketSession> getSubscribedSessions(String topic) {
return authenticatedSessions.keySet().stream()
.filter(session -> session.isOpen() && isSubscribedToTopic(session, topic))
.collect(Collectors.toSet());
}
/**
* 向特定会话发送主题消息
* @param session 目标会话
* @param topic 主题名称
* @param message 消息内容(JSON字符串)
* @throws IOException 发送失败时抛出
*/
public void sendToSession(WebSocketSession session, String topic, String message) throws IOException {
if (session.isOpen() && isSubscribedToTopic(session, topic)) {
String fullMessage = "{\"topic\":\"" + topic + "\",\"data\":" + message + "}";
session.sendMessage(new TextMessage(fullMessage));
}
}
}
前端JS代码如下
javascript
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket 客户端</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.container {
display: flex;
flex-direction: column;
gap: 20px;
}
.control-panel {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
button {
padding: 8px 16px;
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
textarea {
width: 100%;
min-height: 100px;
padding: 8px;
box-sizing: border-box;
}
.status {
padding: 10px;
border-radius: 4px;
margin-bottom: 10px;
}
.connected {
background-color: #d4edda;
color: #155724;
}
.disconnected {
background-color: #f8d7da;
color: #721c24;
}
.messages {
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
background-color: #f8f9fa;
}
.message {
margin-bottom: 10px;
padding: 8px;
border-bottom: 1px solid #eee;
}
.message.incoming {
background-color: #e2f0fd;
}
.message.outgoing {
background-color: #f0f0f0;
}
.message.error {
background-color: #fde8e8;
color: #dc3545;
}
.timestamp {
font-size: 0.8em;
color: #6c757d;
}
.preset-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.data-display {
border: 1px solid #ddd;
padding: 10px;
border-radius: 4px;
margin-top: 10px;
background-color: #f9f9f9;
}
.data-display h3 {
margin-top: 0;
}
.data-display pre {
background-color: #f5f5f5;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="container">
<h1>WebSocket 客户端 (推送模式)</h1>
<div id="status" class="status disconnected">未连接</div>
<div class="control-panel">
<input type="text" id="wsUrl" placeholder="ws://192.168.54.25:8080/ws/api" style="flex-grow: 1;">
<button id="connectBtn">连接</button>
<button id="disconnectBtn" disabled>断开</button>
</div>
<div>
<h3>认证信息</h3>
<div class="preset-buttons">
<button id="setAuthBtn">设置认证消息</button>
<input type="text" id="tokenInput" placeholder="输入token" style="flex-grow: 1;">
</div>
<textarea id="authMessage" readonly></textarea>
</div>
<div>
<h3>订阅管理</h3>
<div class="preset-buttons">
<button id="subscribeEventsBtn" disabled>订阅概览</button>
<button id="unsubscribeBtn" disabled>取消所有订阅</button>
<input type="text" id="dateTimeInput" placeholder="日期时间 (2025-06-20 14:30:00)">
</div>
</div>
<div>
<h3>消息记录</h3>
<div class="messages" id="messageLog"></div>
</div>
<div class="data-display" id="eventsDataDisplay">
<h3>概览数据</h3>
<pre id="eventsData"></pre>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// DOM元素
const wsUrlInput = document.getElementById('wsUrl');
const connectBtn = document.getElementById('connectBtn');
const disconnectBtn = document.getElementById('disconnectBtn');
const statusDiv = document.getElementById('status');
const messageLog = document.getElementById('messageLog');
const authMessage = document.getElementById('authMessage');
const tokenInput = document.getElementById('tokenInput');
const setAuthBtn = document.getElementById('setAuthBtn');
const subscribeEventsBtn = document.getElementById('subscribeEventsBtn');
const unsubscribeBtn = document.getElementById('unsubscribeBtn');
const dateTimeInput = document.getElementById('dateTimeInput');
const eventsDataDisplay = document.getElementById('eventsDataDisplay');
const eventsData = document.getElementById('eventsData');
// 订阅状态
let isSubscribedEvents = false;
let isSubscribedChart = false;
// WebSocket变量
let socket = null;
let isAuthenticated = false;
// 设置认证消息
setAuthBtn.addEventListener('click', function() {
const token = tokenInput.value.trim();
if (token) {
authMessage.value = JSON.stringify({
type: "AUTH",
token: token
}, null, 2);
} else {
alert('请输入token');
}
});
// 订阅按钮事件
subscribeEventsBtn.addEventListener('click', function() {
if (!socket || socket.readyState !== WebSocket.OPEN) {
alert('WebSocket未连接');
return;
}
if (!isAuthenticated) {
alert('请先完成认证');
return;
}
const dateTime = dateTimeInput.value.trim() || new Date().toISOString().slice(0, 19).replace('T', ' ');
const subscribeMsg = JSON.stringify({
type: "SUBSCRIBE",
topic: "OVERVIEW",
params: { dateTime: dateTime }
});
socket.send(subscribeMsg);
logMessage('发送', subscribeMsg, 'outgoing');
isSubscribedEvents = true;
updateSubscribeButtons();
});
unsubscribeBtn.addEventListener('click', function() {
if (!socket || socket.readyState !== WebSocket.OPEN) {
alert('WebSocket未连接');
return;
}
if (!isAuthenticated) {
alert('请先完成认证');
return;
}
if (isSubscribedEvents) {
const unsubscribeMsg = JSON.stringify({
type: "UNSUBSCRIBE",
topic: "OVERVIEW"
});
socket.send(unsubscribeMsg);
logMessage('发送', unsubscribeMsg, 'outgoing');
isSubscribedEvents = false;
}
if (isSubscribedChart) {
const unsubscribeMsg = JSON.stringify({
type: "UNSUBSCRIBE",
topic: "EVENTS_CHART"
});
socket.send(unsubscribeMsg);
logMessage('发送', unsubscribeMsg, 'outgoing');
isSubscribedChart = false;
}
updateSubscribeButtons();
});
// 连接WebSocket
connectBtn.addEventListener('click', function() {
const url = wsUrlInput.value.trim();
if (!url) {
alert('请输入WebSocket URL');
return;
}
connectWebSocket(url);
});
// 断开连接
disconnectBtn.addEventListener('click', function() {
if (socket) {
socket.close();
}
});
// 连接WebSocket函数
function connectWebSocket(url) {
// 如果已有连接,先关闭
if (socket) {
socket.close();
}
// 重置订阅状态
isSubscribedEvents = false;
isSubscribedChart = false;
updateSubscribeButtons();
// 清空数据显示
eventsData.textContent = '';
// 更新UI状态
updateStatus('连接中...', 'disconnected');
connectBtn.disabled = true;
disconnectBtn.disabled = false;
// 创建WebSocket连接
socket = new WebSocket(url);
// 连接打开事件
socket.onopen = function() {
updateStatus('已连接 (未认证)', 'connected');
logMessage('系统', 'WebSocket连接已建立', 'system');
// 如果有认证消息,自动发送
if (authMessage.value.trim()) {
try {
const authMsg = JSON.parse(authMessage.value);
if (authMsg.type === "AUTH") {
socket.send(authMessage.value);
logMessage('发送', authMessage.value, 'outgoing');
}
} catch (e) {
logMessage('错误', '认证消息格式错误: ' + e.message, 'error');
}
}
};
// 收到消息事件
socket.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
logMessage('接收', JSON.stringify(data, null, 2), 'incoming');
// 检查认证状态
if (data.status === "SUCCESS" && data.message === "认证成功") {
isAuthenticated = true;
updateStatus('已连接 (已认证)', 'connected');
updateSubscribeButtons();
}
// 处理推送数据
if (data.topic === "OVERVIEW") {
// 概览数据
eventsData.textContent = JSON.stringify(data, null, 2);
eventsDataDisplay.style.display = 'block';
}
} catch (e) {
logMessage('接收', event.data, 'incoming');
}
};
// 错误事件
socket.onerror = function(error) {
updateStatus('连接错误', 'disconnected');
logMessage('错误', 'WebSocket错误: ' + (error.message || '未知错误'), 'error');
connectBtn.disabled = false;
disconnectBtn.disabled = true;
updateSubscribeButtons();
};
// 连接关闭事件
socket.onclose = function() {
updateStatus('已断开', 'disconnected');
logMessage('系统', 'WebSocket连接已关闭', 'system');
connectBtn.disabled = false;
disconnectBtn.disabled = true;
isAuthenticated = false;
socket = null;
updateSubscribeButtons();
// 重置订阅状态
isSubscribedEvents = false;
isSubscribedChart = false;
// 清空数据显示
eventsData.textContent = '';
};
}
// 更新订阅按钮状态
function updateSubscribeButtons() {
if (!socket || socket.readyState !== WebSocket.OPEN || !isAuthenticated) {
subscribeEventsBtn.disabled = true;
unsubscribeBtn.disabled = true;
return;
}
subscribeEventsBtn.disabled = isSubscribedEvents;
unsubscribeBtn.disabled = !(isSubscribedEvents || isSubscribedChart);
}
// 更新状态显示
function updateStatus(text, className) {
statusDiv.textContent = text;
statusDiv.className = 'status ' + className;
}
// 记录消息到日志
function logMessage(type, content, messageClass) {
const now = new Date();
const timestamp = now.toLocaleTimeString() + '.' + now.getMilliseconds().toString().padStart(3, '0');
const messageDiv = document.createElement('div');
messageDiv.className = 'message ' + messageClass;
const headerDiv = document.createElement('div');
headerDiv.innerHTML = `<strong>${type}</strong> <span class="timestamp">${timestamp}</span>`;
const contentDiv = document.createElement('div');
contentDiv.textContent = content;
messageDiv.appendChild(headerDiv);
messageDiv.appendChild(contentDiv);
messageLog.appendChild(messageDiv);
messageLog.scrollTop = messageLog.scrollHeight;
}
});
</script>
</body>
</html>
下面是实际执行效果

目前存在的问题
分布式环境下websocket存在连接与服务器绑定问题