springboot连接socket实现实时推送以及注意事项

业务场景

背景:有多个企业,企业下有很多用户,每个用户有自己的多个正在处理的文档
需求:每个打开浏览器的用户,可以实时查看到自己正在处理文档的实时进度
思路:接收企业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

原因:

  1. 每建立一个WebSocket链接,都会产生一个新的对象,也就是被@ServerEndpoint修饰的对象。
  2. 在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: 你的主机中的软件中止了一个已建立的连接

原因:

  1. websocket使用了nio,在建立连接、接收消息、触发异常、关闭连接时都是新的线程,但是使用的是同一个Seesion,可以查看如下log

    2023-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
    
  2. 如果用户在建立连接时,线程没有释放,而是使用建立连接的线程来发送消息,那么在关闭连接时便会触发io异常

    也就是说,在建立连接时,如果使用了上述的线程[nio-8081-exec-5]来while(true)给前台推送消息,那么建立连接的线程便不会释放,在关闭socket连接时便会触发io异常

解决方案: 在建立连接时,使用后台线程来推送消息,让@onOpen所在的线程正常释放即可

  1. 创建一个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();
            }
        }
    }
    
  2. 创建一个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();
        }
    
  3. 在建立连接时,创建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传参

  1. 接收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) {
        }
    }
    
  2. 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状态代码
1015TLS握手保留。表示由于无法执行TLS握手(例如,无法验证服务器证书),连接已关闭
1016–2999用于通过WebSocket协议规范的未来版本进行定义,以及通过扩展规范进行定义
3000–3999供库、框架和应用程序使用。这些状态代码直接向IANA注册。WebSocket协议未定义这些代码的解释
4000–4999仅供私人使用,因此无法注册。WebSocket应用程序之间的先前协议可以使用这些代码。WebSocket协议未定义这些代码的解释
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

L-960

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值