Spring Cloud WebSocket 秒断问题排查与解决:网关子协议不一致导致
目录
前言
在微服务架构项目中,我遇到了一个关于 WebSocket 技术应用的难题。本文旨在分享我遇到的 WebSocket 连接 “秒断” 问题,以及如何通过分析和调试解决它。作为一名后端开发者,我承认自己对源码的理解还不够深入,日常开发中我常常依赖于“搜索引擎+AI”来解决问题。但是,当这些工具也无能为力时,我便需要深入源码、查阅资料、向社区求助。希望通过这篇文章,可以帮助遇到同样问题的开发者。
问题背景
在 Spring Cloud 框架下,我尝试使用 WebSocket 技术实现一个终端功能。由于涉及到实时交互,WebSocket 自然成为首选方案。起初,我通过搜索和复制粘贴(CV)代码,并进行了一些修改,快速搭建了服务。服务启动后,一切看起来正常。然而,我很快发现 WebSocket 连接建立后会立即断开,这就是我所说的“秒断”问题。
我尝试进行断点调试,深入源码分析,但复杂的代码逻辑让我感到困惑。由于项目进度紧张,我不得不采用一种临时方案:绕过网关,让前端直接连接后端的“终端服务”。令人惊讶的是,这种方式运行良好,没有任何问题。这让我确信问题出在网关上。
我使用的是 Spring Cloud Gateway 作为微服务的网关。当我尝试通过网关连接后端的 WebSocket 服务时,连接会立即断开。当我直接通过 IP 地址和端口,绕过网关连接后端的 WebSocket 服务时,连接则一切正常。这让我开始怀疑网关在处理 WebSocket 连接时,是否做了某些操作或更改,导致了连接的“秒断”? 如果大家有相关经验,欢迎分享解答。
代码展示
Maven 依赖
引入 spring-boot-starter-websocket
依赖即可使用 Spring Boot 提供的 WebSocket 功能。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
WebSocket 配置类
import com.ruoyi.terminal.webssh.socket.WebSocketHandler;
import com.ruoyi.terminal.webssh.socket.WebSocketInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
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 javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* WebSocket配置
*
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig extends ServerEndpointConfig.Configurator implements WebSocketConfigurer {
@Autowired
private WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ssh")
.addInterceptors(new WebSocketInterceptor())
.setAllowedOrigins("*");
registry
.addHandler(webSocketHandler, "/sock-ssh")
.addInterceptors(new WebSocketInterceptor())
.setAllowedOrigins("*")
// 开启sockJs支持
.withSockJS();
}
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
//获取websocket固定头
List<String> list = request.getHeaders().get(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL);
if (list != null) {
String s = list.get(0);
String[] split = s.split(",");
List<String> resList = new ArrayList<>();
resList.add(split[0]);
//返回的Sec-WebSocket-Protocol只需要从Sec-WebSocket-Protocol取第一个返回就行,否则会握手失败
response.getHeaders().put(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL, resList);
}
//头返回给前端
super.modifyHandshake(sec, request, response);
}
}
说明:
-
@EnableWebSocket: 启用 WebSocket 功能。
-
registerWebSocketHandlers: 注册 WebSocket 处理程序,并配置拦截器和允许的请求源。
- /ssh: 直接的 WebSocket 连接端点。
- /sock-ssh: 支持 SockJS 的 WebSocket 连接端点。
-
ServerEndpointExporter: 用于注册 @ServerEndpoint 注解的 Bean。
-
modifyHandshake: 用于修改握手响应头,解决一些兼容性问题(如 Sec-WebSocket-Protocol)。
WebSocket 处理器
import com.ruoyi.common.constant.Const;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
/**
* WebSocket 处理器
*
*/
@Component
public class WebSocketHandler implements org.springframework.web.socket.WebSocketHandler {
private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);
/**
* 用户连接之前的回调函数
*
* @param session WebSocket会话对象
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) {
logger.info("用户[ {} ]成功连接!", session.getAttributes().get(Const.SESSION_KEY));
}
/**
* 收到WebSocket消息
*
* @param session WebSocket会话对象
* @param message 接收到的消息
*/
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) {
if (message instanceof TextMessage) {
logger.info("用户[ {} ]发送命令: {}", session.getAttributes().get(Const.SESSION_KEY), message.getPayload());
}
}
/**
* 出现错误时的回调
*
* @param session WebSocket会话对象
* @param exception 错误信息
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
logger.error("用户[ {} ]出现错误", session.getAttributes().get(Const.SESSION_KEY), exception);
}
/**
* 断开连接后的回调
*
* @param session WebSocket会话对象
* @param closeStatus 关闭状态对象
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
logger.info("用户[ {} ]已断开连接", session.getAttributes().get(Const.SESSION_KEY));
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}
说明:
- WebSocketHandler: 用于处理 WebSocket 连接的生命周期事件和消息。
- afterConnectionEstablished: WebSocket 连接建立后触发,记录连接信息。
- handleMessage: 接收到客户端发送的消息时触发,处理消息。
- handleTransportError: WebSocket 连接发生错误时触发,记录错误信息。
- afterConnectionClosed: WebSocket 连接关闭后触发,记录断开连接信息。
- supportsPartialMessages: 设置是否支持部分消息,这里设置为 false 表示不支持。
WebSocket 拦截器
import com.ruoyi.common.constant.Const;
import com.ruoyi.common.utils.RandomUtil;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
/**
* WebSocket 拦截器
*
*/
@Component
public class WebSocketInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest) {
attributes.put(Const.SESSION_KEY, RandomUtil.randomString(16));
return true;
}
return false;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Exception exception) {
}
}
说明:
- HandshakeInterceptor: WebSocket 握手拦截器,用于在握手前后执行自定义逻辑。
- beforeHandshake: 在握手前执行,检查请求类型,并为每个 WebSocket 会话生成一个随机 Session ID 并存储到 attributes 中。
- afterHandshake: 在握手后执行,这里为空,未做任何处理。
问题分析 (续~)
在我们的项目中,秒断问题是因为前端在发送连接请求时,传递了子协议 Sec-WebSocket-Protocol,而后端在收到请求后会验证前后端子协议是否一致,不一致则断开连接,也就是标题的‘秒断’。
WebSocket 协议本身没有请求头,所以传参只能通过路径拼接,后端通过路径来获取参数;或者使用子协议 Sec-WebSocket-Protocol 来传递参数。我目前了解的只有这两种方式。
网关报错信息:
在网关的日志中,可以看到如下类似的报错信息(具体错误信息可能因网关配置而异):
2024-01-01 10:00:00.000 ERROR [reactor-http-nio-4] … WebSocketClientHandshakeException: Invalid subprotocol. Actual: null. Expected one of: protocol
Sec-WebSocket-Protocol 的作用:
Sec-WebSocket-Protocol 是 WebSocket 握手协议的一部分,用于指定 WebSocket 连接所使用的子协议。子协议允许在同一个 WebSocket 连接上进行多种不同的协议交互。如果在握手过程中,客户端和服务端指定的子协议不一致,连接就会被拒绝。
错误分析:
从上面的错误信息来看,网关在处理 WebSocket 连接时,没有正确处理 Sec-WebSocket-Protocol。Spring Cloud Gateway 作为中间层,会转发客户端的请求,如果客户端发送了 Sec-WebSocket-Protocol,而网关没有正确透传或处理,就会导致后端服务收到的 Sec-WebSocket-Protocol 与客户端发送的不一致,从而导致握手失败,连接被秒断。当客户端发送 Sec-WebSocket-Protocol 为 protocol 的时候,在经过网关之后到达服务端的 Sec-WebSocket-Protocol 头信息变为了 null, 导致握手失败。
解决方法
WebSocket 有两种常用的写法,对应的解决方法也略有不同:
方法一:原生注解(Tomcat 内嵌)
如果你使用 @ServerEndpoint 注解来实现 WebSocket 服务,并且使用了内嵌的 Tomcat 容器,可以通过以下方式配置子协议:
- @ServerEndpoint: 将当前的类定义成一个 WebSocket 服务器端点,注解的值将用于监听用户连接的终端访问 URL 地址,客户端可以通过这个 URL 来连接到 WebSocket 服务器端点。
- @OnOpen: 当 WebSocket 建立连接成功后会触发这个注解修饰的方法。
- @OnClose: 当 WebSocket 建立的连接断开后会触发这个注解修饰的方法。
- @OnMessage: 当客户端发送消息到服务端时,会触发这个注解修改的方法。
- @OnError: 当 WebSocket 建立连接时出现异常会触发这个注解修饰的方法。
只需在 WebSocketServer 类中的 @ServerEndpoint 注解中加入 subprotocols = {“protocol”} 配置子协议即可:
@Component
@ServerEndpoint(value = "/chat/{uid}", subprotocols = {"protocol"}, configurator = WebSocketConfig.class)
public class WebSocketServer {
// ...
}
subprotocols = {“protocol”} 中的 “protocol” 需要与前端传递的 Sec-WebSocket-Protocol 的值保持一致。
方法二:Spring 封装
如果你使用 Spring 提供的 WebSocketHandler 和 WebSocketConfigurer 来实现 WebSocket 服务,可以在 WebSocketConfig 配置类的 registerWebSocketHandlers 方法中设置子协议。
以下是修改后的 WebSocketConfig 类,其中使用了自定义的 SubProtocolHandshakeInterceptor 拦截器来处理子协议:
import com.ruoyi.terminal.webssh.socket.WebSocketHandler;
import com.ruoyi.terminal.webssh.socket.WebSocketInterceptor;
import com.ruoyi.terminal.webssh.config.SubProtocolHandshakeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
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 javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* WebSocket配置
*
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig extends ServerEndpointConfig.Configurator implements WebSocketConfigurer {
@Autowired
private WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ssh")
.addInterceptors(new WebSocketInterceptor())
.addInterceptors(new SubProtocolHandshakeInterceptor(Collections.singletonList("protocol")))
.setAllowedOrigins("*");
registry
.addHandler(webSocketHandler, "/sock-ssh")
.addInterceptors(new WebSocketInterceptor())
.setAllowedOrigins("*")
// 开启sockJs支持
.withSockJS();
}
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
//获取websocket固定头
List<String> list = request.getHeaders().get(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL);
if (list != null) {
String s = list.get(0);
String[] split = s.split(",");
List<String> resList = new ArrayList<>();
resList.add(split[0]);
//返回的Sec-WebSocket-Protocol只需要从Sec-WebSocket-Protocol取第一个返回就行,否则会握手失败
response.getHeaders().put(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL, resList);
}
//头返回给前端
super.modifyHandshake(sec, request, response);
}
}
我们通过 addInterceptors(new SubProtocolHandshakeInterceptor(Collections.singletonList(“protocol”))) 添加 SubProtocolHandshakeInterceptor ,用于处理 Sec-WebSocket-Protocol。 并且在 modifyHandshake 方法中,只取Sec-WebSocket-Protocol 的第一个协议返回,避免握手失败。
完整代码
WebSocketConfig
import com.ruoyi.terminal.webssh.socket.WebSocketHandler;
import com.ruoyi.terminal.webssh.socket.WebSocketInterceptor;
import com.ruoyi.terminal.webssh.config.SubProtocolHandshakeInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
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 javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* WebSocket配置
*
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig extends ServerEndpointConfig.Configurator implements WebSocketConfigurer {
@Autowired
private WebSocketHandler webSocketHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(webSocketHandler, "/ssh")
.addInterceptors(new WebSocketInterceptor())
.addInterceptors(new SubProtocolHandshakeInterceptor(Collections.singletonList("protocol")))
.setAllowedOrigins("*");
registry
.addHandler(webSocketHandler, "/sock-ssh")
.addInterceptors(new WebSocketInterceptor())
.setAllowedOrigins("*")
// 开启sockJs支持
.withSockJS();
}
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Override
public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
//获取websocket固定头
List<String> list = request.getHeaders().get(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL);
if (list != null) {
String s = list.get(0);
String[] split = s.split(",");
List<String> resList = new ArrayList<>();
resList.add(split[0]);
//返回的Sec-WebSocket-Protocol只需要从Sec-WebSocket-Protocol取第一个返回就行,否则会握手失败
response.getHeaders().put(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL, resList);
}
//头返回给前端
super.modifyHandshake(sec, request, response);
}
}
SubProtocolHandshakeInterceptor
package com.ruoyi.terminal.webssh.config;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import javax.websocket.server.HandshakeRequest;
import java.util.List;
import java.util.Map;
/**
* 子协议拦截器,用于处理websocket的Sec-WebSocket-Protocol
*/
public class SubProtocolHandshakeInterceptor implements HandshakeInterceptor {
private List<String> subProtocols;
/**
* 构造函数
* @param subProtocols 支持的子协议集合
*/
public SubProtocolHandshakeInterceptor(List<String> subProtocols) {
this.subProtocols = subProtocols;
}
/**
* 在握手之前执行, 这里不做任何处理, 主要逻辑在 `afterHandshake` 中实现
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
return true;
}
/**
* 在握手之后执行,用于处理子协议
* @param request 请求信息
* @param response 响应信息
* @param wsHandler websocket处理器
* @param exception 异常信息
*/
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
List<String> requestedProtocols = request.getHeaders().get(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL);
//如果请求头包含Sec-WebSocket-Protocol
if (requestedProtocols != null && !requestedProtocols.isEmpty()) {
// 遍历支持的子协议
for(String subProtocol: subProtocols){
// System.out.println(requestedProtocols.get(0));
// System.out.println(subProtocol);
// 如果请求头包含支持的子协议,则设置响应头,并结束循环
if(requestedProtocols.get(0).contains(subProtocol)){
response.getHeaders().set(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL, subProtocol);
return;
}
}
// 如果不支持,则移除 Sec-WebSocket-Protocol 响应头
// response.getHeaders().remove(HandshakeRequest.SEC_WEBSOCKET_PROTOCOL);
}
}
}
这段代码实现了 HandshakeInterceptor 接口,用于在 WebSocket 握手过程中处理 Sec-WebSocket-Protocol。
- beforeHandshake 方法: 在握手之前执行,这里不做任何处理,直接返回 true。
- afterHandshake 方法: 在握手之后执行,用于处理子协议。首先获取请求头中 Sec-WebSocket-Protocol 的值,如果请求头包含 Sec-WebSocket-Protocol, 则会遍历支持的子协议,如果请求头中包含支持的子协议,则设置响应头,并结束循环。如果不支持,则移除 Sec-WebSocket-Protocol 响应头。这里设置响应头后,就不会再报 Invalid subprotocol 错误了
WebSocketHandler
import com.ruoyi.common.constant.Const;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
/**
* WebSocket 处理器
*
*/
@Component
public class WebSocketHandler implements org.springframework.web.socket.WebSocketHandler {
private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);
/**
* 用户连接之前的回调函数
*
* @param session WebSocket会话对象
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) {
logger.info("用户[ {} ]成功连接!", session.getAttributes().get(Const.SESSION_KEY));
}
/**
* 收到WebSocket消息
*
* @param session WebSocket会话对象
* @param message 接收到的消息
*/
@Override
public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) {
if (message instanceof TextMessage) {
logger.info("用户[ {} ]发送命令: {}", session.getAttributes().get(Const.SESSION_KEY), message.getPayload());
}
}
/**
* 出现错误时的回调
*
* @param session WebSocket会话对象
* @param exception 错误信息
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) {
logger.error("用户[ {} ]出现错误", session.getAttributes().get(Const.SESSION_KEY), exception);
}
/**
* 断开连接后的回调
*
* @param session WebSocket会话对象
* @param closeStatus 关闭状态对象
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
logger.info("用户[ {} ]已断开连接", session.getAttributes().get(Const.SESSION_KEY));
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}
这段代码实现了 org.springframework.web.socket.WebSocketHandler 接口,用于处理 WebSocket 连接的生命周期事件和消息。
- afterConnectionEstablished: WebSocket 连接建立后触发,记录连接信息。
- handleMessage: 接收到客户端发送的消息时触发,处理消息。
- handleTransportError: WebSocket 连接发生错误时触发,记录错误信息。
- afterConnectionClosed: WebSocket 连接关闭后触发,记录断开连接信息。
- supportsPartialMessages: 设置是否支持部分消息,这里设置为 false 表示不支持。
总结
通过本次问题排查,我深入理解了 WebSocket 子协议的作用,以及网关在 WebSocket 连接中的角色。为了避免类似问题再次发生,总结以下几点:
- 确保前端和后端 WebSocket 的子协议一致,这是解决 “秒断” 问题的关键。
- 理解 Spring Cloud Gateway 等网关在处理 WebSocket 连接时可能会对 Sec-WebSocket-Protocol 进行处理,从而导致握手失败。
- 在开发过程中,要充分利用浏览器开发工具和日志信息来排查问题。
- 对于 Spring Boot 提供的 WebSocket 支持,可以通过 SubProtocolHandshakeInterceptor 等方式来处理子协议。
- 如果使用了 ServerEndpoint 原生注解,需要添加 subprotocols 属性
希望本文能够帮助遇到类似问题的开发者。欢迎大家在评论区留言交流,共同进步!