转载自:http://www.cnblogs.com/wade-luffy/p/6222960.html
ChannelHandler类似于Servlet的Filter过滤器,负责对I/O事件或者I/O操作进行拦截和处理,它可以选择性地拦截和处理自己感兴趣的事件,也可以透传和终止事件的传递。基于ChannelHandler接口,用户可以方便地进行业务逻辑定制,例如打印日志、统一封装异常信息、性能统计和消息编解码等。
ChannelHandler支持注解,目前支持的注解有两种。
1. Sharable:多个ChannelPipeline共用同一个ChannelHandler;
2. Skip:被Skip注解的方法不会被调用,直接被忽略。
来看ByteToMessageDecoder实现的channelRead方法:
如果是第一次接收的情况下,直接接data放入到cumulation中,这个cumulation是一个全局变量。这样做的好处就是cumulation相当于一个容器,在上层代码多次调用channelRead的时候,也就是当发送端的信息可能被接收端分多次接收的时候,这个容器存储信息
由于ByteToMessageDecoder并没有考虑TCP粘包和组包等场景,读半包需要用户解码器自己负责处理。正因为如此,对于大多数场景不会直接继承ByteToMessageDecoder,而是继承另外一些更高级的解码器来屏蔽半包的处理。
为什么称它为二次解码器呢?从SocketChannel读取到的TCP数据报是ByteBuffer,实际就是字节数组,首先需要将ByteBuffer缓冲区中的数据报读取出来,并将其解码为Java对象;然后对Java对象根据某些规则做二次解码,将其解码为另一个POJO对象。因为MessageToMessageDecoder在ByteToMessageDecoder之后,所以称之为二次解码器。
二次解码器在实际的商业项目中非常有用,以HTTP+XML协议栈为例,第一次解码往往是将字节数组解码成HttpRequest对象,然后对HttpRequest消息中的消息体字符串进行二次解码,将XML格式的字符串解码为POJO对象,这就用到了二次解码器。
看看channelRead方法的源码:
1. 固定长度,例如每120个字节代表一个整包消息,不足的前面补零。解码器在处理这类定常消息的时候比较简单,每次读到指定长度的字节后再进行解码。DelimiterBased FrameDecoder
2. 通过回车换行符区分消息,例如FTP协议。这类区分消息的方式多用于文本协议。LineBasedFrameDecoder
3. 通过分隔符区分整包消息。 DelimiterBased FrameDecoder
4. 通过指定长度来标识整包消息。LengthFieldBasedFrameDecoder
如果消息是通过长度进行区分的,LengthFieldBasedFrameDecoder都可以自动处理粘包和半包问题,只需要传入正确的参数,即可轻松搞定“读半包”问题。
LengthFieldBasedFrameDecoder源码中decode部分:
通过LengthFieldPrepender可以将待发送消息的长度写入到ByteBuf的前2个字节,编码后的消息组成为长度字段+原消息的方式。通过设置LengthFieldPrepender为true,消息长度将包含长度本身占用的字节数。
ChannelHandler类似于Servlet的Filter过滤器,负责对I/O事件或者I/O操作进行拦截和处理,它可以选择性地拦截和处理自己感兴趣的事件,也可以透传和终止事件的传递。基于ChannelHandler接口,用户可以方便地进行业务逻辑定制,例如打印日志、统一封装异常信息、性能统计和消息编解码等。
ChannelHandler支持注解,目前支持的注解有两种。
1. Sharable:多个ChannelPipeline共用同一个ChannelHandler;
2. Skip:被Skip注解的方法不会被调用,直接被忽略。
ByteToMessageDecoder
利用NIO进行网络编程时,往往需要将读取到的字节数组或者字节缓冲区解码为业务可以使用的POJO对象。为了方便业务将ByteBuf解码成业务POJO对象,Netty提供了ByteToMessageDecoder抽象工具解码类。public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {
…
protected abstract void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception;
… ByteToMessageDecoder继承ChannelInboundHandlerAdapter,用户自定义的解码器如果继承ByteToMessageDecoder,只需要实现void decode(ChannelHandler Context ctx, ByteBuf in, List<Object> out)抽象方法即可完成ByteBuf到POJO对象的解码。来看ByteToMessageDecoder实现的channelRead方法:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//首先判断需要解码的msg对象是否是ByteBuf,如果是ByteBuf才需要进行解码,否则直接透传。
if (msg instanceof ByteBuf) {
RecyclableArrayList out = RecyclableArrayList.newInstance();
try {
ByteBuf data = (ByteBuf) msg;
//通过cumulation是否为空判断解码器是否缓存了没有解码完成的半包消息
first = cumulation == null;
if (first) {
//如果为空,说明是首次解码或者最近一次已经处理完了半包消息,没有缓存的半包消息需要处理,直接将需要解码的ByteBuf赋值给cumulation;
cumulation = data;
} else {
//如果cumulation缓存有上次没有解码完成的ByteBuf,则进行复制操作,将需要解码的ByteBuf复制到cumulation中
//在复制之前需要对cumulation的可写缓冲区进行判断,如果不足则需要动态扩展
if (cumulation.writerIndex() > cumulation.maxCapacity() - data.readableBytes()) {
//扩展的代码很简单,利用字节缓冲区分配器重新分配一个新的ByteBuf,将老的cumulation复制到新的ByteBuf中,释放cumulation。
//此处内存扩展没有采用倍增或者步进的方式,分配的缓冲区恰恰够用,此处的算法可以优化下,以防止连续半包导致的频繁缓冲区扩张和内存复制。
expandCumulation(ctx, data.readableBytes());
}
//半包解码前:(半包消息1= cumulation.readableBytes())
//半包解码后:(半包消息2= data.readableBytes()=半包消息1+msg.readableBytes())
cumulation.writeBytes(data);
data.release();
}
//复制操作完成之后释放需要解码的ByteBuf对象,调用callDecode方法进行解码
//对ByteBuf进行循环解码,循环的条件是解码缓冲区对象中有可读的字节
//调用抽象decode方法,由用户的子类解码器进行解码
//解码后需要对当前的pipeline状态和解码结果进行判断
//如果当前的ChannelHandlerContext已经被移除,则不能继续进行解码,直接退出循环;
//如果输出的out列表长度没变化,说明解码没有成功,需要针对以下不同场景进行判断。
//A.如果用户解码器没有消费ByteBuf,则说明是个半包消息,需要由I/O线程继续读取后续的数据报,在这种场景下要退出循环。
//B.如果用户解码器消费了ByteBuf,说明可以解码可以继续进行。业务解码器需要遵守Netty的某些契约,解码器才能正常工作,否则可能会导致功能错误
//最重要的契约就是:如果业务解码器认为当前的字节缓冲区无法完成业务层的解码,需要将readIndex复位,告诉Netty解码条件不满足应当退出解码,继续读取数据报。
//如果用户解码器没有消费ByteBuf,oldInputLength == in.readableBytes(),但是却解码出了一个或者多个对象,这种行为被认为是非法的,需要抛出DecoderException异常。
//最后通过isSingleDecode进行判断,如果是单条消息解码器,第一次解码完成之后就退出循环。
callDecode(ctx, cumulation, out);
} catch (DecoderException e) {
throw e;
} catch (Throwable t) {
throw new DecoderException(t);
} finally {
if (cumulation != null && !cumulation.isReadable()) {
cumulation.release();
cumulation = null;
}
int size = out.size();
decodeWasNull = size == 0;
for (int i = 0; i < size; i ++) {
ctx.fireChannelRead(out.get(i));
}
out.recycle();
}
} else {
ctx.fireChannelRead(msg);
}
} 代码一开始就判断msg是不是ByteBuf,也就是为什么一般把解码器放在Channelhandler链的第一个的原因了,这样做的好处就是msg一般就是ByteBuf,不会被其他的业务逻辑影响。而且这个msg极有可能是directByteBuf,也就是说这是堆外内存的,因为ByteBuf在传输的时候堆外内存传输的时候可以少一次复制。如果是第一次接收的情况下,直接接data放入到cumulation中,这个cumulation是一个全局变量。这样做的好处就是cumulation相当于一个容器,在上层代码多次调用channelRead的时候,也就是当发送端的信息可能被接收端分多次接收的时候,这个容器存储信息
由于ByteToMessageDecoder并没有考虑TCP粘包和组包等场景,读半包需要用户解码器自己负责处理。正因为如此,对于大多数场景不会直接继承ByteToMessageDecoder,而是继承另外一些更高级的解码器来屏蔽半包的处理。
MessageToMessageDecoder
MessageToMessageDecoder实际上是Netty的二次解码器,它的职责是将一个对象二次解码为其他对象。为什么称它为二次解码器呢?从SocketChannel读取到的TCP数据报是ByteBuffer,实际就是字节数组,首先需要将ByteBuffer缓冲区中的数据报读取出来,并将其解码为Java对象;然后对Java对象根据某些规则做二次解码,将其解码为另一个POJO对象。因为MessageToMessageDecoder在ByteToMessageDecoder之后,所以称之为二次解码器。
二次解码器在实际的商业项目中非常有用,以HTTP+XML协议栈为例,第一次解码往往是将字节数组解码成HttpRequest对象,然后对HttpRequest消息中的消息体字符串进行二次解码,将XML格式的字符串解码为POJO对象,这就用到了二次解码器。
看看channelRead方法的源码:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
//先通过RecyclableArrayList创建一个新的可循环利用的RecyclableArrayList,
RecyclableArrayList out = RecyclableArrayList.newInstance();
try {
//对解码的消息类型进行判断,通过类型参数校验器看是否是可接收的类型,如果是则校验通过
if (acceptInboundMessage(msg)) {
@SuppressWarnings("unchecked")
I cast = (I) msg;
try {
//直接调用decode抽象方法,由具体实现子类进行消息解码
decode(ctx, cast, out);
} finally {
//解码完成之后,调用ReferenceCountUtil的release方法来释放被解码的msg对象。
ReferenceCountUtil.release(cast);
}
} else {
//如果需要解码的对象不是当前解码器可以接收和处理的类型,则将它加入到RecyclableArrayList中不进行解码。
out.add(msg);
}
} catch (DecoderException e) {
throw e;
} catch (Exception e) {
throw new DecoderException(e);
} finally {
int size = out.size();
//最后,对RecyclableArrayList进行遍历,循环调用ChannelHandlerContext的fireChannelRead方法,
//通知后续的ChannelHandler继续进行处理。循环通知完成之后,通过recycle方法释放RecyclableArrayList对象。
for (int i = 0; i < size; i ++) {
ctx.fireChannelRead(out.get(i));
}
out.recycle();
}
}LengthFieldBasedFrameDecoder
如何区分一个整包消息,通常有如下4种做法。1. 固定长度,例如每120个字节代表一个整包消息,不足的前面补零。解码器在处理这类定常消息的时候比较简单,每次读到指定长度的字节后再进行解码。DelimiterBased FrameDecoder
2. 通过回车换行符区分消息,例如FTP协议。这类区分消息的方式多用于文本协议。LineBasedFrameDecoder
3. 通过分隔符区分整包消息。 DelimiterBased FrameDecoder
4. 通过指定长度来标识整包消息。LengthFieldBasedFrameDecoder
如果消息是通过长度进行区分的,LengthFieldBasedFrameDecoder都可以自动处理粘包和半包问题,只需要传入正确的参数,即可轻松搞定“读半包”问题。
LengthFieldBasedFrameDecoder源码中decode部分:
@Override
protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
//调用内部的decode(ChannelHandlerContext ctx, ByteBuf in)方法,如果解码成功,将其加入到输出的List<Object> out列表中。
Object decoded = decode(ctx, in);
if (decoded != null) {
out.add(decoded);
}
}
protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
//判断discardingTooLongFrame标识,看是否需要丢弃当前可读的字节缓冲区,如果为真,则执行丢弃操作
if (discardingTooLongFrame) {
long bytesToDiscard = this.bytesToDiscard;
//判断需要丢弃的字节长度,由于丢弃的字节数不能大于当前缓冲区可读的字节数,所以需要通过Math.min(bytesToDiscard, in.readableBytes())函数进行选择,
//取bytesToDiscard和缓冲区可读字节数之中的最小值。
int localBytesToDiscard = (int) Math.min(bytesToDiscard, in.readableBytes());
//计算获取需要丢弃的字节数之后,调用ByteBuf的skipBytes方法跳过需要忽略的字节长度,
in.skipBytes(localBytesToDiscard);
//然后bytesToDiscard减去已经忽略的字节长度。
bytesToDiscard -= localBytesToDiscard;
this.bytesToDiscard = bytesToDiscard;
//最后判断是否已经达到需要忽略的字节数,达到的话对discardingTooLongFrame等进行置位
failIfNecessary(false);
}
//对当前缓冲区的可读字节数和长度偏移量进行对比,如果小于长度偏移量,则说明当前缓冲区的数据报不够,需要返回空,由I/O线程继续读取后续的数据报。
if (in.readableBytes() < lengthFieldEndOffset) {
return null;
}
int actualLengthFieldOffset = in.readerIndex() + lengthFieldOffset;
//通过读索引和lengthFieldOffset计算获取实际的长度字段索引,然后通过索引值获取消息报文的长度字段
//根据长度字段自身的字节长度进行判断,共有以下6种可能的取值。
//长度所占字节为1,通过ByteBuf的getUnsignedByte方法获取长度值;
//长度所占字节为2,通过ByteBuf的getUnsignedShort方法获取长度值;
//长度所占字节为3,通过ByteBuf的getUnsignedMedium方法获取长度值;
//长度所占字节为4,通过ByteBuf的getUnsignedInt方法获取长度值;
//长度所占字节为8,通过ByteBuf的getLong方法获取长度值;
//其他长度不支持,抛出DecoderException异常。
long frameLength = getUnadjustedFrameLength(in, actualLengthFieldOffset, lengthFieldLength, byteOrder);
//如果长度小于0,说明报文非法,跳过lengthFieldEndOffset个字节,抛出Corrupted FrameException异常。
if (frameLength < 0) {
in.skipBytes(lengthFieldEndOffset);
throw new CorruptedFrameException(
"negative pre-adjustment length field: " + frameLength);
}
//根据lengthFieldEndOffset和lengthAdjustment字段进行长度修正
frameLength += lengthAdjustment + lengthFieldEndOffset;
//如果修正后的报文长度小于lengthFieldEndOffset,则说明是非法数据报,需要抛出CorruptedFrameException异常。
if (frameLength < lengthFieldEndOffset) {
in.skipBytes(lengthFieldEndOffset);
throw new CorruptedFrameException(
"Adjusted frame length (" + frameLength + ") is less " +
"than lengthFieldEndOffset: " + lengthFieldEndOffset);
}
//如果修正后的报文长度大于ByteBuf的最大容量,说明接收到的消息长度大于系统允许的最大长度上限,
//需要设置discardingTooLongFrame,计算需要丢弃的字节数,根据情况选择是否需要抛出解码异常。
if (frameLength > maxFrameLength) {
//丢弃的策略如下:frameLength减去ByteBuf的可读字节数就是需要丢弃的字节长度,
//如果需要丢弃的字节数discard小于缓冲区可读的字节数,则直接丢弃整包消息。
//如果需要丢弃的字节数大于当前可读字节数,说明即便当前所有可读的字节数全部丢弃,也无法完成任务,则设置discardingTooLongFrame为true,下次解码的时候继续丢弃。
//丢弃操作完成之后,调用failIfNecessary方法根据实际情况抛出异常。
long discard = frameLength - in.readableBytes();
tooLongFrameLength = frameLength;
if (discard < 0) {
// buffer contains more bytes then the frameLength so we can discard all now
in.skipBytes((int) frameLength);
} else {
// Enter the discard mode and discard everything received so far.
discardingTooLongFrame = true;
bytesToDiscard = discard;
in.skipBytes(in.readableBytes());
}
failIfNecessary(true);
return null;
}
//如果当前的可读字节数小于frameLength,说明是个半包消息,需要返回空,由I/O线程继续读取后续的数据报,等待下次解码。
// never overflows because it's less than maxFrameLength
int frameLengthInt = (int) frameLength;
if (in.readableBytes() < frameLengthInt) {
return null;
}
//对需要忽略的消息头字段进行判断,如果大于消息长度frameLength,说明码流非法,需要忽略当前的数据报,抛出CorruptedFrameException异常。
if (initialBytesToStrip > frameLengthInt) {
in.skipBytes(frameLengthInt);
throw new CorruptedFrameException(
"Adjusted frame length (" + frameLength + ") is less " +
"than initialBytesToStrip: " + initialBytesToStrip);
}
//通过ByteBuf的skipBytes方法忽略消息头中不需要的字段,得到整包ByteBuf。
in.skipBytes(initialBytesToStrip);
int readerIndex = in.readerIndex();
int actualFrameLength = frameLengthInt - initialBytesToStrip;
//通过extractFrame方法获取解码后的整包消息缓冲区
//根据消息的实际长度分配一个新的ByteBuf对象,将需要解码的ByteBuf可写缓冲区复制到新创建的ByteBuf中并返回,
//返回之后更新原解码缓冲区ByteBuf为原读索引+消息报文的实际长度(actualFrameLength)。
ByteBuf frame = extractFrame(ctx, in, readerIndex, actualFrameLength);
in.readerIndex(readerIndex + actualFrameLength);
return frame;
} 实际使用时不需要对LengthFieldBasedFrameDecoder进行定制。只需要了解每个参数的用法,再结合用户的业务场景进行参数设置,即可实现半包消息的自动解码,后面的业务解码器得到的是个完整的整包消息,不用再额外考虑如何处理半包。这极大地降低了开发难度,提升了开发效率。MessageToByteEncoder
MessageToByteEncoder负责将POJO对象编码成ByteBuf,用户的编码器继承MessageToByteEncoder,实现void encode(ChannelHandlerContext ctx, I msg, ByteBuf out)接口。 @Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
ByteBuf buf = null;
try {
//首先判断当前编码器是否支持需要发送的消息,如果不支持则直接透传;
if (acceptOutboundMessage(msg)) {
@SuppressWarnings("unchecked")
I cast = (I) msg;
//对于直接内存分配ioBuffer(堆外内存),对于堆内存通过heapBuffer方法分配。
if (preferDirect) {
buf = ctx.alloc().ioBuffer();
} else {
buf = ctx.alloc().heapBuffer();
}
try {
//编码使用的缓冲区分配完成之后,调用encode抽象方法进行编码,
encode(ctx, cast, buf);
} finally {
//编码完成之后,调用ReferenceCountUtil的release方法释放编码对象msg。
ReferenceCountUtil.release(cast);
}
//对编码后的ByteBuf进行以下判断。
//如果缓冲区包含可发送的字节,则调用ChannelHandlerContext的write方法发送ByteBuf;
//如果缓冲区没有包含可写的字节,则需要释放编码后的ByteBuf,写入一个空的ByteBuf到ChannelHandlerContext中。
if (buf.isReadable()) {
ctx.write(buf, promise);
} else {
buf.release();
ctx.write(Unpooled.EMPTY_BUFFER, promise);
}
//发送操作完成之后,在方法退出之前释放编码缓冲区ByteBuf对象。
buf = null;
} else {
//如果不支持则直接透传;
ctx.write(msg, promise);
}
} catch (EncoderException e) {
throw e;
} catch (Throwable e) {
throw new EncoderException(e);
} finally {
//发送操作完成之后,在方法退出之前释放编码缓冲区ByteBuf对象。
if (buf != null) {
buf.release();
}
}
}MessageToMessageEncoder
用户的编码器继承MessageToMessageEncoder解码器,实现void encode(Channel HandlerContext ctx, I msg, List out)方法即可。注意,它与MessageToByteEncoder的区别是输出是对象列表而不是ByteBuf。 @Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
RecyclableArrayList out = null;
try {
//创建RecyclableArrayList对象,判断当前需要编码的对象是否是编码器可处理的类型,如果不是,则忽略,执行下一个ChannelHandler的write方法。
if (acceptOutboundMessage(msg)) {
out = RecyclableArrayList.newInstance();
@SuppressWarnings("unchecked")
I cast = (I) msg;
try {
//具体的编码方法实现由用户子类编码器负责完成
encode(ctx, cast, out);
} finally {
ReferenceCountUtil.release(cast);
}
//如果编码后的RecyclableArrayList为空,说明编码没有成功,释放RecyclableArrayList引用。
if (out.isEmpty()) {
out.recycle();
out = null;
throw new EncoderException(
StringUtil.simpleClassName(this) + " must produce at least one message.");
}
} else {
ctx.write(msg, promise);
}
} catch (EncoderException e) {
throw e;
} catch (Throwable t) {
throw new EncoderException(t);
} finally {
//如果编码成功,则通过遍历RecyclableArrayList,循环发送编码后的POJO对象
if (out != null) {
final int sizeMinusOne = out.size() - 1;
if (sizeMinusOne >= 0) {
for (int i = 0; i < sizeMinusOne; i ++) {
ctx.write(out.get(i));
}
ctx.write(out.get(sizeMinusOne), promise);
}
out.recycle();
}
}
}LengthFieldPrepender
如果协议中的第一个字段为长度字段,Netty提供了LengthFieldPrepender编码器,它可以计算当前待发送消息的二进制字节长度,将该长度添加到ByteBuf的缓冲区头中。通过LengthFieldPrepender可以将待发送消息的长度写入到ByteBuf的前2个字节,编码后的消息组成为长度字段+原消息的方式。通过设置LengthFieldPrepender为true,消息长度将包含长度本身占用的字节数。
@Override
protected void encode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
int length = msg.readableBytes() + lengthAdjustment;
//首先对长度字段进行设置,如果需要包含消息长度自身,则在原来长度的基础之上再加上lengthFieldLength的长度。
if (lengthIncludesLengthFieldLength) {
length += lengthFieldLength;
}
//如果调整后的消息长度小于0,则抛出参数非法异常。
if (length < 0) {
throw new IllegalArgumentException(
"Adjusted frame length (" + length + ") is less than zero");
}
//对消息长度自身所占的字节数进行判断,以便采用正确的方法将长度字段写入到ByteBuf中,共有以下6种可能。
switch (lengthFieldLength) {
case 1:
//长度字段所占字节为1:如果使用1个Byte字节代表消息长度,则最大长度需要小于256个字节。对长度进行校验,如果校验失败,则抛出参数非法异常;
//若校验通过,则创建新的ByteBuf并通过writeByte将长度值写入到ByteBuf中。
if (length >= 256) {
throw new IllegalArgumentException(
"length does not fit into a byte: " + length);
}
out.add(ctx.alloc().buffer(1).writeByte((byte) length));
break;
case 2:
//长度字段所占字节为2:如果使用2个Byte字节代表消息长度,则最大长度需要小于65536个字节,对长度进行校验,如果校验失败,则抛出参数非法异常;
//若校验通过,则创建新的ByteBuf并通过writeShort将长度值写入到ByteBuf中。
if (length >= 65536) {
throw new IllegalArgumentException(
"length does not fit into a short integer: " + length);
}
out.add(ctx.alloc().buffer(2).writeShort((short) length));
break;
case 3:
//长度字段所占字节为3:如果使用3个Byte字节代表消息长度,则最大长度需要小于16777216个字节,对长度进行校验,如果校验失败,则抛出参数非法异常;
//若校验通过,则创建新的ByteBuf并通过writeMedium将长度值写入到ByteBuf中。
if (length >= 16777216) {
throw new IllegalArgumentException(
"length does not fit into a medium integer: " + length);
}
out.add(ctx.alloc().buffer(3).writeMedium(length));
break;
case 4:
//长度字段所占字节为4:创建新的ByteBuf,并通过writeInt将长度值写入到ByteBuf中。
out.add(ctx.alloc().buffer(4).writeInt(length));
break;
case 8:
//长度字段所占字节为8:创建新的ByteBuf,并通过writeLong将长度值写入到ByteBuf中。
out.add(ctx.alloc().buffer(8).writeLong(length));
break;
default:
//其他长度值:直接抛出Error。
throw new Error("should not reach here");
}
//最后将原需要发送的ByteBuf复制到List<Object> out中,完成编码。
out.add(msg.retain());
}
本文介绍Netty中的关键IO解码与编码组件,包括ChannelHandler、ByteToMessageDecoder、MessageToMessageDecoder、LengthFieldBasedFrameDecoder及各类编码器如MessageToByteEncoder等的工作原理与应用场景。
1459

被折叠的 条评论
为什么被折叠?



