深入SocketIO源码来解决SocketIOServer重启失效的问题

前言

我们在开发SocketIO服务器时,往往需要用到已建立连接的客户端的信息,例如在触发向某些特定客户端发送数据时,要能获取到对应的客户端对象,因此我们是需要将一组一组客户端对应关系缓存起来的,举个例子,现在有一个客户端想要向服务器获取某个厕所下的空气质量数据,服务器接收到请求,会获取客户端的sessionId(客户端唯一标识),然后根据客户端传来的厕所信息作为标签,将sessionId加入到这个标签对应的客户端sessionId集合里,后面我们往这个厕所推送空气质量数据的时候,就能知道这个厕所的空气质量数据有哪些客户端在监听,再向这些客户端发送实时数据即可,并且,客户端可以选择要监听,它也可以选择拒绝监听,拒绝了监听的需要在监听列表中剔除以防止误发,因此,需要缓存维护众多事件的标签与客户端sessionId集合的对应关系,并且拒绝监听的客户端需要有expire。
我们可以开启一个定时任务来定期清理过期的sessionId,并且我们可以通过重启SocketIOServer来触发disconnect进行清理资源操作,清除长期无效的连接的同时,有效的客户端也能自动重连。

准备工作

我们在模拟重启SocketIOServer服务之前,需要将工程必要的逻辑准备好。
引入可能需要用到的maven坐标

<dependencies>
    <!-- 我们模拟客户端页面的时候需要用到thymeleaf -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>
    <!-- socketio依赖包 -->
  	<dependency>
			<groupId>com.corundumstudio.socketio</groupId>
			<artifactId>netty-socketio</artifactId>
			<version>1.7.7</version>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
		</dependency>
		<dependency>
			<groupId>commons-io</groupId>
			<artifactId>commons-io</artifactId>
			<version>2.7</version>
		</dependency>
		<dependency>
			<groupId>com.google.guava</groupId>
			<artifactId>guava</artifactId>
			<version>32.0.0-jre</version>
		</dependency>
		<dependency>
			<groupId>org.apache.commons</groupId>
			<artifactId>commons-lang3</artifactId>
		</dependency>
		<dependency>
			<groupId>com.google.code.gson</groupId>
			<artifactId>gson</artifactId>
			<version>2.8.9</version>
		</dependency>
</dependencies>

获取socketio配置项,初始化SocketIOServer放入Spring容器,并创建 SpringAnnotationScanner 实例,该实例将扫描并注册 Socket.IO 事件处理器等组件。

import com.corundumstudio.socketio.Configuration;
import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.annotation.SpringAnnotationScanner;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;

@org.springframework.context.annotation.Configuration
@Data
public class SocketIOConfig {
    @Value("${socketio.hostname:0.0.0.0}")//默认为0.0.0.0,服务器将监听来自所有网络接口的连接。这意味着它可以通过任何可用的网络接口接受来自不同主机或计算机的连接请求
    private String hostname = "0.0.0.0";
    @Value("${socketio.port:9092}")
    private Integer port;

    @Bean
    public SocketIOServer socketIOServer(){
        Configuration config = new Configuration();
        config.setAllowCustomRequests(true);
        config.setHostname(hostname);
        config.setPort(port);
        SocketIOServer server = new SocketIOServer(config);
        //        server.addListeners(eventListennter);
        return server;
    }

    @Bean
    public SpringAnnotationScanner springAnnotationScanner(SocketIOServer socketIOServer) {
        // 创建 SpringAnnotationScanner 实例,该实例将扫描并注册 Socket.IO 事件处理器等组件。
        return new SpringAnnotationScanner(socketIOServer);
    }
}

对象构建完成后执行启动我们的SocketIO服务器

@Service
@Slf4j
public class SocketIOServerStarter {
    @Autowired
    SocketIOServer socketIOServer;

    @Autowired
    SocketIOConfig socketIOConfBean;

    @PostConstruct
    public void startServer() {
        if (!StringUtils.isNotBlank(socketIOConfBean.getHostname()) || socketIOConfBean.getPort() == null || socketIOConfBean.getPort()<1) {
            return;
        }
        socketIOServer.start();
    }

    public void closeServer() {
        if(socketIOServer!=null){
            socketIOServer.stop();
        }
    }
}

监听客户端事件

@Service
@Slf4j
public class EventListener {

    @OnConnect
    public void onConnect(SocketIOClient client) {
        String sessionId = client.getSessionId().toString();
        String deviceId = client.getHandshakeData().getSingleUrlParam("deviceId");
        log.info("connect.onConnect:{}|{}|{}", sessionId, deviceId, client.getRemoteAddress().toString());
    }

    @OnDisconnect
    public void onDisconnect(SocketIOClient client) {
        log.info("socket.client.disconnect:{}", client.getSessionId().toString());
    }

    @OnEvent(AQ_CURRENT)
    public void onAqCurrent(SocketIOClient client, AqCurrentDto aqCurrentDto){
        //接收客户端监听空气质量数据的请求,这里直接返回固定数据给客户端
        log.info("connect.AQ_CURRENT:{}|{}",  client.getSessionId().toString(), JSONUtils.beanToJson(aqCurrentDto));
        client.sendEvent(AQ_CURRENT,new AqEventDataDto(2,1,1f,1f,1f,1f,23.6f,1000,10f));
    }
}

客户端页面(需要依赖一些js文件),尝试建立连接,当建立连接后向服务端发送监听AQ_CURRENT即空气质量数据信息,并监听服务端的AQ_CURRENT事件发来/返回的数据。

<!DOCTYPE html>
<html>
  <head>

    <meta charset="utf-8" />

    <title>Demo Chat</title>

    <!--<link href="bootstrap.css" rel="stylesheet">-->

    <style>
      body {
        padding:20px;
      }
      #console {
        height: 400px;
        overflow: auto;
      }
      .username-msg {color: #ffa500;}
      .connect-msg {color:green;}
      .disconnect-msg {color:red;}
      .send-msg {color:#888}
    </style>


    <script src="js/socket.io/socket.io.js"></script>
    <script src="js/moment.min.js"></script>
    <script src="http://www.htmleaf.com/js/jquery/1.10.1/jquery.min.js"></script>

    <script>

      var userName = 'user' + Math.floor((Math.random()*1000)+1);

      var socketUrl = "http://localhost:9092"

      var socket =  io.connect(socketUrl,{
        query: {
          deviceId:'aaaaaa10000197g'
        }
      });


      socket.on('connect', function() {
        var jsonObject = {siteName: "qdaeon",wcName:"a-3",roomType:"male"}
        socket.emit('AQ_CURRENT',jsonObject)
      });

      socket.on('AQ_CURRENT',function (data) {
        console.log("AQ_CURRENT|",data)
        output(data);
      });

      socket.on('disconnect', function() {
        output('<span class="disconnect-msg">The client has disconnected!</span>');
      });

      function sendDisconnect() {
        socket.disconnect();
      }

      function sendMessage() {
        var jsonObject = {siteName: "qdaeon",wcName:"a-3",roomType:"male"}
        socket.emit('AQ_CURRENT',jsonObject)
      }

      function output(message) {
        var currentTime = "<span class='time'>" +  moment().format('HH:mm:ss.SSS') + "</span>";
        var element = $("<div>" + currentTime + " " + message + "</div>");
        $('#console').prepend(element);
      }

      $(document).keydown(function(e){
        if(e.keyCode == 13) {
          $('#send').click();
        }
      });
    </script>
  </head>

  <body>

    <h1>Netty-socketio Demo Chat</h1>

    <br/>

    <div id="console" class="well">
    </div>

    <form class="well form-inline" onsubmit="return false;">
      <input id="msg" class="input-xlarge" type="text" placeholder="Type something..."/>
      <button type="button" onClick="sendMessage()" class="btn" id="send">Send</button>
      <button type="button" onClick="sendDisconnect()" class="btn">Disconnect</button>
    </form>



  </body>

</html>

配置下端口和客户端页面thymeleaf

server.port=8081
spring.thymeleaf.prefix=classpath:/templates/

启动服务器,访问http://localhost:8081,即可访问到客户端页,建立连接成功,接收服务端发来的信息。
image.png
image.png
最后,我们写个重启任务来测试重启后的情形。

无效重启

首先会想到,重启SocketIOServer服务,那我先把SocketIOServer服务停了,在start一下不就完事了吗?

@Component
public class SocketIoTask {

    @Autowired
    private SocketIOServerStarter socketIOServerStarter;
    @Scheduled(cron = "0/30 * * * * ? ")
    public void restartSocketIOServer(){
        socketIOServerStarter.closeServer();
        socketIOServerStarter.startServer();
    }
}

我们来看看结果
image.png
停止SocketIOServer服务后,客户端断开连接,然后再调用socketIOServerStarter.startServer()重新开启服务,但是这时客户端重新过来建立连接时,服务器就报错了
根据报错信息cannot be started once stopped可得知当前SocketIOServer服务对象一旦停了就不能被开启,因此我们不能使用对当前SocketIOServer服务closeServer然后再startServer来实现SocketIOServer服务重启操作。

2023-09-25 17:06:04.401  WARN 5944 --- [ntLoopGroup-5-1] io.netty.channel.DefaultChannelPipeline  : An exceptionCaught() event was fired, and it reached at the tail of the pipeline. It usually means the last handler in the pipeline did not handle the exception.

java.lang.IllegalStateException: cannot be started once stopped
at io.netty.util.HashedWheelTimer.start(HashedWheelTimer.java:372) ~[netty-common-4.1.70.Final.jar:4.1.70.Final]
at io.netty.util.HashedWheelTimer.newTimeout(HashedWheelTimer.java:447) ~[netty-common-4.1.70.Final.jar:4.1.70.Final]
at com.corundumstudio.socketio.scheduler.HashedWheelTimeoutScheduler.schedule(HashedWheelTimeoutScheduler.java:85) ~[netty-socketio-1.7.7.jar:na]
at com.corundumstudio.socketio.handler.ClientHead.schedulePingTimeout(ClientHead.java:125) ~[netty-socketio-1.7.7.jar:na]
at com.corundumstudio.socketio.handler.AuthorizeHandler.authorize(AuthorizeHandler.java:174) ~[netty-socketio-1.7.7.jar:na]
at com.corundumstudio.socketio.handler.AuthorizeHandler.channelRead(AuthorizeHandler.java:108) ~[netty-socketio-1.7.7.jar:na]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.SimpleChannelInboundHandler.channelRead(SimpleChannelInboundHandler.java:102) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.handler.codec.MessageToMessageDecoder.channelRead(MessageToMessageDecoder.java:103) ~[netty-codec-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.handler.codec.ByteToMessageDecoder.fireChannelRead(ByteToMessageDecoder.java:324) ~[netty-codec-4.1.70.Final.jar:4.1.70.Final]
at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:296) ~[netty-codec-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:719) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:655) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:581) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:493) ~[netty-transport-4.1.70.Final.jar:4.1.70.Final]
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:986) ~[netty-common-4.1.70.Final.jar:4.1.70.Final]
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) ~[netty-common-4.1.70.Final.jar:4.1.70.Final]
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) ~[netty-common-4.1.70.Final.jar:4.1.70.Final]
at java.base/java.lang.Thread.run(Thread.java:833) ~[na:na]

有效重启

既然上面错误日志已经提醒我们不能对同一个SocketIOServer服务停止后再启动的操作,那我如果再new 一个SocketIOServer,不用这个SocketIOServer呢?按照这个方法,经过调试之后你会发现socketio连接一直未响应,为什么?回到我们刚刚的SocketIOConfig

    @Bean
    public SpringAnnotationScanner springAnnotationScanner(SocketIOServer socketIOServer) {
        // 创建 SpringAnnotationScanner 实例,该实例将扫描并注册 Socket.IO 事件处理器等组件。
        return new SpringAnnotationScanner(socketIOServer);
    }

原来使用注解方式来监听事件,需要将我们一开始创建的SocketIOServer传递给SpringAnnotationScanner,才会帮我们监听拦截事件。所以我们虽然创建了一个新的SocketIOServer对象,但是监听事件使用的还是原来的已经停止的SocketIOServer。
那有没有什么办法可以不使用新的SocketIOServer,沿用原来的SocketIOServer对象就可以让服务再次启动?

我们进入SocketIOServer的源码,来看看为什么原来的SocketIOServer就不能像它报错说的cannot be started once stopped
我们进入报错的第一行代码HashedWheelTimer.java:372
WORKER_STATE_UPDATER.get(this)状态为WORKER_STATE_SHUTDOWN时,会报这个错。
image.png
我们来定位这个WORKER_STATE_SHUTDOWN,这个静态常量为volatile变量workerState的值
image.png
这是AtomicIntegerFieldUpdater.newUpdater(HashedWheelTimer.class, "workerState");底层代码,
说明WORKER_STATE_UPDATER字段通过反射可以获取workerState的字段,则上面switch (WORKER_STATE_UPDATER.get(this))获取到的是workerState变量的值。
image.png
即重新start SocketIOServer后,客户端建立连接是否能成功,事件是否能处理,取决于workerState的值。当
workerState的值为WORKER_STATE_SHUTDOWN时,则会启动失败,那我们要是找出workerState的值是从哪里被设置为WORKER_STATE_SHUTDOWN的,那不就可以判断出问题的来源在哪了~
于是,查看哪里来改变了WORKER_STATE_SHUTDOWN,即看WORKER_STATE_SHUTDOWN的引用
我们看到这里WORKER_STATE_UPDATER试图通过CAS将WORKER_STATE_STARTED运行状态设置为WORKER_STATE_SHUTDOWN停止状态。
image.png
image.png
定位到了当前类HashedWheelTimer的方法stop,看看它的引用
选择其中一个引用
image.pngHashedWheelTimeoutScheduler类的shutdown方法调用了它
image.png
我们查看shutdown方法的引用,发现是一个SocketIOChannelInitializer类的stop方法调用了它。
image.png
再查看stop方法的引用,会发现这不就是SocketIOServer的stop停止服务器的方法吗
image.png
原来就是因为pipelineFactory的关闭导致workerState的值变为WORKER_STATE_SHUTDOWN的!
image.png
这个SocketIOChannelInitializerChannelInitializer类型的,作为childHandler,学过Netty的都知道,这是用于在创建新的通道(Channel)时进行的初始化配置,主要作用是定义一个回调方法 initChannel,可以在 initChannel 方法中添加一系列的处理器(ChannelHandler)来处理入站和出站数据。
image.png
image.png
SocketIOChannelInitializer里初始化了一个CancelableScheduler scheduler = new HashedWheelTimeoutScheduler();
HashedWheelTimeoutScheduler里又由初始化了一个HashedWheelTimer executorService = new HashedWheelTimer();
HashedWheelTimer就是刚刚的private volatile int workerState变量所在的类,我们都知道int变量默认值为0,所以workerState在类HashedWheelTimer初始化后的值对应WORKER_STATE_INIT常量的值
image.png
按照这个推理,初始化一个HashedWheelTimer即可得到worker为init状态,那么我们创建一个新的SocketIOChannelInitializer不就可以得到让workerStateWORKER_STATE_INIT吗?说白了,就是旧的已经被stop的SocketIOChannelInitializer对应的HashedWheelTimerworkerState已经为WORKER_STATE_SHUTDOWN了,那我们就不用这个呗,自己创建new一个SocketIOChannelInitializer设置到SocketIOServer不就可以了吗?
我们可以看到SocketIOServer已经提供了设置SocketIOChannelInitializer pipelineFactory的方法了。
image.png
我们上代码校验下:

    public void socketIORestart(){
        socketIOServer.stop();
        SocketIOChannelInitializer pipelineFactory = new SocketIOChannelInitializer();
        socketIOServer.setPipelineFactory(pipelineFactory);
        socketIOServer.start();
    }

重新启动
image.png
客户端连接服务端成功
image.png
服务端停止后,客户端显示断开
image.png
服务端start后,可以看到客户端又连接上并且正常监听数据了
image.png
服务端停止、启动、建立与客户端连接、事件监听都一切正常
image.png
验证了我们刚刚的定位,即是SocketIOChannelInitializer pipelineFactory的关闭导致workerState状态变为DOWN,进而运行计时器调度的时候就抛出了cannot be started once stopped异常,即客户端连接无法handle处理成功。因此,想在SocketIOServerstop后重新启动,需要新建一个新的SocketIOChannelInitializer,即丢弃已经DOWNworkerState,因为一旦WORKER_STATE_SHUTDOWN了就不能修改回WORKER_STATE_INIT状态,由此需要重新初始化一个。

  • 5
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值