(文章转自:hankchen,http://www.blogjava.net/hankchen)
Netty是一个高性能的NIO通信框架,提供异步的、事件驱动的网络编程模型。使用Netty可以方便用户开发各种常用协议的网络程序。例如:TCP、UDP、HTTP等等。
Netty的最新版本是3.2.7,官网地址是: http://www.jboss.org/netty
本文的主要目的是基于Netty实现一个通用二进制协议的高效数据传输。协议是通用的二进制协议,高效并且扩展性很好。
一个好的协议有两个标准:
(1)生成的传输数据要少,即数据压缩比要高。这样可以减少网络开销。
(2)传输数据和业务对象之间的转换速度要快。
一、协议的定义
无论是请求还是响应,报文都由一个通用报文头和实际数据组成。报文头在前,数据在后。
(1)报文头:由数据解析类型,数据解析方法,编码,扩展字节,包长度组成,共16个字节:
编码方式(1byte)、加密(1byte)、扩展1(1byte)、扩展2(1byte)、会话ID(4byte)、命令或者结果码(4byte)、数据包长(4byte)
(2)数据:由数据包长指定。请求或回复数据。类型对应为JAVA的Map<String,String>
数据格式定义:
字段1键名长度 字段1键名 字段1值长度 字段1值
字段2键名长度 字段2键名 字段2值长度 字段2值
字段3键名长度 字段3键名 字段3值长度 字段3值
… … … …
长度为整型,占4个字节
代码中用两个Vo对象来表示:XLRequest和XLResponse。
XLReponse.java
package org.jboss.netty.example.xlsvr.vo; import java.util.HashMap; import java.util.Map; /** * @author hankchen * 2012-2-3 下午02:46:52 */ /** * 响应数据 */ /** * 通用协议介绍 * * 通用报文格式:无论是请求还是响应,报文都由一个通用报文头和实际数据组成。报文头在前,数据在后 * (1)报文头:由数据解析类型,数据解析方法,编码,扩展字节,包长度组成,共16个字节: * 编码方式(1byte)、加密(1byte)、扩展1(1byte)、扩展2(1byte)、会话ID(4byte)、命令或者结果码(4byte)、包长(4byte) * (2)数据:由包长指定。请求或回复数据。类型对应为JAVA的Map<String,String> * 数据格式定义: * 字段1键名长度 字段1键名 字段1值长度 字段1值 * 字段2键名长度 字段2键名 字段2值长度 字段2值 * 字段3键名长度 字段3键名 字段3值长度 字段3值 * … … … … * 长度为整型,占4个字节 */ public class XLResponse { private byte encode;// 数据编码格式。已定义:0:UTF-8,1:GBK,2:GB2312,3:ISO8859-1 private byte encrypt;// 加密类型。0表示不加密 private byte extend1;// 用于扩展协议。暂未定义任何值 private byte extend2;// 用于扩展协议。暂未定义任何值 private int sessionid;// 会话ID private int result;// 结果码 private int length;// 数据包长 private Map<String,String> values=new HashMap<String, String>(); private String ip; public void setValue(String key,String value){ values.put(key, value); } public String getValue(String key){ if (key==null) { return null; } return values.get(key); } public byte getEncode() { return encode; } public void setEncode(byte encode) { this.encode = encode; } public byte getEncrypt() { return encrypt; } public void setEncrypt(byte encrypt) { this.encrypt = encrypt; } public byte getExtend1() { return extend1; } public void setExtend1(byte extend1) { this.extend1 = extend1; } public byte getExtend2() { return extend2; } public void setExtend2(byte extend2) { this.extend2 = extend2; } public int getSessionid() { return sessionid; } public void setSessionid(int sessionid) { this.sessionid = sessionid; } public int getResult() { return result; } public void setResult(int result) { this.result = result; } public int getLength() { return length; } public void setLength(int length) { this.length = length; } public Map<String, String> getValues() { return values; } public String getIp() { return ip; } public void setIp(String ip) { this.ip = ip; } public void setValues(Map<String, String> values) { this.values = values; } @Override public String toString() { return "XLResponse [encode=" + encode + ", encrypt=" + encrypt + ", extend1=" + extend1 + ", extend2=" + extend2 + ", sessionid=" + sessionid + ", result=" + result + ", length=" + length + ", values=" + values + ", ip=" + ip + "]"; } }
XLRequest.java
package org.jboss.netty.example.xlsvr.vo; import java.util.HashMap; import java.util.Map; /** * @author hankchen * 2012-2-3 下午02:46:41 */ /** * 请求数据 */ /** * 通用协议介绍 * * 通用报文格式:无论是请求还是响应,报文都由一个通用报文头和实际数据组成。报文头在前,数据在后 * (1)报文头:由数据解析类型,数据解析方法,编码,扩展字节,包长度组成,共16个字节: * 编码方式(1byte)、加密(1byte)、扩展1(1byte)、扩展2(1byte)、会话ID(4byte)、命令或者结果码(4byte)、包长(4byte) * (2)数据:由包长指定。请求或回复数据。类型对应为JAVA的Map<String,String> * 数据格式定义: * 字段1键名长度 字段1键名 字段1值长度 字段1值 * 字段2键名长度 字段2键名 字段2值长度 字段2值 * 字段3键名长度 字段3键名 字段3值长度 字段3值 * … … … … * 长度为整型,占4个字节 */ public class XLRequest { private byte encode;// 数据编码格式。已定义:0:UTF-8,1:GBK,2:GB2312,3:ISO8859-1 private byte encrypt;// 加密类型。0表示不加密 private byte extend1;// 用于扩展协议。暂未定义任何值 private byte extend2;// 用于扩展协议。暂未定义任何值 private int sessionid;// 会话ID private int command;// 命令 private int length;// 数据包长 private Map<String,String> params=new HashMap<String, String>(); //参数 private String ip; public byte getEncode() { return encode; } public void setEncode(byte encode) { this.encode = encode; } public byte getEncrypt() { return encrypt; } public void setEncrypt(byte encrypt) { this.encrypt = encrypt; } public byte getExtend1() { return extend1; } public void setExtend1(byte extend1) { this.extend1 = extend1; } public byte getExtend2() { return extend2; } public void setExtend2(byte extend2) { this.extend2 = extend2; } public int getSessionid() { return sessionid; } public void setSessionid(int sessionid) { this.sessionid = sessionid; } public int getCommand() { return command; } public void setCommand(int command) { this.command = command; } public int getLength() { return length; } public void setLength(int length) { this.length = length; } public Map<String, String> getParams() { return params; } public void setValue(String key,String value){ params.put(key, value); } public String getValue(String key){ if (key==null) { return null; } return params.get(key); } public String getIp() { return ip; } public void setIp(String ip) { this.ip = ip; } public void setParams(Map<String, String> params) { this.params = params; } @Override public String toString() { return "XLRequest [encode=" + encode + ", encrypt=" + encrypt + ", extend1=" + extend1 + ", extend2=" + extend2 + ", sessionid=" + sessionid + ", command=" + command + ", length=" + length + ", params=" + params + ", ip=" + ip + "]"; } }
二、协议的编码和解码
对于自定义二进制协议,编码解码器往往是Netty开发的重点。这里直接给出相关类的代码。
XLServerEncoder.java
package org.jboss.netty.example.xlsvr.codec; import java.nio.ByteBuffer; import org.jboss.netty.buffer.ChannelBuffer; import org.jboss.netty.buffer.ChannelBuffers; import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.channel.Channels; import org.jboss.netty.channel.MessageEvent; import org.jboss.netty.channel.SimpleChannelDownstreamHandler; import org.jboss.netty.example.xlsvr.util.ProtocolUtil; import org.jboss.netty.example.xlsvr.vo.XLResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * @author hankchen * 2012-2-3 上午10:48:15 */ /** * 服务器端编码器 */ public class XLServerEncoder extends SimpleChannelDownstreamHandler { Logger logger=LoggerFactory.getLogger(XLServerEncoder.class); @Override public void writeRequested(ChannelHandlerContext ctx, MessageEvent e) throws Exception { XLResponse response=(XLResponse)e.getMessage(); ByteBuffer headBuffer=ByteBuffer.allocate(16); /** * 先组织报文头 */ headBuffer.put(response.getEncode()); headBuffer.put(response.getEncrypt()); headBuffer.put(response.getExtend1()); headBuffer.put(response.getExtend2()); headBuffer.putInt(response.getSessionid()); headBuffer.putInt(response.getResult()); /** * 组织报文的数据部分 */ ChannelBuffer dataBuffer=ProtocolUtil.encode(response.getEncode(),response.getValues()); int length=dataBuffer.readableBytes(); headBuffer.putInt(length); /** * 非常重要 * ByteBuffer需要手动flip(),ChannelBuffer不需要 */ headBuffer.flip(); ChannelBuffer totalBuffer=ChannelBuffers.dynamicBuffer(); totalBuffer.writeBytes(headBuffer); logger.info("totalBuffer size="+totalBuffer.readableBytes()); totalBuffer.writeBytes(dataBuffer); logger.info("totalBuffer size="+totalBuffer.readableBytes()); Channels.write(ctx, e.getFuture(), totalBuffer); } }
XLClientDecoder.java
package org.jboss.netty.example.xlsvr.codec; import org.jboss.netty.buffer.ChannelBuffer; import org.jboss.netty.buffer.ChannelBuffers; import org.jboss.netty.channel.Channel; import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.example.xlsvr.util.ProtocolUtil; import org.jboss.netty.example.xlsvr.vo.XLResponse; import org.jboss.netty.handler.codec.frame.FrameDecoder; /** * @author hankchen * 2012-2-3 上午10:47:54 */ /** * 客户端解码器 */ public class XLClientDecoder extends FrameDecoder { @Override protected Object decode(ChannelHandlerContext context, Channel channel, ChannelBuffer buffer) throws Exception { if (buffer.readableBytes()<16) { return null; } buffer.markReaderIndex(); byte encode=buffer.readByte(); byte encrypt=buffer.readByte(); byte extend1=buffer.readByte(); byte extend2=buffer.readByte(); int sessionid=buffer.readInt(); int result=buffer.readInt(); int length=buffer.readInt(); // 数据包长 if (buffer.readableBytes()<length) { buffer.resetReaderIndex(); return null; } ChannelBuffer dataBuffer=ChannelBuffers.buffer(length); buffer.readBytes(dataBuffer, length); XLResponse response=new XLResponse(); response.setEncode(encode); response.setEncrypt(encrypt); response.setExtend1(extend1); response.setExtend2(extend2); response.setSessionid(sessionid); response.setResult(result); response.setLength(length); response.setValues(ProtocolUtil.decode(encode, dataBuffer)); response.setIp(ProtocolUtil.getClientIp(channel)); return response; } }
ProtocolUtil.java
package org.jboss.netty.example.xlsvr.util; import java.net.SocketAddress; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; import org.jboss.netty.buffer.ChannelBuffer; import org.jboss.netty.buffer.ChannelBuffers; import org.jboss.netty.channel.Channel; /** * @author hankchen * 2012-2-4 下午01:57:33 */ public class ProtocolUtil { /** * 编码报文的数据部分 * @param encode * @param values * @return */ public static ChannelBuffer encode(int encode,Map<String,String> values){ ChannelBuffer totalBuffer=null; if (values!=null && values.size()>0) { totalBuffer=ChannelBuffers.dynamicBuffer(); int length=0,index=0; ChannelBuffer [] channelBuffers=new ChannelBuffer[values.size()]; Charset charset=XLCharSetFactory.getCharset(encode); for(Entry<String,String> entry:values.entrySet()){ String key=entry.getKey(); String value=entry.getValue(); ChannelBuffer buffer=ChannelBuffers.dynamicBuffer(); buffer.writeInt(key.length()); buffer.writeBytes(key.getBytes(charset)); buffer.writeInt(value.length()); buffer.writeBytes(value.getBytes(charset)); channelBuffers[index++]=buffer; length+=buffer.readableBytes(); } for (int i = 0; i < channelBuffers.length; i++) { totalBuffer.writeBytes(channelBuffers[i]); } } return totalBuffer; } /** * 解码报文的数据部分 * @param encode * @param dataBuffer * @return */ public static Map<String,String> decode(int encode,ChannelBuffer dataBuffer){ Map<String,String> dataMap=new HashMap<String, String>(); if (dataBuffer!=null && dataBuffer.readableBytes()>0) { int processIndex=0,length=dataBuffer.readableBytes(); Charset charset=XLCharSetFactory.getCharset(encode); while(processIndex<length){ /** * 获取Key */ int size=dataBuffer.readInt(); byte [] contents=new byte [size]; dataBuffer.readBytes(contents); String key=new String(contents, charset); processIndex=processIndex+size+4; /** * 获取Value */ size=dataBuffer.readInt(); contents=new byte [size]; dataBuffer.readBytes(contents); String value=new String(contents, charset); dataMap.put(key, value); processIndex=processIndex+size+4; } } return dataMap; } /** * 获取客户端IP * @param channel * @return */ public static String getClientIp(Channel channel){ /** * 获取客户端IP */ SocketAddress address = channel.getRemoteAddress(); String ip = ""; if (address != null) { ip = address.toString().trim(); int index = ip.lastIndexOf(':'); if (index < 1) { index = ip.length(); } ip = ip.substring(1, index); } if (ip.length() > 15) { ip = ip.substring(Math.max(ip.indexOf("/") + 1, ip.length() - 15)); } return ip; } }
三、服务器端实现
服务器端提供的功能是:
1、接收客户端的请求(非关闭命令),返回XLResponse类型的数据。
2、如果客户端的请求是关闭命令:shutdown,则服务器端关闭自身进程。
为了展示多协议的运用,这里客户端的请求采用的是基于问本行(\n\r)的协议。
具体代码如下:
package org.jboss.netty.example.xlsvr; import java.net.InetSocketAddress; import java.util.concurrent.Executors; import org.jboss.netty.bootstrap.ServerBootstrap; import org.jboss.netty.channel.Channel; import org.jboss.netty.channel.ChannelPipeline; import org.jboss.netty.channel.group.ChannelGroup; import org.jboss.netty.channel.group.ChannelGroupFuture; import org.jboss.netty.channel.group.DefaultChannelGroup; import org.jboss.netty.channel.socket.nio.NioServerSocketChannelFactory; import org.jboss.netty.example.xlsvr.codec.XLServerEncoder; import org.jboss.netty.handler.codec.frame.DelimiterBasedFrameDecoder; import org.jboss.netty.handler.codec.frame.Delimiters; import org.jboss.netty.handler.codec.string.StringDecoder; import org.jboss.netty.util.CharsetUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * @author hankchen * 2012-1-30 下午03:21:38 */ public class XLServer { public static final int port =8080; public static final Logger logger=LoggerFactory.getLogger(XLServer.class); public static final ChannelGroup allChannels=new DefaultChannelGroup("XLServer"); private static final ServerBootstrap serverBootstrap=new ServerBootstrap(new NioServerSocketChannelFactory(Executors.newCachedThreadPool(), Executors.newCachedThreadPool())); public static void main(String [] args){ try { XLServer.startup(); } catch (Exception e) { e.printStackTrace(); } } public static boolean startup() throws Exception{ /** * 采用默认ChannelPipeline管道 * 这意味着同一个XLServerHandler实例将被多个Channel通道共享 * 这种方式对于XLServerHandler中无有状态的成员变量是可以的,并且可以提高性能! */ ChannelPipeline pipeline=serverBootstrap.getPipeline(); /** * 解码器是基于文本行的协议,\r\n或者\n\r */ pipeline.addLast("frameDecoder", new DelimiterBasedFrameDecoder(80, Delimiters.lineDelimiter())); pipeline.addLast("stringDecoder", new StringDecoder(CharsetUtil.UTF_8)); pipeline.addLast("encoder", new XLServerEncoder()); pipeline.addLast("handler", new XLServerHandler()); serverBootstrap.setOption("child.tcpNoDelay", true); //注意child前缀 serverBootstrap.setOption("child.keepAlive", true); //注意child前缀 /** * ServerBootstrap对象的bind方法返回了一个绑定了本地地址的服务端Channel通道对象 */ Channel channel=serverBootstrap.bind(new InetSocketAddress(port)); allChannels.add(channel); logger.info("server is started on port "+port); return false; } public static void shutdown() throws Exception{ try { /** * 主动关闭服务器 */ ChannelGroupFuture future=allChannels.close(); future.awaitUninterruptibly();//阻塞,直到服务器关闭 //serverBootstrap.releaseExternalResources(); } catch (Exception e) { e.printStackTrace(); logger.error(e.getMessage(),e); } finally{ logger.info("server is shutdown on port "+port); System.exit(1); } } }
XLServerHandler.java
package org.jboss.netty.example.xlsvr; import java.util.Random; import org.jboss.netty.channel.Channel; import org.jboss.netty.channel.ChannelFuture; import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.channel.ChannelHandler.Sharable; import org.jboss.netty.channel.ChannelStateEvent; import org.jboss.netty.channel.ExceptionEvent; import org.jboss.netty.channel.MessageEvent; import org.jboss.netty.channel.SimpleChannelHandler; import org.jboss.netty.example.xlsvr.vo.XLResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * @author hankchen * 2012-1-30 下午03:22:24 */ @Sharable public class XLServerHandler extends SimpleChannelHandler { private static final Logger logger=LoggerFactory.getLogger(XLServerHandler.class); @Override public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { logger.info("messageReceived"); if (e.getMessage() instanceof String) { String content=(String)e.getMessage(); logger.info("content is "+content); if ("shutdown".equalsIgnoreCase(content)) { //e.getChannel().close(); XLServer.shutdown(); }else { sendResponse(ctx); } }else { logger.error("message is not a String."); e.getChannel().close(); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { logger.error(e.getCause().getMessage(),e.getCause()); e.getCause().printStackTrace(); e.getChannel().close(); } @Override public void channelConnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { logger.info("channelConnected"); sendResponse(ctx); } @Override public void channelClosed(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { logger.info("channelClosed"); //删除通道 XLServer.allChannels.remove(e.getChannel()); } @Override public void channelDisconnected(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { logger.info("channelDisconnected"); super.channelDisconnected(ctx, e); } @Override public void channelOpen(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { logger.info("channelOpen"); //增加通道 XLServer.allChannels.add(e.getChannel()); } /** * 发送响应内容 * @param ctx * @param e * @return */ private ChannelFuture sendResponse(ChannelHandlerContext ctx){ Channel channel=ctx.getChannel(); Random random=new Random(); XLResponse response=new XLResponse(); response.setEncode((byte)0); response.setResult(1); response.setValue("name","hankchen"); response.setValue("time", String.valueOf(System.currentTimeMillis())); response.setValue("age",String.valueOf(random.nextInt())); /** * 发送接收信息的时间戳到客户端 * 注意:Netty中所有的IO操作都是异步的! */ ChannelFuture future=channel.write(response); //发送内容 return future; } }
四、客户端实现
客户端的功能是连接服务器,发送10次请求,然后发送关闭服务器的命令,最后主动关闭客户端。
关键代码如下:
/** * Copyright (C): 2012 * @author hankchen * 2012-1-30 下午03:21:26 */ /** * 服务器特征: * 1、使用专用解码器解析服务器发过来的数据 * 2、客户端主动关闭连接 */ public class XLClient { public static final int port =XLServer.port; public static final String host ="localhost"; private static final Logger logger=LoggerFactory.getLogger(XLClient.class); private static final NioClientSocketChannelFactory clientSocketChannelFactory=new NioClientSocketChannelFactory(Executors.newCachedThreadPool(),Executors.newCachedThreadPool()); private static final ClientBootstrap clientBootstrap=new ClientBootstrap(clientSocketChannelFactory); /** * @param args * @throws Exception */ public static void main(String[] args) throws Exception { ChannelFuture future=XLClient.startup(); logger.info("future state is "+future.isSuccess()); } /** * 启动客户端 * @return * @throws Exception */ public static ChannelFuture startup() throws Exception { /** * 注意:由于XLClientHandler中有状态的成员变量,因此不能采用默认共享ChannelPipeline的方式 * 例如,下面的代码形式是错误的: * ChannelPipeline pipeline=clientBootstrap.getPipeline(); * pipeline.addLast("handler", new XLClientHandler()); */ clientBootstrap.setPipelineFactory(new XLClientPipelineFactory()); //只能这样设置 /** * 请注意,这里不存在使用“child.”前缀的配置项,客户端的SocketChannel实例不存在父级Channel对象 */ clientBootstrap.setOption("tcpNoDelay", true); clientBootstrap.setOption("keepAlive", true); ChannelFuture future=clientBootstrap.connect(new InetSocketAddress(host, port)); /** * 阻塞式的等待,直到ChannelFuture对象返回这个连接操作的成功或失败状态 */ future.awaitUninterruptibly(); /** * 如果连接失败,我们将打印连接失败的原因。 * 如果连接操作没有成功或者被取消,ChannelFuture对象的getCause()方法将返回连接失败的原因。 */ if (!future.isSuccess()) { future.getCause().printStackTrace(); }else { logger.info("client is connected to server "+host+":"+port); } return future; } /** * 关闭客户端 * @param future * @throws Exception */ public static void shutdown(ChannelFuture future) throws Exception{ try { /** * 主动关闭客户端连接,会阻塞等待直到通道关闭 */ future.getChannel().close().awaitUninterruptibly(); //future.getChannel().getCloseFuture().awaitUninterruptibly(); /** * 释放ChannelFactory通道工厂使用的资源。 * 这一步仅需要调用 releaseExternalResources()方法即可。 * 包括NIO Secector和线程池在内的所有资源将被自动的关闭和终止。 */ clientBootstrap.releaseExternalResources(); } catch (Exception e) { e.printStackTrace(); logger.error(e.getMessage(),e); } finally{ System.exit(1); logger.info("client is shutdown to server "+host+":"+port); } } }
XLClientPipelineFactory.java
public class XLClientPipelineFactory implements ChannelPipelineFactory{ @Override public ChannelPipeline getPipeline() throws Exception { ChannelPipeline pipeline=Channels.pipeline(); /** * 使用专用的解码器,解决数据分段的问题 * 从业务逻辑代码中分离协议处理部分总是一个很不错的想法。 */ pipeline.addLast("decoder", new XLClientDecoder()); /** * 有专门的编码解码器,这时处理器就不需要管数据分段和数据格式问题,只需要关注业务逻辑了! */ pipeline.addLast("handler", new XLClientHandler()); return pipeline; } }
XLClientHandler.java
/** * Copyright (C): 2012 * @author hankchen * 2012-1-30 下午03:21:52 */ /** * 服务器特征: * 1、使用专用的编码解码器,解决数据分段的问题 * 2、使用POJO替代ChannelBuffer传输 */ public class XLClientHandler extends SimpleChannelHandler { private static final Logger logger=LoggerFactory.getLogger(XLClientHandler.class); private final AtomicInteger count=new AtomicInteger(0); //计数器 @Override public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception { processMethod1(ctx, e); //处理方式一 } /** * @param ctx * @param e * @throws Exception */ public void processMethod1(ChannelHandlerContext ctx, MessageEvent e) throws Exception{ logger.info("processMethod1……,count="+count.addAndGet(1)); XLResponse serverTime=(XLResponse)e.getMessage(); logger.info("messageReceived,content:"+serverTime.toString()); Thread.sleep(1000); if (count.get()<10) { //从新发送请求获取最新的服务器时间 ctx.getChannel().write(ChannelBuffers.wrappedBuffer("again\r\n".getBytes())); }else{ //从新发送请求关闭服务器 ctx.getChannel().write(ChannelBuffers.wrappedBuffer("shutdown\r\n".getBytes())); } } @Override public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception { logger.info("exceptionCaught"); e.getCause().printStackTrace(); ctx.getChannel().close(); } @Override public void channelClosed(ChannelHandlerContext ctx, ChannelStateEvent e) throws Exception { logger.info("channelClosed"); super.channelClosed(ctx, e); } }
全文代码较多,写了很多注释,希望对读者有用,谢谢!