Netty实现长连接服务端跟客户端,使用单独的业务线程池,并支持心跳

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),如下图所示: Netty实现长连接服务端跟客户端,使用单独的业务线程池,并支持心跳_第1张图片

心跳机制为Ping-ping模式,即双方都发送心跳,但无需响应心跳。20秒内未发送任何内容则发送心跳,60秒未收到任何内容则认为超时,关闭连接。略作修改也可以支持Ping-pong模式,即客户端发起心跳,服务端将心跳原路返回。

报文为头部+消息体格式,头部为17个字节,依次为4个int字段+1个byte字段。4个int字段依次为magicNumber(魔幻数,此值不对的报文为非法输入,直接丢弃),length(后面消息体长度),messageType(消息类型,1为业务消息,2为心跳),logId(请求方生成随机整数值,响应方原路返回);1个byte字段为flag(0表示是请求,1表示是响应)。 Netty实现长连接服务端跟客户端,使用单独的业务线程池,并支持心跳_第2张图片

源文件列表

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 out) throws Exception {
	// 不够报文头长度,返回
	if (in.readableBytes() < NettyMessage.HEAD_LEN) {
		return;
	}
	
	in.markReaderIndex();
	int magicNumber = in.readInt();
	int length = in.readInt();
	int messageType = in.readInt();
	int logId = in.readInt();
	byte flag = in.readByte();
	
	// 如果magicNumber对不上或者length为负数,那有可能是通过telnet随意输入的内容,直接丢弃处理,不需要重置readerIndex
	if (magicNumber != Constants.MAGIC_NUMBER || length < 0) {
		logger.warn("非法输入,丢弃");
		return;
	}
	
	// 长度超过消息头长度,但是剩下的不够一个完整的报文,那么就重置readerIndex,返回等读取更多的数据再处理
	if (in.readableBytes() < length) {
		in.resetReaderIndex();
		return;
	}
	
	NettyMessage message = new NettyMessage(magicNumber, length, messageType, logId);
	message.setFlag(flag);
	byte[] bodyArray = new byte[length];
	in.readBytes(bodyArray);
	message.setMessageBody(bodyArray);
	
	out.add(message);
}
 
   
  • 业务逻辑处理入口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和端口,连接,然后发送消息,可以看到收到的响应消息,如果不发送消息,则可以看到双方的都在发送心跳,长连接得以保持。 Netty实现长连接服务端跟客户端,使用单独的业务线程池,并支持心跳_第3张图片

  • 当然也是可以支持多个Client同时接入的。

源代码

完整的源代码在我的码云 码云testcase

参考资料

  • 简书: the_flash 这兄弟Netty系列文章写得挺透彻,强烈推荐
  • CSDN Blog: Netty心跳之IdleStateHandler

转载于:https://my.oschina.net/myumen/blog/1545489

你可能感兴趣的:(Netty实现长连接服务端跟客户端,使用单独的业务线程池,并支持心跳)