业务场景
背景:有多个企业,企业下有很多用户,每个用户有自己的多个正在处理的文档
需求:每个打开浏览器的用户,可以实时查看到自己正在处理文档的实时进度
思路:接收企业id、用户id、需要推送的文档id列表,然后启用后台线程进行实时推送,全部处理完毕后服务端主动关闭socket连接
什么是WebSocket?
WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。
WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。
开启WebSocket支持
pom依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
添加如下配置类
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
如果不是starter的依赖,那么需要在启动类上添加@EnableWebSocket
注解
WebSocket简单使用示例
@ServerEndpoint(value = "/websocket/document/{enterpriseId}/{userId}")
public class WebSocketServer {
/**
* 连接时触发
*/
@OnOpen
public void onOpen(@PathParam("enterpriseId") Long enterpriseId, @PathParam("userId") Long userId, Session session) {
}
/**
* 收到客户端消息时触发
*/
@OnMessage
public void onMessage(@PathParam("enterpriseId") Long enterpriseId, @PathParam("userId") Long userId, Session session, String message) {
// 发送指定消息
session.getBasicRemote().sendText("xxx");
}
/**
* 异常时触发
*/
@OnError
public void onError(@PathParam("enterpriseId") Long enterpriseId, @PathParam("userId") Long userId, Session session, Throwable throwable) {
}
/**
* 关闭连接时触发
*/
@OnClose
public void onClose(@PathParam("enterpriseId") Long enterpriseId, @PathParam("userId") Long userId, Session session) {
}
}
注意事项1:自动注入失效问题
现象:
- 使用
@Autowired
对@ServerEndpoint
标注的类的属性进行注入时,显示为null
原因:
- 每建立一个WebSocket链接,都会产生一个新的对象,也就是被@ServerEndpoint修饰的对象。
- 在spring容器的角度来看,WebSocket是一个多例的对象而非单例。
解决方案: 给类属性注入,实现单例的效果,如下:
@Slf4j
@Component
@ServerEndpoint(value = "/websocket/document/{enterpriseId}/{userId}")
public class WebSocketServer {
private static RedisTemplate<String, Object> redisTemplate;
private static DocumentTransService documentTransService;
private static ThreadPoolTaskExecutor executor;
@Autowired
public void setField(RedisTemplate<String, Object> redisTemplate,
DocumentTransService documentTransService,
@Qualifier("ws-pool") ThreadPoolTaskExecutor executor) {
WebSocketServer.redisTemplate = redisTemplate;
WebSocketServer.documentTransService = documentTransService;
WebSocketServer.executor = executor;
}
}
注意事项2:关闭socket连接时频繁出现IOException异常
现象:
- 当前端快速建立并关闭socket连接时,后端会触发异常
java.io.IOException: 你的主机中的软件中止了一个已建立的连接
原因:
-
websocket使用了nio,在建立连接、接收消息、触发异常、关闭连接时都是新的线程,但是使用的是同一个
Seesion
,可以查看如下log2023-03-01 13:43:04.353 INFO 29700 --- [nio-8081-exec-5] : client已连接 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 开始广播 2023-03-01 13:43:05.794 INFO 29700 --- [io-8081-exec-10] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:43:05.953 INFO 29700 --- [nio-8081-exec-7] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:43:06.086 INFO 29700 --- [nio-8081-exec-8] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:43:06.247 INFO 29700 --- [nio-8081-exec-2] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:43:06.358 INFO 29700 --- [nio-8081-exec-1] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:43:06.501 INFO 29700 --- [nio-8081-exec-3] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:43:06.624 INFO 29700 --- [nio-8081-exec-6] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:43:06.766 INFO 29700 --- [nio-8081-exec-5] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:43:06.933 INFO 29700 --- [nio-8081-exec-9] : client接收到信息 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13 message=aaa 2023-03-01 13:44:29.906 INFO 29700 --- [nio-8081-exec-2] : client连接关闭 userId=1 session=org.apache.tomcat.websocket.WsSession@70c06a13
-
如果用户在建立连接时,线程没有释放,而是使用建立连接的线程来发送消息,那么在关闭连接时便会触发io异常
也就是说,在建立连接时,如果使用了上述的线程[nio-8081-exec-5]来while(true)给前台推送消息,那么建立连接的线程便不会释放,在关闭socket连接时便会触发io异常
解决方案: 在建立连接时,使用后台线程来推送消息,让@onOpen
所在的线程正常释放即可
-
创建一个
WebSocketSender
类实现Runnable
:@Slf4j public class WebSocketSender implements Runnable { private static final CloseReason reason = new CloseReason(CloseReason.CloseCodes.GOING_AWAY, "全部推送完毕,服务端主动关闭socket连接"); private final AtomicBoolean atomicBoolean = new AtomicBoolean(true); private final Session session; private final Long enterpriseId; private final Long userId; public WebSocketSender(Session session, Long enterpriseId, Long userId) { this.session = session; this.enterpriseId = enterpriseId; this.userId = userId; } public void stop() { atomicBoolean.set(false); if (session.isOpen()) { try { session.close(reason); } catch (IOException e) { e.printStackTrace(); } } } @Override public void run() { log.info("send ws...userId={} session={}", userId, session); int i = 0; while (atomicBoolean.get()) { if (!atomicBoolean.get()) { break; } if (session.isOpen()) { try { session.getBasicRemote().sendText("xxx"); } catch (IOException e) { e.printStackTrace(); } } i++; // 服务端主动关闭socket if (i == 10) stop(); } } }
-
创建一个
WebSocketUtil
工具类,来管理WebSocketSender
:@Slf4j public class WebSocketUtil { private static final Map<Session, WebSocketSender> senders = new ConcurrentHashMap<>(); public static void putSender(Session session, WebSocketSender webSocketSender) { senders.putIfAbsent(session, webSocketSender); } public static void removeAndCloseSender(Session session) { final WebSocketSender sender = senders.remove(session); if (sender != null) sender.stop(); }
-
在建立连接时,创建
WebSocketSender
,在异常或者关闭时,移除缓存中的WebSocketSender
:/** * 连接时触发 */ @OnOpen public void onOpen(@PathParam("enterpriseId") Long enterpriseId, @PathParam("userId") Long userId, Session session) { session.setMaxIdleTimeout(1000 * 60 * 30); log.info("client已连接 userId={} enterpriseId={} session={} 开始广播", userId, enterpriseId, session); WebSocketSender webSocketSender = new WebSocketSender(session, enterpriseId, userId); WebSocketUtil.putSender(session, sender); executor.execute(webSocketSender); } /** * 异常时触发 */ @OnError public void onError(@PathParam("enterpriseId") Long enterpriseId, @PathParam("userId") Long userId, Session session, Throwable throwable) { log.error("client连接异常 userId={} enterpriseId={} session={} message={}", userId, enterpriseId, session, throwable.getMessage()); WebSocketUtil.removeAndCloseSender(session); throwable.printStackTrace(); } /** * 关闭连接时触发 */ @OnClose public void onClose(@PathParam("enterpriseId") Long enterpriseId, @PathParam("userId") Long userId, Session session) { WebSocketUtil.removeAndCloseSender(session); log.info("client连接关闭 userId={} enterpriseId={} session={}", userId, enterpriseId, session); }
注意事项3:如何接收url站位和url传参
-
接收url站位参数,通过@PathParam注释获取
@ServerEndpoint(value = "/websocket/document/{enterpriseId}/{userId}") public class WebSocketServer { @OnOpen public void onOpen(@PathParam("enterpriseId") Long enterpriseId, @PathParam("userId") Long userId, Session session) { } }
-
url传参,通过session.getQueryString()方法获取:
比如在建立websocket连接时想获取一个id列表,在url后拼接即可,然后使用
session.getQueryString()
方法即可获取,可以参考下面的getDocumentIdSet()方法ws://192.168.111.67:65000/document-webSocket/websocket/document/1/1?idList=307,308,309,111
public static Set<Long> getDocumentIdSet(Session session) { final String queryString = session.getQueryString(); if (queryString==null||!queryString.contains("=")) return Collections.emptySet(); final String[] strings = queryString.split("="); if (strings.length != 2) return Collections.emptySet(); final String[] ids = strings[1].split(","); if (ids.length == 0) return Collections.emptySet(); return Arrays.stream(ids).map(Long::parseLong).collect(Collectors.toSet()); }
注意事项4:如何接收头信息
查阅这篇文章即可:springboot如何获取websocket的header头信息
注意事项5:使用状态码主动关闭websocket
关闭状态码 | 简称 | 原因 |
---|---|---|
1000 | 正常关闭 | 连接成功地完成了创建它的目的。 |
1001 | 离开 | 端点消失了,可能是因为服务器故障,也可能是因为浏览器离开了打开连接的页面。 |
1002 | 协议错误 | 由于协议错误,端点正在终止连接。 |
1003 | 不支持的数据 | 由于端点接收到的数据类型无法接受,连接被终止。(例如,纯文本端点接收二进制数据 |
1004 | 暂时保留 | 保留。将来可能会定义一个含义。 |
1005 | 无状态接收 | 保留。指示未提供状态代码,即使需要状态代码 |
1006 | 异常关闭 | 保留。指示当需要状态代码时,连接异常关闭(即未发送关闭帧) |
1007 | 无效的帧负载数据 | 端点正在终止连接,因为收到的消息包含不一致的数据(例如,文本消息中的非UTF-8数据) |
1008 | 策略冲突 | 端点正在终止连接,因为它收到了违反其策略的消息。这是一个通用状态代码,在代码1003和1009不适用时使用 |
1009 | 消息太大 | 端点正在终止连接,因为接收到的数据帧太大 |
1010 | 强制扩展 | 客户端正在终止连接,因为它希望服务器协商一个或多个扩展,但服务器没有协商 |
1011 | 内部错误 | 服务器正在终止连接,因为它遇到意外情况,无法完成请求 |
1012 | 服务重新启动 | 服务器正在终止连接,因为它正在重新启动 |
1013 | 稍后重试 | 由于临时情况,服务器正在终止连接,例如,它过载,并且正在丢弃某些客户端 |
1014 | 坏网关 | 服务器充当网关或代理,并从上游服务器收到无效响应。这类似于502 HTTP状态代码 |
1015 | TLS握手 | 保留。表示由于无法执行TLS握手(例如,无法验证服务器证书),连接已关闭 |
1016–2999 | 用于通过WebSocket协议规范的未来版本进行定义,以及通过扩展规范进行定义 | |
3000–3999 | 供库、框架和应用程序使用。这些状态代码直接向IANA注册。WebSocket协议未定义这些代码的解释 | |
4000–4999 | 仅供私人使用,因此无法注册。WebSocket应用程序之间的先前协议可以使用这些代码。WebSocket协议未定义这些代码的解释 |