前言
我们在开发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,即可访问到客户端页,建立连接成功,接收服务端发来的信息。
最后,我们写个重启任务来测试重启后的情形。
无效重启
首先会想到,重启SocketIOServer服务,那我先把SocketIOServer服务停了,在start一下不就完事了吗?
@Component
public class SocketIoTask {
@Autowired
private SocketIOServerStarter socketIOServerStarter;
@Scheduled(cron = "0/30 * * * * ? ")
public void restartSocketIOServer(){
socketIOServerStarter.closeServer();
socketIOServerStarter.startServer();
}
}
我们来看看结果
停止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
时,会报这个错。
我们来定位这个WORKER_STATE_SHUTDOWN
,这个静态常量为volatile变量workerState
的值
这是AtomicIntegerFieldUpdater.newUpdater(HashedWheelTimer.class, "workerState");
底层代码,
说明WORKER_STATE_UPDATER
字段通过反射可以获取workerState
的字段,则上面switch (WORKER_STATE_UPDATER.get(this))
获取到的是workerState
变量的值。
即重新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
停止状态。
定位到了当前类HashedWheelTimer
的方法stop
,看看它的引用
选择其中一个引用HashedWheelTimeoutScheduler
类的shutdown
方法调用了它
我们查看shutdown
方法的引用,发现是一个SocketIOChannelInitializer
类的stop
方法调用了它。
再查看stop
方法的引用,会发现这不就是SocketIOServer的stop停止服务器的方法吗
原来就是因为pipelineFactory
的关闭导致workerState
的值变为WORKER_STATE_SHUTDOWN
的!
这个SocketIOChannelInitializer
是ChannelInitializer
类型的,作为childHandler
,学过Netty的都知道,这是用于在创建新的通道(Channel)时进行的初始化配置,主要作用是定义一个回调方法 initChannel,可以在 initChannel 方法中添加一系列的处理器(ChannelHandler)来处理入站和出站数据。SocketIOChannelInitializer
里初始化了一个CancelableScheduler scheduler = new HashedWheelTimeoutScheduler();
HashedWheelTimeoutScheduler
里又由初始化了一个HashedWheelTimer executorService = new HashedWheelTimer();
HashedWheelTimer
就是刚刚的private volatile int workerState
变量所在的类,我们都知道int变量默认值为0,所以workerState
在类HashedWheelTimer
初始化后的值对应WORKER_STATE_INIT
常量的值
按照这个推理,初始化一个HashedWheelTimer
即可得到worker为init状态,那么我们创建一个新的SocketIOChannelInitializer
不就可以得到让workerState
为WORKER_STATE_INIT
吗?说白了,就是旧的已经被stop的SocketIOChannelInitializer
对应的HashedWheelTimer
的 workerState
已经为WORKER_STATE_SHUTDOWN
了,那我们就不用这个呗,自己创建new一个SocketIOChannelInitializer
设置到SocketIOServer
不就可以了吗?
我们可以看到SocketIOServer
已经提供了设置SocketIOChannelInitializer pipelineFactory
的方法了。
我们上代码校验下:
public void socketIORestart(){
socketIOServer.stop();
SocketIOChannelInitializer pipelineFactory = new SocketIOChannelInitializer();
socketIOServer.setPipelineFactory(pipelineFactory);
socketIOServer.start();
}
重新启动
客户端连接服务端成功
服务端停止后,客户端显示断开
服务端start后,可以看到客户端又连接上并且正常监听数据了
服务端停止、启动、建立与客户端连接、事件监听都一切正常
验证了我们刚刚的定位,即是SocketIOChannelInitializer pipelineFactory
的关闭导致workerState
状态变为DOWN
,进而运行计时器调度的时候就抛出了cannot be started once stopped
异常,即客户端连接无法handle处理成功。因此,想在SocketIOServer
stop后重新启动,需要新建一个新的SocketIOChannelInitializer
,即丢弃已经DOWN
的workerState
,因为一旦WORKER_STATE_SHUTDOWN
了就不能修改回WORKER_STATE_INIT
状态,由此需要重新初始化一个。