Netty实现长连接服务端跟客户端,使用单独的业务线程池,并支持心跳
背景
前阵子完成过一个系统,对接某交易所接口,通过长连接收发交易报文,并由应用程序发送心跳维持长连接。受限于开发平台的限制,只能采用传统的BIO实现。好在交易量并不大,未出现性能问题,一直稳定运行。但BIO始终是老掉牙的东西,后来做为业余的练习,通过NIO实现了底层的通讯框架。鉴于NIO的epoll bug,这次试试通过Netty来实现,包括服务端代码和客户端代码,服务端为后台程序,客户端有Swing UI。
程序实现的功能
- 服务端,等待客户端连接,处理客户端的请求;将请求消息加上部分内容后返回给客户端;
- 客户端,连接并发送请求,接收服务端的响应;
- 服务端跟客户端均发送心跳,ping-ping模式;
关于Netty的一些事
在开始之前,需要说说有关Netty的一些内容。
- Netty是采用的Reactor主从多线程模型(这个可能有分歧,也有人认为是Reactor多线程模型);
- 通常bossGroup只需要设置成1个线程即可;当需要监听多个端口,并采用同一个
ServerBootstrap
启动时,才有必要设置成多线程,每个端口会分派给一个固定线程进行监听;这种情况很少见。 - workerGroup线程数默认为CPU数*2;
- 默认情况下,所有ChildHandler均由workerGroup执行,如果其中有耗时操作,则会阻塞workerGroup线程,导致不能及时处理其他channel的IO读写事件;所以一般需要单独的业务线程池,来处理具体的业务逻辑;添加Handler时,可以通过参数指定执行Handler的线程池;
实现
实现起来其实简单。Pipeline中包括LoggingHandler(通过日志观察Netty执行过程)、Decoder、NettyBusinessDuplexHandler(业务处理入口,运行在业务线程池)、NettyHeartBeatDuplexHandler(心跳处理,包括与之对应IdleStateHandler),如下图所示:
心跳机制为Ping-ping模式,即双方都发送心跳,但无需响应心跳。20秒内未发送任何内容则发送心跳,60秒未收到任何内容则认为超时,关闭连接。略作修改也可以支持Ping-pong模式,即客户端发起心跳,服务端将心跳原路返回。
报文为头部+消息体格式,头部为17个字节,依次为4个int字段+1个byte字段。4个int字段依次为magicNumber(魔幻数,此值不对的报文为非法输入,直接丢弃),length(后面消息体长度),messageType(消息类型,1为业务消息,2为心跳),logId(请求方生成随机整数值,响应方原路返回);1个byte字段为flag(0表示是请求,1表示是响应)。
源文件列表
package
org.alive.learn.netty.
------+ AppBusinessProcessor.java 业务处理抽象基类
------+ ClientBusinessProcessor.java 客户端业务处理实现类
------+ Constants.java 常量
------+ NettyBusinessDuplexHandler.java 业务处理入口Handler,由业务线程池执行
------+ NettyClient.java 客户端
------+ NettyClientHelper.java 客户端辅助类
------+ NettyClientUI.java 客户端SwingUI
------+ NettyHeartBeatDuplexHandler.java 心跳处理Handler
------+ NettyMessage.java 消息对象
------+ NettyMessageDecoder.java 消息Decoder
------+ NettyServer.java 服务端
------+ ServerBusinessProcessor.java
org.alive.tools.socketmessage.
------+ UIUpdater.java UI组件更新工具类
------+ UIUtil.java
实现细节
- Server端程序主要注意业务线程池,采用
DefaultEventExecutorGroup
,添加NettyBusinessDuplexHandler
的时候通过参数指定由业务线程池执行;另外Server启动后,main线程继续等待输入,输入q退出程序。代码片段如下所示:
bizGroup = new DefaultEventExecutorGroup(bizThreadNum);
// ......
ServerBootstrap b = new ServerBootstrap();
// ......
// 设置用于SocketChannel的属性和handler
b.childHandler(new ChannelInitializer() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
ch.pipeline().addLast(new IdleStateHandler(60, 20, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new NettyHeartBeatDuplexHandler());
ch.pipeline().addLast(new NettyMessageDecoder());
ch.pipeline().addLast(bizGroup, new NettyBusinessDuplexHandler(new ServerBusinessProcessor()));
}
});
- Decoder,因为报文设计是前17个字节为报文头,故先判断是否够17个字节,够的话就先标记readerIndex,再把17字节读取出来,如果输入非法,则直接丢弃这17个字节;如果输入合法,再看后面长度是否够一个消息,不够就重置readerIndex,等待更多读取后下一次执行decode;最终长度够了就可以decode成NettyMessage对象;decode方法如下:
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List
- 业务逻辑处理入口
NettyBusinessDuplexHandler
,需要传入不同的AppBusinessProcessor子类,调用AppBusinessProcessor中的process方法完成业务处理。主要实现channelRead方法,收到的消息是心跳则忽略,是请求则需要将响应写回,是响应则不需要写回。
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
NettyMessage bizMsg = (NettyMessage) msg; // 拆分好的消息
ReferenceCountUtil.release(msg);
if (bizMsg.getMessageType() == NettyMessage.MESSAGE_TYPE_HB) {
logger.info("收到心跳 -- {}", bizMsg.toString());
} else {
// 处理业务消息
logger.info("收到消息 -- {}", bizMsg.toString());
bizProcessor.process(bizMsg);
// 如果接收到的是请求,则需要写回响应消息
if (bizMsg.getFlag() == 0) {
bizMsg.setFlag((byte) 1);
logger.info("写回消息 -- {}", bizMsg.toString());
ByteBuf rspMsg = Unpooled.copiedBuffer(bizMsg.composeFull());
ctx.writeAndFlush(rspMsg);
}
}
// 继续传递给Pipeline下一个Handler
// super.channelRead(ctx, msg);
// ctx.fireChannelRead(msg);
}
- 心跳处理
NettyHeartBeatDuplexHandler
,因为Pipeline中配置了ch.pipeline().addLast(new IdleStateHandler(60, 20, 0, TimeUnit.SECONDS));
即60秒未读取到消息触发READER_IDLE,表示超时,需要关闭Channel;20秒未写入消息触发WRITE_IDLE,表示需要发送心跳。userEventTriggered方法如下:
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
IdleState state = ((IdleStateEvent) evt).state();
if (state == IdleState.WRITER_IDLE) { // 20s
// throw new Exception("idle exception");
logger.info("idle 20s, send heartbeat");
ByteBuf buf = Unpooled.copiedBuffer(NettyMessage.HEATBEAT_MSG.composeFull());
ctx.writeAndFlush(buf);
} else if (state == IdleState.READER_IDLE) { // 60s
logger.info("连接timeout,请求关闭 " + ctx.channel());
ctx.close();
}
// 注意事项:
// 因为我实现的逻辑是所有IdleStateEvent只由NettyHeartBeatHandler一个Handler处理即可;
// 所以可以不需要将事件继续向pipeline后续的Handler传递,当然传递了也没什么事,因为其他的地方不能处理;
// 在某些情况下,如果你定义的事件需要通知多个Handler处理,那么一定要加上下面这一句才行。
// super.userEventTriggered(ctx, evt);
} else {
// 其他事件转发给Pipeline中其他的Handler处理
super.userEventTriggered(ctx, evt);
}
}
- Client端实现比较简单,pipeline跟Server端完全一样,main函数将需要的对象初始化后,启动Swing UI即可。
NettyClientHelper
辅助类提供方法供UI事件处理调用,也是为了跟UI组件解耦。另,更新界面依赖于UIUpdater
类,此类通过反射机制调用Swing组件的更新方法,通常为setText,将更新动作编排到UI事件调度线程(EDT)中。这个是Swing编程的常规思路,这里不多啰嗦。
程序运行效果
- 启动NettyServer,通过telnet localhost 7890测试,如果不输入任何内容,则发送2次心跳后,超时关闭连接:
2017-09-29 15:50:03 [main] - INFO server started sucessfully.
2017-09-29 15:50:03 [main] - INFO Server is running on port 7890, press q to quit.
2017-09-29 15:50:13 [nioEventLoopGroup-3-1] - INFO 连接建立[id: 0x1a5f5e68, L:/0:0:0:0:0:0:0:1:7890 - R:/0:0:0:0:0:0:0:1:64056]
2017-09-29 15:50:33 [nioEventLoopGroup-3-1] - INFO idle 20s, send heartbeat
2017-09-29 15:50:53 [nioEventLoopGroup-3-1] - INFO idle 20s, send heartbeat
2017-09-29 15:51:13 [nioEventLoopGroup-3-1] - INFO 连接timeout,请求关闭 [id: 0x1a5f5e68, L:/0:0:0:0:0:0:0:1:7890 - R:/0:0:0:0:0:0:0:1:64056]
2017-09-29 15:51:13 [nioEventLoopGroup-3-1] - INFO 连接关闭[id: 0x1a5f5e68, L:/0:0:0:0:0:0:0:1:7890 ! R:/0:0:0:0:0:0:0:1:64056]
-
启动NettyClient,填写正确的IP和端口,连接,然后发送消息,可以看到收到的响应消息,如果不发送消息,则可以看到双方的都在发送心跳,长连接得以保持。
-
当然也是可以支持多个Client同时接入的。
源代码
完整的源代码在我的码云 码云testcase
参考资料
- 简书: the_flash 这兄弟Netty系列文章写得挺透彻,强烈推荐
- CSDN Blog: Netty心跳之IdleStateHandler