SpringBoot之WebSocket

标签: 无 分类: 未分类 创建时间:2024-06-29 09:15:03 更新时间:2025-01-17 10:39:22

1.前言

最近在弄这个 大疆机场 的东西,需要用到了 websocket 进行通讯,于是就在源码的基础上研究了下这个 websocket 的东西。

WebSocket 是一种基于 TCP 协议的全双工通信协议,它允许客户端和服务器之间建立持久的、双向的通信连接。相比传统的 HTTP 请求 - 响应模式,WebSocket 提供了实时、低延迟的数据传输能力。通过 WebSocket,客户端和服务器可以在任意时间点互相发送消息,实现实时更新和即时通信的功能。WebSocket 协议经过了多个浏览器和服务器的支持,成为了现代 Web 应用中常用的通信协议之一。它广泛应用于聊天应用、实时数据更新、多人游戏等场景,为 Web 应用提供了更好的用户体验和更高效的数据传输方式。

2.集成

集成的方式有很两种:

  • (1)是使用由 Jakarta EE 规范提供的 Api,也就是 jakarta.websocket 包下的接口。

  • (2)是使用 spring 提供的支持,也就是 spring-websocket 模块。前者是一种独立于框架的技术规范,而后者是 Spring 生态系统的一部分,可以与其他 Spring 模块(如 Spring MVC、Spring Security)无缝集成,共享其配置和功能。我觉得也就是 stomp 的方式,因为我看一些文章中提到了这个 stomp 的名称。

2.1.添加依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2.2.Jakarta EE 规范

(1) 开发 ServerEndpoint 端点
@ServerEndpoint:将目前的类定义成一个websocket服务器端,注解的值将被用于监听用户连接的终端访问URL地址,客户端可以通过这个URL来连接到WebSocket服务器端
@OnOpen:当WebSocket建立连接成功后会触发这个注解修饰的方法。
@OnClose:当WebSocket建立的连接断开后会触发这个注解修饰的方法。
@OnMessage:当客户端发送消息到服务端时,会触发这个注解修改的方法。
@OnError:当WebSocket建立连接时出现异常会触发这个注解修饰的方法。

(2) 配置 ServerEndpointExporter
定义好端点后,需要在配置类中通过定义 ServerEndpointExporter Bean 进行注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class WebSocketConfiguration {

@Bean
public ServerEndpointExporter serverEndpointExporter (){

ServerEndpointExporter exporter = new ServerEndpointExporter();

// 手动注册 WebSocket 端点
exporter.setAnnotatedEndpointClasses(EchoChannel.class);

return exporter;
}
}
参考文章:
【1】.在 Spring Boot 中整合、使用 WebSocket 1.添加依赖。2.开发 ServerEndpoint 端点。3.配置 ServerEndpointExporter。 这个就是上面的方法。
【2】.Using WebSocket to build an interactive web application
【3】.springBoot使用webSocket的几种方式以及在高并发出现的问题及解决 1.第一种方式-原生注解(tomcat内嵌);2.第二种方式-Spring封装;3.第三种方式-TIO;4.第四种方式-STOMP。

2.3.spring-websocket方式

(1) 配置 WebSocket 配置类

  • @EnableWebSocketMessageBroker:用于开启stomp协议,这样就能支持@MessageMapping注解,类似于@requestMapping一样,同时前端可以使用Stomp客户端进行通讯;

  • registerStompEndpoints实现:主要用来注册端点地址、开启跨域授权、增加拦截器、声明SockJS,这也是前端选择SockJS的原因,因为spring项目本身就支持;

  • configureMessageBroker实现:主要用来设置客户端订阅消息的路径(可以多个)、点对点订阅路径前缀的设置、访问服务端@MessageMapping接口的前缀路径、心跳设置等;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Configuration
@EnableWebSocketMessageBroker
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {

/**
* 注册stomp端点
*
* @param registry stomp端点注册对象
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint(WsConstants.WEBSOCKET_PATH)
.setAllowedOrigins("*")
.withSockJS();
}

/**
* 配置消息代理
*
* @param registry 消息代理注册对象
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {

// 配置服务端推送消息给客户端的代理路径
registry.enableSimpleBroker(WsConstants.BROKER.BROKER_QUEUE, WsConstants.BROKER.BROKER_TOPIC);

// 定义点对点推送时的前缀为/queue
registry.setUserDestinationPrefix(WsConstants.BROKER.BROKER_QUEUE);

// 定义客户端访问服务端消息接口时的前缀
registry.setApplicationDestinationPrefixes(WsConstants.WS_PERFIX);
}
}

(2) 消息接口
消息接口使用@MessageMapping注解,前面讲的配置类@EnableWebSocketMessageBroker注解开启后才能使用这个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@RestController
@RequestMapping("/api")
@Slf4j
public class MsgController {

private final SimpMessagingTemplate messagingTemplate;

public MsgController(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}

/**
* 发送广播消息
* -- 说明:
* 1)、@MessageMapping注解对应客户端的stomp.send('url');
* 2)、用法一:要么配合@SendTo("转发的订阅路径"),去掉messagingTemplate,同时return msg来使用,return msg会去找@SendTo注解的路径;
* 3)、用法二:要么设置成void,使用messagingTemplate来控制转发的订阅路径,且不能return msg,个人推荐这种。
*
* @param msg 消息
*/
@MessageMapping("/send")
public void sendAll(@RequestParam String msg) {

log.info("[发送消息]>>>> msg: {}", msg);

// 发送消息给客户端
messagingTemplate.convertAndSend(WsConstants.BROKER.BROKER_TOPIC, msg);
}
}
参考文章:
【1】.【Java分享客栈】SpringBoot整合WebSocket+Stomp搭建群聊项目 这个也是上面的方法
【2】.Spring Boot 中的 STOMP 是什么,原理,如何使用 在 Spring Boot 中,STOMP 是一种简单的文本协议,用于在客户端和服务器之间进行实时消息传递。它是 WebSocket 协议的一种扩展,可以在 WebSocket 上运行。STOMP(Simple Text Oriented Messaging Protocol)是一种简单的文本协议,用于在客户端和服务器之间进行实时消息传递。它是一种基于文本的协议,易于理解和使用。STOMP 是 WebSocket 协议的一种扩展,可以在 WebSocket 上运行。在 Spring Boot 中,STOMP 是通过 Spring WebSocket 模块来实现的。在 Spring Boot 中,STOMP 是通过 WebSocket 进行实现的。WebSocket 是一种双向通信协议,可以在客户端和服务器之间建立持久化的连接。在 WebSocket 连接建立后,客户端和服务器之间可以通过发送和接收消息来进行实时通信。
【3】.Springboot 整合 WebSocket ,使用STOMP协议 ,前后端整合实战 (一) 1.后端整合websocket (STOMP协议);2.群发、指定单发;3.前端简单页面示例(接收、发送消息),前端页面也用了 socketjs。

3.单点推送

单点推送的含义就是说只针对某些应用进行推送,其他的一些应用不进行推动,也就是 一对一 的进行推送。

4.认证

有两种认证授权的方式,一种是使用连接地址,请求地址中带参数;一种是使用协议头.

4.1.基于请求地址

基于请求头的认证,就是将 token 携带到 websocket 的请求 url 中
(1)前端代码

1
2
3
4
5
6
7
import { getToken } from '/@/utils/auth';
const websocketURL = import.meta.env.VITE_WEBSOCKET_URL;
export function getWebsocketUrl() {
const token: string = getToken() || ('' as string);
const url = websocketURL + '?token=' + encodeURI(token);
return url;
}

(2)后端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Component
public class AuthInterceptor implements HandlerInterceptor {

public static final String PARAM_TOKEN = "x-auth-token";

public static final String TOKEN_CLAIM = "customClaim";

// @Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
log.debug("request uri: {}, IP: {}", uri, request.getRemoteAddr());
// The options method is passed directly.
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
response.setStatus(HttpStatus.OK.value());
return false;
}
String token = request.getHeader(PARAM_TOKEN);
// Check if the token exists.
if (!StringUtils.hasText(token)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
log.error(CommonErrorEnum.NO_TOKEN.getMessage());
return false;
}

// Check if the current token is valid.
Optional<CustomClaim> customClaimOpt = JwtUtil.parseToken(token);
if (customClaimOpt.isEmpty()) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return false;
}

// Put the custom data from the token into the request.
request.setAttribute(TOKEN_CLAIM, customClaimOpt.get());
return true;
}

// @Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
// Delete the custom data in the request after the request ends.
request.removeAttribute(TOKEN_CLAIM);
}
}

4.2.基于协议头的方式

基于协议头的方式,那就是讲认证信息放到了 header 的 Sec-WebSocket-Protocol 中。
(1)前端代码

1
2
3
4
5
6
const token = (getToken() || '') as string;
this._socket = new ReconnectingWebSocket(this._url, [token], {
maxReconnectionDelay: 20000, // 断开后最大的重连时间: 20s,每多一次重连,会增加 1.3 倍,5 * 1.3 * 1.3 * 1.3...
minReconnectionDelay: 5000, // 断开后最短的重连时间: 5s
maxRetries: 5,
});

(2)后端代码
如果基于协议头传递了参数,后端响应的时候header也需要带上协议头参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Slf4j
public class WebsocketFilter implements Filter {

private static final String TOKEN_KEY = "Sec-WebSocket-Protocol";

private static CommonAPI commonApi;

private static RedisUtil redisUtil;

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
if (commonApi == null) {
commonApi = SpringContextUtils.getBean(CommonAPI.class);
}
if (redisUtil == null) {
redisUtil = SpringContextUtils.getBean(RedisUtil.class);
}
HttpServletRequest request = (HttpServletRequest)servletRequest;
String token = request.getHeader(TOKEN_KEY);

log.debug("Websocket连接 Token安全校验,Path = {},token:{}", request.getRequestURI(), token);

try {
TokenUtils.verifyToken(token, commonApi, redisUtil);
} catch (Exception exception) {
//log.error("Websocket连接 Token安全校验失败,IP:{}, Token:{}, Path = {},异常:{}", oConvertUtils.getIpAddrByRequest(request), token, request.getRequestURI(), exception.getMessage());
log.debug("Websocket连接 Token安全校验失败,IP:{}, Token:{}, Path = {},异常:{}", oConvertUtils.getIpAddrByRequest(request), token, request.getRequestURI(), exception.getMessage());
return;
}

// 带上协议头参数
HttpServletResponse response = (HttpServletResponse)servletResponse;
response.setHeader(TOKEN_KEY, token);
filterChain.doFilter(servletRequest, servletResponse);
}

}
参考文章:
【1】.headers.put(“token”
【2】.Spring中的Websocket身份验证和授权
【3】.springboot+websocket认证授权思路 创建自定义拦截器,实现HandshakeInterceptor接口
【4】.websocket-js连接如何携带token验证 1.请求地址中带参数;2.基于协议头:websocket请求头中可以包含Sec-WebSocket-Protocol这个属性,该属性是一个自定义的子协议。它从客户端发送到服务器并返回从服务器到客户端确认子协议。我们可以利用这个属性添加token。如果基于协议头传递了参数,后端响应的时候header也需要带上协议头参数。
【5】.WebSocket的集成 JEECG BOOT 增加websocket 旨在服务端主动向客户端推送数据,实现系统向在线用户推送消息,可群发,可对指定用户发送
【6】.websocket增加鉴权整合springboot 这里基于协议的授权,就是在 header 中增加了一个 Sec-WebSocket-Protocol 头。

5.wss

除了使用 nginx 的配置,在测试的时候,如何启动 wss 呢?
在我本地测试 https 或者 wss,就需要先配置,不用通过 nginx 等工具。
(1) 生成签名

1
keytool -genkeypair -alias tomcat -keyalg RSA -keystore D:\keystore.jks

(2) 配置

1
2
3
4
5
6
7
8
9
server:
port: 9099
# 这里有问题,我这么配置总是找不到文件
ssl:
enabled: true
key-store: D:\zlc\drone\resources\keystore.jks
key-store-password: 123456
key-password: 123456
key-alias: tomcat

问题

1.SpringCloudAlibaba 应用webSocket,解决连接成功后会立刻断开等问题

【尝试方案】
(1)我尝试了修改配置,去掉认证,无效

(2)尝试了连接 jeecgboot 的websocket ,结果不行

(3)认证的时候携带了 token, 也绕过了 shiro,不行

(4)尝试找出错误,但是连报错都没有,什么原因都不知道

(5)去掉了 registry.addEndpoint(“/dronews”).setAllowedOriginPatterns(“*”).setHandshakeHandler(handshakeHandler) 中的 handshakeHandler 问题。

(6)后来发现了有新的错误提示了: Closing session due to exception for StandardWebSocketSession[id=9374f3ce-119f-693c-a327-a730435bc74d, uri=ws://localhost:8080/hjkj/dronews]java.lang.NullPointerException: Cannot invoke “java.security.Principal.getName()” because “principal” is null

(7)后来我在 在线测试 上测试我本地的 websocket 地址,好像是可以的,可以直连,不断开。那么问题就出在了我的客户端上了。

【解决方案】
经过我上面的不断的尝试,最后终于找到了原因,那就是是我的配置问题。我本来用了 websocket 的 token 的方式进行,但是我也同时用了协议的方式,增加了 [token], 结果就出现了问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 错误配置
const token = (getToken() || '') as string;
this._socket = new ReconnectingWebSocket(this._url, [token], {
maxReconnectionDelay: 20000, // 断开后最大的重连时间: 20s,每多一次重连,会增加 1.3 倍,5 * 1.3 * 1.3 * 1.3...
minReconnectionDelay: 5000, // 断开后最短的重连时间: 5s
maxRetries: 5,
});

// 正确配置
this._socket = new ReconnectingWebSocket(this._url, [], {
maxReconnectionDelay: 20000, // 断开后最大的重连时间: 20s,每多一次重连,会增加 1.3 倍,5 * 1.3 * 1.3 * 1.3...
minReconnectionDelay: 5000, // 断开后最短的重连时间: 5s
maxRetries: 5,
});
参考文章:
【1】.SpringCloudAlibaba 应用webSocket,解决连接成功后会立刻断开等问题 权限认真把子路径也要加上去
【2】.【解决】websocket ws连不上或无法连接 因为我写的是@ServerEndpoint的方式。需要注入ServerEndpointExporter,这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint。要注意,如果使用独立的servlet容器,而不是直接使用springboot的内置容器,就不要注入ServerEndpointExporter,因为它将由容器自己提供和管理。
【3】.Websocket 总是断开重连
【4】.SpringBoot集成websocket时出现的异常断开问题
【5】.java websocket 断线_WebSocket断开原因分析,再也不怕为什么又断开了 这里写了状态码,也就是断开的原因
【6】.WebSocket连接失败的原因与解决方案
【7】.java springboot websocket 服务 服务器主动关闭连接 导致 抛出java.io.EOFException异常
【8】.【websocket】spring boot 集成 websocket 的四种方式 1. 原生注解;2. Spring封装;3. TIO;4.STOMP
【9】.WebSocket之Sec-WebSocket-Protocol (带token发起连接) protocols对应的就是发起ws连接时, 携带在请求头中的Sec-WebSocket-Protocol属性, 服务端可以获取到此属性的值用于通信逻辑(即通信子协议,当然用来进行token认证也是完全没问题的)

2.连接总是断开

本地测试没有问题,使用nginx部署之后,配置了 wss,结果总是断开重连的情况。

【尝试方案】
(1)我检查了我的websocket链接,服务端打印了,已经能够建立连接了,但是最后还是没有连接上。
(2)我尝试输出了断开的错误码,结果显示 1006,还是服务器关闭了,到底为什么关闭了,我不晓得,除了配置文件不同之外,其他的没有什么不同的啊,我已经反反复复的检查了相关的配置。
(3)尝试测试prod这个配置文件,结果在本地的时候,这个配置文件没有问题,可以正常启动
(4)尝试将 yml 配置文件包含到主模块中,这个也没有用,我看了jar文件,打包后到模块里面包含了子模块的配置文件
(5)尝试通过frp将远程端口引导到本地开发环境进行测试,结果显示开始能建立连接,但是过后就直接断开了
(6)我尝试将 https 转成了 http,结果也还是不行
(7)我尝试直接通过 ip+端口 的方式,直接访问 websocket 服务,不通过nginx。刚开始总是出现各种莫名其妙的问题,还是不行,后来我在本地测试的时候,解决了各种个样的问题,最后到也还是能连通了。现在最大的问题,就是落在了那个 nginx 配置上面。
(8)尝试修改nginx配置,对着网上的东西,一点点的对,一点点的对。
(9)我一直以为是我配置了rtmp之后,导致的问题,后来发现去掉之后,还是不行。

【总结问题】
经过长时间的测试,总结
(1)使用 http 协议,ws 协议,不通过nginx代理,直接访问应用程序暴漏的端口,可以建立 websocket 连接。
(2)同样使用 http 协议,ws 协议,通过nginx代理后,访问应用程序暴漏的端口,结果就无法建立 websocket 连接。
(3)上面两部的 应用程序都是同一个,端口也都相同,没有修改,既然出现这样的情况,那么就有可能是 nginx 配置不正确。

【解决方案】
经过我长达一天的努力解决,最后竟然发现问题,修改前的nginx配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
location /hjkj/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Headers *;
add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
#add_header Access-Control-Allow-Credentials true;
return 200;
}

## add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Headers *;

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
## 保持长时间连接
proxy_read_timeout 3600s;

proxy_set_header Host $host;#保留代理之前的host
proxy_set_header X-Real-IP $remote_addr;#保留代理之前的真实客户端ip
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header HTTP_X_FORWARDED_FOR $remote_addr;#在多级代理的情况下,记录每次代理之前的客户端真实ip
proxy_set_header X-Forwarded-Proto $scheme; #表示客户端真实的协议(http还是https)
proxy_set_header Referer $scheme://$server_name$request_uri;

# 本地
# proxy_pass http://localhost:9099/hjkj/;
# 内网
proxy_pass http://localhost:10022/hjkj/;

}

修改后的 nginx如下,内容其实差不多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
location /hjkj/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Headers *;
add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
#add_header Access-Control-Allow-Credentials true;
return 200;
}

add_header Access-Control-Allow-Origin $http_origin;
#add_header Access-Control-Allow-Headers *;

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
## 保持长时间连接
proxy_read_timeout 3600s;
# 主要加了这个配置
proxy_http_version 1.1;
proxy_set_header Host $host;#保留代理之前的host
# 内网
proxy_pass http://localhost:10022/hjkj/;

}

总结,就是非常的扯淡,我足足浪费了一天的时间,但是解决问题的过程却特别的曲折,真是令人难过。

参考文章:
【1】.webSocket配置wss访问
【2】.WebSocket协议状态码1006:探索与解析 状态码1006表示连接已关闭,无法建立连接。具体含义如下:1.当客户端或服务器在建立WebSocket连接时发生异常,连接将被关闭,状态码1006将被返回。2.通常情况下,状态码1006表示网络连接中断、服务器宕机或其他无法建立连接的错误。与其他状态码相比,状态码1006的特点是无法建立连接,通常是由于网络问题或服务器故障引起的。解决状态码1006的常见方法包括:检查网络连接是否正常、检查服务器是否正常运行、检查服务器配置是否正确。
【3】.Nginx代理webSocket经常中断的解决方案, 如何保持长连接 nginx等待你 第一次通讯和第二次通讯的时间差,超过了它设定的最大等待时间,简单来说就是,超时,所以就啪的一声断了,开始上解决步骤。
【4】.Nginx配置WebSocket反向代理 这里我照着一点点的配置,最后还是找到了问题的所在

3.1015问题

在我使用 springboot 配置了 https 之后,由 ws 改为 wss,结果测试的时候,总是出现:1015 TLS_HANDSHAKE

参考文章:
【1】.websocket在线测试
小额赞助
本人提供免费与付费咨询服务,感谢您的支持!赞助请发邮件通知,方便公布您的善意!
**光 3.01 元
Sun 3.00 元
bibichuan 3.00 元
微信公众号
广告位
诚心邀请广大金主爸爸洽谈合作
每日一省
isNaN 和 Number.isNaN 函数的区别?

1.函数 isNaN 接收参数后,会尝试将这个参数转换为数值,任何不能被转换为数值的的值都会返回 true,因此非数字值传入也会返回 true ,会影响 NaN 的判断。

2.函数 Number.isNaN 会首先判断传入参数是否为数字,如果是数字再继续判断是否为 NaN ,不会进行数据类型的转换,这种方法对于 NaN 的判断更为准确。

每日二省
为什么0.1+0.2 ! == 0.3,如何让其相等?

一个直接的解决方法就是设置一个误差范围,通常称为“机器精度”。对JavaScript来说,这个值通常为2-52,在ES6中,提供了Number.EPSILON属性,而它的值就是2-52,只要判断0.1+0.2-0.3是否小于Number.EPSILON,如果小于,就可以判断为0.1+0.2 ===0.3。

每日三省
== 操作符的强制类型转换规则?

1.首先会判断两者类型是否**相同,**相同的话就比较两者的大小。

2.类型不相同的话,就会进行类型转换。

3.会先判断是否在对比 null 和 undefined,是的话就会返回 true。

4.判断两者类型是否为 string 和 number,是的话就会将字符串转换为 number。

5.判断其中一方是否为 boolean,是的话就会把 boolean 转为 number 再进行判断。

6.判断其中一方是否为 object 且另一方为 string、number 或者 symbol,是的话就会把 object 转为原始类型再进行判断。

每日英语
Happiness is time precipitation, smile is the lonely sad.
幸福是年华的沉淀,微笑是寂寞的悲伤。