编解码器
编解码技术这是实现网络通信的基础,让我们可以定义任何满足业务需求的应用层协议。在网络编程中,我们经常会使用各种网络传输协议,其中 TCP 是最常用的协议。我们首先需要了解的是 TCP 最基本的拆包/粘包问题以及常用的解决方案,才能更好地理解 Netty 的编解码框架。
出现拆包/粘包的原因
在网络通信的过程中,每次可以发送的数据包大小是受多种因素限制的,如 MTU 传输单元大小、MSS 最大分段大小、滑动窗口等。如果一次传输的网络包数据大小超过传输单元大小,那么我们的数据可能会拆分为多个数据包发送出去。如果每次请求的网络包数据都很小,一共请求了 10000 次,TCP 并不会分别发送 10000 次。因为 TCP 采用的 Nagle 算法对此作出了优化。
MTU 最大传输单元和 MSS 最大分段大小
MTU(Maxitum Transmission Unit) 是链路层一次最大传输数据的大小。MTU 一般来说大小为 1500 byte。MSS(Maximum Segement Size) 是指 TCP 最大报文段长度,它是传输层一次发送最大数据的大小。如下图所示,MTU 和 MSS 一般的计算关系为:MSS = MTU - IP 首部 - TCP首部,如果 MSS + TCP 首部 + IP 首部 > MTU,那么数据包将会被拆分为多个发送。这就是拆包现象。
滑动窗口
滑动窗口是 TCP 传输层用于流量控制的一种有效措施,也被称为通告窗口。滑动窗口是数据接收方设置的窗口大小,随后接收方会把窗口大小告诉发送方,以此限制发送方每次发送数据的大小,从而达到流量控制的目的。这样数据发送方不需要每发送一组数据就阻塞等待接收方确认,允许发送方同时发送多个数据分组,每次发送的数据都会被限制在窗口大小内。由此可见,滑动窗口可以大幅度提升网络吞吐量。
那么 TCP 报文是怎么确保数据包按次序到达且不丢数据呢?首先,所有的数据帧都是有编号的,TCP 并不会为每个报文段都回复 ACK 响应,它会对多个报文段回复一次 ACK。假设有三个报文段 A、B、C,发送方先发送了B、C,接收方则必须等待 A 报文段到达,如果一定时间内仍未等到 A 报文段,那么 B、C 也会被丢弃,发送方会发起重试。如果已接收到 A 报文段,那么将会回复发送方一次 ACK 确认。
Nagle 算法
Nagle 算法于 1984 年被福特航空和通信公司定义为 TCP/IP 拥塞控制方法。它主要用于解决频繁发送小数据包而带来的网络拥塞问题。试想如果每次需要发送的数据只有 1 字节,加上 20 个字节 IP Header 和 20 个字节 TCP Header,每次发送的数据包大小为 41 字节,但是只有 1 字节是有效信息,这就造成了非常大的浪费。Nagle 算法可以理解为批量发送,也是我们平时编程中经常用到的优化思路,它是在数据未得到确认之前先写入缓冲区,等待数据确认或者缓冲区积攒到一定大小再把数据包发送出去。
Linux 在默认情况下是开启 Nagle 算法的,在大量小数据包的场景下可以有效地降低网络开销。但如果你的业务场景每次发送的数据都需要获得及时响应,那么 Nagle 算法就不能满足你的需求了,因为 Nagle 算法会有一定的数据延迟。你可以通过 Linux 提供的 TCP_NODELAY 参数禁用 Nagle 算法。Netty 中为了使数据传输延迟最小化,就默认禁用了 Nagle 算法,这一点与 Linux 操作系统的默认行为是相反的。
在客户端和服务端通信的过程中,服务端一次读到的数据大小是不确定的。如上图所示,拆包/粘包可能会出现以下五种情况:
- 服务端恰巧读到了两个完整的数据包 A 和 B,没有出现拆包/粘包问题;
- 服务端接收到 A 和 B 粘在一起的数据包,服务端需要解析出 A 和 B;
- 服务端收到完整的 A 和 B 的一部分数据包 B-1,服务端需要解析出完整的 A,并等待读取完整的 B 数据包;
- 服务端接收到 A 的一部分数据包 A-1,此时需要等待接收到完整的 A 数据包;
- 数据包 A 较大,服务端需要多次才可以接收完数据包 A。
由于拆包/粘包问题的存在,数据接收方很难界定数据包的边界在哪里,很难识别出一个完整的数据包。所以需要提供一种机制来识别数据包的界限,这也是解决拆包/粘包的唯一方法:定义应用层的通信协议。下面我们一起看下主流协议的解决方案。
1.消息长度固定
每个数据报文都需要一个固定的长度。当接收方累计读取到固定长度的报文后,就认为已经获得一个完整的消息。当发送方的数据小于固定长度时,则需要空位补齐。
+----+------+------+---+----+
| AB | CDEF | GHIJ | K | LM |
+----+------+------+---+----+
假设我们的固定长度为 4 字节,那么如上所示的 5 条数据一共需要发送 4 个报文:
+------+------+------+------+
| ABCD | EFGH | IJKL | M000 |
+------+------+------+------+
消息定长法使用非常简单,但是缺点也非常明显,无法很好设定固定长度的值,如果长度太大会造成字节浪费,长度太小又会影响消息传输,所以在一般情况下消息定长法不会被采用。
2.特定分隔符
既然接收方无法区分消息的边界,那么我们可以在每次发送报文的尾部加上特定分隔符,接收方就可以根据特殊分隔符进行消息拆分。以下报文根据特定分隔符 按行解析,即可得到 AB、CDEF、GHIJ、K、LM 五条原始报文。
+-------------------------+
| AB
CDEF
GHIJ
K
LM
|
+-------------------------+
由于在发送报文时尾部需要添加特定分隔符,所以对于分隔符的选择一定要避免和消息体中字符相同,以免冲突。否则可能出现错误的消息拆分。比较推荐的做法是将消息进行编码,例如 base64 编码,然后可以选择 64 个编码字符之外的字符作为特定分隔符。特定分隔符法在消息协议足够简单的场景下比较高效,例如大名鼎鼎的 Redis 在通信过程中采用的就是换行分隔符。
消息长度 + 消息内容
消息头 消息体
+--------+----------+
| Length | Content |
+--------+----------+
消息长度 + 消息内容是项目开发中最常用的一种协议,如上展示了该协议的基本格式。消息头中存放消息的总长度,例如使用 4 字节的 int 值记录消息的长度,消息体实际的二进制的字节数据。接收方在解析数据时,首先读取消息头的长度字段 Len,然后紧接着读取长度为 Len 的字节数据,该数据即判定为一个完整的数据报文。依然以上述提到的原始字节数据为例,使用该协议进行编码后的结果如下所示:
+-----+-------+-------+----+-----+
| 2AB | 4CDEF | 4GHIJ | 1K | 2LM |
+-----+-------+-------+----+-----+
消息长度 + 消息内容的使用方式非常灵活,且不会存在消息定长法和特定分隔符法的明显缺陷。当然在消息头中不仅只限于存放消息的长度,而且可以自定义其他必要的扩展字段,例如消息版本、算法类型等。
基于消息长度 + 消息内容的变长协议是项目开发中最常用的一种方法。
通信协议设计
所谓协议,就是通信双方事先商量好的接口暗语,在 TCP 网络编程中,发送方和接收方的数据包格式都是二进制,发送方将对象转化成二进制流发送给接收方,接收方获得二进制数据后需要知道如何解析成对象,所以协议是双方能够正常通信的基础。
目前市面上已经有不少通用的协议,例如 HTTP、HTTPS、JSON-RPC、FTP、IMAP、Protobuf 等。通用协议兼容性好,易于维护,各种异构系统之间可以实现无缝对接。如果在满足业务场景以及性能需求的前提下,推荐采用通用协议的方案。相比通用协议,自定义协议主要有以下优点。
- 极致性能:通用的通信协议考虑了很多兼容性的因素,必然在性能方面有所损失。
- 扩展性:自定义的协议相比通用协议更好扩展,可以更好地满足自己的业务需求。
- 安全性:通用协议是公开的,很多漏洞已经很多被黑客攻破。自定义协议更加安全,因为黑客需要先破解你的协议内容。
一个完备的网络协议需要具备一下基本要素:
1. 魔数
魔数是通信双方协商的一个暗号,通常采用固定的几个字节表示。魔数的作用是防止任何人随便向服务器的端口上发送数据。服务端在接收到数据时会解析出前几个固定字节的魔数,然后做正确性比对。如果和约定的魔数不匹配,则认为是非法数据,可以直接关闭连接或者采取其他措施以增强系统的安全防护。魔数的思想在压缩算法、Java Class 文件等场景中都有所体现,例如 Class 文件开头就存储了魔数 0xCAFEBABE,在加载 Class 文件时首先会验证魔数的正确性。
2. 协议版本号
随着业务需求的变化,协议可能需要对结构或字段进行改动,不同版本的协议对应的解析方法也是不同的。所以在生产级项目中强烈建议预留协议版本号这个字段。
3. 序列化算法
序列化算法字段表示数据发送方应该采用何种方法将请求的对象转化为二进制,以及如何再将二进制转化为对象,如 JSON、Hessian、Java 自带序列化等。
4. 报文类型
在不同的业务场景中,报文可能存在不同的类型。例如在 RPC 框架中有请求、响应、心跳等类型的报文,在 IM 即时通信的场景中有登陆、创建群聊、发送消息、接收消息、退出群聊等类型的报文。
5. 长度域字段
长度域字段代表请求数据的长度,接收方根据长度域字段获取一个完整的报文。
6. 请求数据
请求数据通常为序列化之后得到的二进制流,每种请求数据的内容是不一样的。
7. 状态
状态字段用于标识请求是否正常。一般由被调用方设置。例如一次 RPC 调用失败,状态字段可被服务提供方设置为异常状态。
8. 保留字段
保留字段是可选项,为了应对协议升级的可能性,可以预留若干字节的保留字段,以备不时之需。
一个较为通用的协议示例:
+---------------------------------------------------------------+
| 魔数 2byte | 协议版本号 1byte | 序列化算法 1byte | 报文类型 1byte |
+---------------------------------------------------------------+
| 状态 1byte | 保留字段 4byte | 数据长度 4byte |
+---------------------------------------------------------------+
| 数据内容 (长度不定) |
+---------------------------------------------------------------+
Netty 实现自定义通信协议
Netty 作为一个非常优秀的网络通信框架,已经为我们提供了非常丰富的编解码抽象基类,帮助我们更方便地基于这些抽象基类扩展实现自定义协议。
Netty 中编解码器分类:
Netty 常用编码器类型:
- MessageToByteEncoder 对象编码成字节流;
- MessageToMessageEncoder 一种消息类型编码成另外一种消息类型。
Netty 常用解码器类型:
- ByteToMessageDecoder/ReplayingDecoder 将字节流解码为消息对象;
- MessageToMessageDecoder 将一种消息类型解码为另外一种消息类型。
编解码器可以分为一次解码器和二次解码器,一次解码器用于解决 TCP 拆包/粘包问题,按协议解析后得到的字节数据。如果你需要对解析后的字节数据做对象模型的转换,这时候便需要用到二次解码器,同理编码器的过程是反过来的。
- 一次编解码器:MessageToByteEncoder/ByteToMessageDecoder。
- 二次编解码器:MessageToMessageEncoder/MessageToMessageDecoder。
下面我们对 Netty 中常用的抽象编解码类进行详细的介绍。
抽象编码类
编码类是 ChanneOutboundHandler 的抽象类实现,具体操作的是 Outbound 出站数据。
MessageToByteEncoder
MessageToByteEncoder 用于将对象编码成字节流,MessageToByteEncoder 提供了唯一的 encode 抽象方法,我们只需要实现encode 方法即可完成自定义编码。
public abstract class MessageToByteEncoder<I> extends ChannelOutboundHandlerAdapter { private final TypeParameterMatcher matcher; private final boolean preferDirect; /** * see {@link #MessageToByteEncoder(boolean)} with {@code true} as boolean parameter. */ protected MessageToByteEncoder() { this(true); } /** * see {@link #MessageToByteEncoder(Class, boolean)} with {@code true} as boolean value. */ protected MessageToByteEncoder(Class<? extends I> outboundMessageType) { this(outboundMessageType, true); } /** * Create a new instance which will try to detect the types to match out of the type parameter of the class. * * @param preferDirect {@code true} if a direct {@link ByteBuf} should be tried to be used as target for * the encoded messages. If {@code false} is used it will allocate a heap * {@link ByteBuf}, which is backed by an byte array. */ protected MessageToByteEncoder(boolean preferDirect) { matcher = TypeParameterMatcher.find(this, MessageToByteEncoder.class, "I"); this.preferDirect = preferDirect; } /** * Create a new instance * * @param outboundMessageType The type of messages to match * @param preferDirect {@code true} if a direct {@link ByteBuf} should be tried to be used as target for * the encoded messages. If {@code false} is used it will allocate a heap * {@link ByteBuf}, which is backed by an byte array. */ protected MessageToByteEncoder(Class<? extends I> outboundMessageType, boolean preferDirect) { matcher = TypeParameterMatcher.get(outboundMessageType); this.preferDirect = preferDirect; } /** * Returns {@code true} if the given message should be handled. If {@code false} it will be passed to the next * {@link ChannelOutboundHandler} in the {@link ChannelPipeline}. */ public boolean acceptOutboundMessage(Object msg) throws Exception { return matcher.match(msg); } @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; buf = allocateBuffer(ctx, cast, preferDirect); try { encode(ctx, cast, buf); } finally { ReferenceCountUtil.release(cast); } if (buf.isReadable()) { ctx.write(buf, promise); } else { buf.release(); ctx.write(Unpooled.EMPTY_BUFFER, promise); } buf = null; } else { ctx.write(msg, promise); } } catch (EncoderException e) { throw e; } catch (Throwable e) { throw new EncoderException(e); } finally { if (buf != null) { buf.release(); } } } /** * Allocate a {@link ByteBuf} which will be used as argument of {@link #encode(ChannelHandlerContext, I, ByteBuf)}. * Sub-classes may override this method to return {@link ByteBuf} with a perfect matching {@code initialCapacity}. */ protected ByteBuf allocateBuffer(ChannelHandlerContext ctx, @SuppressWarnings("unused") I msg, boolean preferDirect) throws Exception { if (preferDirect) { return ctx.alloc().ioBuffer(); } else { return ctx.alloc().heapBuffer(); } } /** * Encode a message into a {@link ByteBuf}. This method will be called for each written message that can be handled * by this encoder. * * @param ctx the {@link ChannelHandlerContext} which this {@link MessageToByteEncoder} belongs to * @param msg the message to encode * @param out the {@link ByteBuf} into which the encoded message will be written * @throws Exception is thrown if an error occurs */ protected abstract void encode(ChannelHandlerContext ctx, I msg, ByteBuf out) throws Exception; protected boolean isPreferDirect() { return preferDirect; } }
这里使用泛型是因为要针对特定类型的编码,未告知类型,无法进行编码,只能不处理。只有告知类型,匹配到响应的编码的类型,才能进行相应的编码。
TypeParameterMatcher
是个抽象类,内部有个类型参数匹配器,也就是上面说的泛型的,只有匹配到相应的泛型,才会进行解码,否则就往前传递。他也是存在于线程本地变量中,在UnpaddedInternalThreadLocalMap中:
//类型和参数类型匹配器的map Map<Class<?>, TypeParameterMatcher> typeParameterMatcherGetCache; //类型和类型和参数类型匹配器的map Map<Class<?>, Map<String, TypeParameterMatcher>> typeParameterMatcherFindCache;
他有两种方式获取
get获取类型参数匹配器
public static TypeParameterMatcher get(final Class<?> parameterType) { final Map<Class<?>, TypeParameterMatcher> getCache = InternalThreadLocalMap.get().typeParameterMatcherGetCache(); TypeParameterMatcher matcher = getCache.get(parameterType); if (matcher == null) { if (parameterType == Object.class) { matcher = NOOP; } else { matcher = new ReflectiveMatcher(parameterType); } getCache.put(parameterType, matcher); } return matcher; }
会根据传进来得Class对象,判断是哪个类型,从而生成相应的匹配器,如果是Object,就使用NOOP ,否则就是ReflectiveMatcher。这个就是根据类型获取参数类型匹配器,就是用到上面的 InternalThreadLocalMap 的typeParameterMatcherGetCache。
NOOP
private static final TypeParameterMatcher NOOP = new TypeParameterMatcher() { @Override public boolean match(Object msg) { return true; } };
ReflectiveMatcher
其实就是实现match方法,把相关的类型保存,然后匹配的时候看是否是这个类型的实例。
private static final class ReflectiveMatcher extends TypeParameterMatcher { private final Class<?> type; ReflectiveMatcher(Class<?> type) { this.type = type; } @Override public boolean match(Object msg) { return type.isInstance(msg); } }
find寻找泛型对应的匹配器
public static TypeParameterMatcher find( final Object object, final Class<?> parametrizedSuperclass, final String typeParamName) { final Map<Class<?>, Map<String, TypeParameterMatcher>> findCache = InternalThreadLocalMap.get().typeParameterMatcherFindCache(); final Class<?> thisClass = object.getClass(); Map<String, TypeParameterMatcher> map = findCache.get(thisClass); if (map == null) { map = new HashMap<String, TypeParameterMatcher>(); findCache.put(thisClass, map); } TypeParameterMatcher matcher = map.get(typeParamName); if (matcher == null) { matcher = get(find0(object, parametrizedSuperclass, typeParamName)); map.put(typeParamName, matcher); } return matcher; }
这里首先还是从线程本地变量里获取InternalThreadLocalMap 的typeParameterMatcherFindCache,然后根据当前对象获取对应的Map<String, TypeParameterMatcher>。如果不存在,就用反射来找出泛型的具体类型,最后根据类型返回匹配器,中间还会缓存类型和匹配器的映射关系。
find0(final Object object, Class<?> parametrizedSuperclass, String typeParamName)
用反射,根据当前对象获取泛型I
的真实类型。
private static Class<?> find0( final Object object, Class<?> parametrizedSuperclass, String typeParamName) { final Class<?> thisClass = object.getClass(); Class<?> currentClass = thisClass; for (;;) { if (currentClass.getSuperclass() == parametrizedSuperclass) { int typeParamIndex = -1; TypeVariable<?>[] typeParams = currentClass.getSuperclass().getTypeParameters(); for (int i = 0; i < typeParams.length; i ++) { if (typeParamName.equals(typeParams[i].getName())) { typeParamIndex = i; break; } } if (typeParamIndex < 0) { throw new IllegalStateException( "unknown type parameter '" + typeParamName + "': " + parametrizedSuperclass); } Type genericSuperType = currentClass.getGenericSuperclass(); if (!(genericSuperType instanceof ParameterizedType)) { return Object.class; } Type[] actualTypeParams = ((ParameterizedType) genericSuperType).getActualTypeArguments(); Type actualTypeParam = actualTypeParams[typeParamIndex]; if (actualTypeParam instanceof ParameterizedType) { actualTypeParam = ((ParameterizedType) actualTypeParam).getRawType(); } if (actualTypeParam instanceof Class) { return (Class<?>) actualTypeParam; } if (actualTypeParam instanceof GenericArrayType) { Type componentType = ((GenericArrayType) actualTypeParam).getGenericComponentType(); if (componentType instanceof ParameterizedType) { componentType = ((ParameterizedType) componentType).getRawType(); } if (componentType instanceof Class) { return Array.newInstance((Class<?>) componentType, 0).getClass(); } } if (actualTypeParam instanceof TypeVariable) { // Resolved type parameter points to another type parameter. TypeVariable<?> v = (TypeVariable<?>) actualTypeParam; currentClass = thisClass; if (!(v.getGenericDeclaration() instanceof Class)) { return Object.class; } parametrizedSuperclass = (Class<?>) v.getGenericDeclaration(); typeParamName = v.getName(); if (parametrizedSuperclass.isAssignableFrom(thisClass)) { continue; } else { return Object.class; } } return fail(thisClass, typeParamName); } currentClass = currentClass.getSuperclass(); if (currentClass == null) { return fail(thisClass, typeParamName); } } }
显示构造函数
这个构造函数传了要匹配的类型,直接去获取匹配器即可。这里就是直接parameterType和TypeParameterMatcher对应起来了。
protected MessageToByteEncoder(Class<? extends I> outboundMessageType) { this(outboundMessageType, true); }
protected MessageToByteEncoder(Class<? extends I> outboundMessageType, boolean preferDirect) {
matcher = TypeParameterMatcher.get(outboundMessageType);
this.preferDirect = preferDirect;
}
隐式构造函数
这个两个构造函数没有传类型,而是传了字符串I,也就是底层会通过反射出泛型的具体类型,然后获得匹配器。这里是通过当前对象的Class对象比如Class1,对应找到HashMap<String, TypeParameterMatcher>(),通过字符串I再找到TypeParameterMatcher。在这个过程中,会将I对应的具体类型parameterType和TypeParameterMatcher也放入UnpaddedInternalThreadLocalMap的typeParameterMatcherGetCache缓存中。
protected MessageToByteEncoder() { this(true); } protected MessageToByteEncoder(boolean preferDirect) { matcher = TypeParameterMatcher.find(this, MessageToByteEncoder.class, "I"); this.preferDirect = preferDirect; }
write写方法
关键还是写方法,他会判断消息是否是类型匹配的,是的话才会申请一个缓冲区,然后进行编码,不是就直接往前传递了。编码完了会尝试释放消息。如果编码失败,就往前写一个空缓冲区,把申请的缓冲区释放了。
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { ByteBuf buf = null; try { if (acceptOutboundMessage(msg)) {// 1. 消息类型是否匹配,利用上面的TypeParameterMatcher进行匹配 @SuppressWarnings("unchecked") I cast = (I) msg; buf = allocateBuffer(ctx, cast, preferDirect);// 2. 分配 ByteBuf 资源 try { encode(ctx, cast, buf);// 3. 执行 encode 方法完成数据编码 } finally { ReferenceCountUtil.release(cast); } if (buf.isReadable()) { ctx.write(buf, promise);// 4. 向后传递写事件 } else { buf.release(); ctx.write(Unpooled.EMPTY_BUFFER, promise); } buf = null; } else { ctx.write(msg, promise); } } catch (EncoderException e) { throw e; } catch (Throwable e) { throw new EncoderException(e); } finally { if (buf != null) { buf.release(); } } }
allocateBuffer申请缓冲区
如果优先是直接缓冲区,就会申请直接缓冲区,否则就是堆内缓冲区。
protected ByteBuf allocateBuffer(ChannelHandlerContext ctx, @SuppressWarnings("unused") I msg, boolean preferDirect) throws Exception { if (preferDirect) { return ctx.alloc().ioBuffer(); } else { return ctx.alloc().heapBuffer(); } }
MessageToByteEncoder 重写了 ChanneOutboundHandler 的 write() 方法,其主要逻辑分为以下几个步骤:
- acceptOutboundMessage 判断是否有匹配的消息类型,如果匹配需要执行编码流程,如果不匹配直接继续传递给下一个 ChannelOutboundHandler;
- 分配 ByteBuf 资源,默认使用堆外内存;
- 调用子类实现的 encode 方法完成数据编码,一旦消息被成功编码,会通过调用 ReferenceCountUtil.release(cast) 自动释放;
- 如果 ByteBuf 可读,说明已经成功编码得到数据,然后写入 ChannelHandlerContext 交到下一个节点;如果 ByteBuf 不可读,则释放 ByteBuf 资源,向下传递空的 ByteBuf 对象。
编码器实现非常简单,不需要关注拆包/粘包问题。如下例子,展示了如何将字符串类型的数据写入到 ByteBuf 实例,ByteBuf 实例将传递给 ChannelPipeline 链表中的下一个 ChannelOutboundHandler。
public class StringToByteEncoder extends MessageToByteEncoder<String> { @Override protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out) throws Exception { out.writeBytes(msg.getBytes()); } }
MessageToMessageEncoder
MessageToMessageEncoder 与 MessageToByteEncoder 类似,同样只需要实现 encode 方法。与 MessageToByteEncoder 不同的是,MessageToMessageEncoder 是将一种格式的消息转换为另外一种格式的消息。其中第二个 Message 所指的可以是任意一个对象,如果该对象是 ByteBuf 类型,那么基本上和 MessageToByteEncoder 的实现原理是一致的。此外 MessageToByteEncoder 的输出结果是对象列表,编码后的结果属于中间对象,最终仍然会转化成 ByteBuf 进行传输。
MessageToMessageEncoder 常用的实现子类有 StringEncoder、LineEncoder、Base64Encoder 等。以 StringEncoder 为例看下 MessageToMessageEncoder 的用法。源码示例如下:将 CharSequence 类型(String、StringBuilder、StringBuffer 等)转换成 ByteBuf 类型,结合 StringDecoder 可以直接实现 String 类型数据的编解码。
public class StringEncoder extends MessageToMessageEncoder<CharSequence> { private final Charset charset; /** * Creates a new instance with the current system character set. */ public StringEncoder() { this(Charset.defaultCharset()); } /** * Creates a new instance with the specified character set. */ public StringEncoder(Charset charset) { this.charset = ObjectUtil.checkNotNull(charset, "charset"); } @Override protected void encode(ChannelHandlerContext ctx, CharSequence msg, List<Object> out) throws Exception { if (msg.length() == 0) { return; } out.add(ByteBufUtil.encodeString(ctx.alloc(), CharBuffer.wrap(msg), charset)); } }
抽象解码类
解码类是 ChanneInboundHandler 的抽象类实现,操作的是 Inbound 入站数据。解码器实现的难度要远大于编码器,因为解码器需要考虑拆包/粘包问题。由于接收方有可能没有接收到完整的消息,所以解码框架需要对入站的数据做缓冲操作,直至获取到完整的消息。
ByteToMessageDecoder
属性
private static final byte STATE_INIT = 0;//初始状态 private static final byte STATE_CALLING_CHILD_DECODE = 1;//正在调用子类解码 private static final byte STATE_HANDLER_REMOVED_PENDING = 2;//处理器待删除 ByteBuf cumulation;//累加缓冲区 private Cumulator cumulator = MERGE_CUMULATOR;//默认是合并累加器 private boolean singleDecode;//是否只解码一次 private boolean first;//是否是第一次累加缓冲区 /** * This flag is used to determine if we need to call {@link ChannelHandlerContext#read()} to consume more data * when {@link ChannelConfig#isAutoRead()} is {@code false}. */ private boolean firedChannelRead;//自动读取是false的时候,是否要去调用ChannelHandlerContext的read()来设置监听读事件,可能没读完 /** * A bitmask where the bits are defined as * <ul> * <li>{@link #STATE_INIT}</li> * <li>{@link #STATE_CALLING_CHILD_DECODE}</li> * <li>{@link #STATE_HANDLER_REMOVED_PENDING}</li> * </ul> */ private byte decodeState = STATE_INIT;//如果在执行解码的时候处理器上下文被删除了,可以及时响应。 private int discardAfterReads = 16;//读取16个字节后丢弃已读的 private int numReads;//cumulation读取数据的次数
累加缓冲区,如果有不能拼成一个消息的数据会放入这个缓冲区里,等待下一次继续拼。当然累加缓冲区怎么累加,就需要有累加器,默认是合并累加器MERGE_CUMULATOR。
MERGE_CUMULATOR合并累加器
主要是做一般缓冲区的合并,直接将新的缓冲区拷贝到累加缓冲区中。
public static final Cumulator MERGE_CUMULATOR = new Cumulator() { @Override public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) { if (!cumulation.isReadable() && in.isContiguous()) {//累计的不可读(比如为空缓冲区),且新的是连续的,不是符合缓冲区,释放老的,返回新的 // If cumulation is empty and input buffer is contiguous, use it directly cumulation.release(); return in; } try { final int required = in.readableBytes(); if (required > cumulation.maxWritableBytes() || (required > cumulation.maxFastWritableBytes() && cumulation.refCnt() > 1) || cumulation.isReadOnly()) {//扩容了 // Expand cumulation (by replacing it) under the following conditions: // - cumulation cannot be resized to accommodate the additional data // - cumulation can be expanded with a reallocation operation to accommodate but the buffer is // assumed to be shared (e.g. refCnt() > 1) and the reallocation may not be safe. return expandCumulation(alloc, cumulation, in); } cumulation.writeBytes(in, in.readerIndex(), required);//将in写入 in.readerIndex(in.writerIndex());//in不可读了 return cumulation; } finally { // We must release in in all cases as otherwise it may produce a leak if writeBytes(...) throw // for whatever release (for example because of OutOfMemoryError) in.release();//返回前要释放in } } };
COMPOSITE_CUMULATOR复合累加器
另一个是复合累加器,也就是处理复合缓冲区,默认累加缓冲区也会是复合缓冲区。如果添加进来的缓冲区不可读,那就什么都不做,也就是复合缓冲区的累加方式。
public static final Cumulator COMPOSITE_CUMULATOR = new Cumulator() { @Override public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) { if (!cumulation.isReadable()) {//不可读了,直接返回in cumulation.release(); return in; } CompositeByteBuf composite = null; try { if (cumulation instanceof CompositeByteBuf && cumulation.refCnt() == 1) {//累计的是复合缓冲区且无其他引用 composite = (CompositeByteBuf) cumulation; // Writer index must equal capacity if we are going to "write" // new components to the end if (composite.writerIndex() != composite.capacity()) {//更新容量到写索引处 composite.capacity(composite.writerIndex()); } } else {//如果不是复合缓冲区,就创建一个复合缓冲区把累计的添加进来 composite = alloc.compositeBuffer(Integer.MAX_VALUE).addFlattenedComponents(true, cumulation); } composite.addFlattenedComponents(true, in);//再添加in in = null; return composite; } finally { if (in != null) {//有异常,要释放缓冲区 // We must release if the ownership was not transferred as otherwise it may produce a leak in.release(); // Also release any new buffer allocated if we're not returning it if (composite != null && composite != cumulation) {//有新的缓冲区申请的话也要释放 composite.release(); } } } } };
抽象方法decode
其实有一个抽象方法需要子类实现,那就是具体的解码方法,参数in
就是累加缓冲区,out
可以理解为一个列表,存放解码后的对象。
protected abstract void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception;
decode() 是用户必须实现的抽象方法,在该方法在调用时需要传入接收的数据 ByteBuf,及用来添加编码后消息的 List。由于 TCP 粘包问题,ByteBuf 中可能包含多个有效的报文,或者不够一个完整的报文。Netty 会重复回调 decode() 方法,直到没有解码出新的完整报文可以添加到 List 当中,或者 ByteBuf 没有更多可读取的数据为止。如果此时 List 的内容不为空,那么会传递给 ChannelPipeline 中的下一个ChannelInboundHandler。
channelRead读方法
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof ByteBuf) {//只处理字节缓冲区类型的 CodecOutputList out = CodecOutputList.newInstance(); try { first = cumulation == null; cumulation = cumulator.cumulate(ctx.alloc(), first ? Unpooled.EMPTY_BUFFER : cumulation, (ByteBuf) msg);//累加 callDecode(ctx, cumulation, out); } catch (DecoderException e) { throw e; } catch (Exception e) { throw new DecoderException(e); } finally { try { if (cumulation != null && !cumulation.isReadable()) {//不为空也不可读,要释放 numReads = 0; cumulation.release(); cumulation = null; } else if (++numReads >= discardAfterReads) {//读取数据的次数大于阈值,则尝试丢弃已读的,避免占着内存 // We did enough reads already try to discard some bytes so we not risk to see a OOME. // See https://github.com/netty/netty/issues/4275 numReads = 0; discardSomeReadBytes(); } int size = out.size(); firedChannelRead |= out.insertSinceRecycled();//有被添加或者设置,表是有读过了 fireChannelRead(ctx, out, size);//尝试传递数据 } finally { out.recycle(); } } } else { ctx.fireChannelRead(msg);//其他类型继续传递 } }
解码器也是一个处理器,只是在业务处理器前面做解码用,当然也是在读数据的地方做处理。CodecOutputList暂时不用管,就当一个列表,存放解码出来的消息就行。其实流程就是将新来的缓冲区 msg加到累加的缓冲区cumulation中,然后返回的又赋值给cumulation,这样就做到了合并了,然后去进行解码,解码的结果放入列表out 中。最后再进行资源的释放,往后传递消息和列表的回收。
callDecode解码
只要判断新的缓冲区in还有可读的,就进行解码,当然最开始消息列表out是空的,所以就进行子类来解码decodeRemovalReentryProtection,解码后看是否真正读取了缓冲区的内容,如果没读,说明不符合子类解码器的要求,就跳出循环了。如果能读取,就判断是否只解码一次,是就跳出,不是就继续读取来解码,解码好的消息会马上传递给后面,并把消息列表清空,当然这里不一定一次解码1个消息,也可能一次很多个。当然每次完成解码或者传递消息后要进行上下文是否被移除的检查,如果被移除了,就不能再进行处理了。
protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { try { while (in.isReadable()) {//有可读的 int outSize = out.size(); if (outSize > 0) {//有消息解码出来就先传递了 fireChannelRead(ctx, out, outSize);//有解码好的数据就传递给后面 out.clear();//清空 // Check if this handler was removed before continuing with decoding. // If it was removed, it is not safe to continue to operate on the buffer. // // See: // - https://github.com/netty/netty/issues/4635 if (ctx.isRemoved()) {//上下文被删除了就不处理了 break; } outSize = 0; } //继续解码 int oldInputLength = in.readableBytes();//还以后多少字节可读 decodeRemovalReentryProtection(ctx, in, out);//解码 // Check if this handler was removed before continuing the loop. // If it was removed, it is not safe to continue to operate on the buffer. // // See https://github.com/netty/netty/issues/1664 if (ctx.isRemoved()) { break; } if (outSize == out.size()) {//没有生成新的消息,可能要求不够无法解码出一个消息 if (oldInputLength == in.readableBytes()) {//没有读取数据 break; } else { continue; } } if (oldInputLength == in.readableBytes()) {//解码器没有读数据 throw new DecoderException( StringUtil.simpleClassName(getClass()) + ".decode() did not read anything but decoded a message."); } if (isSingleDecode()) {//是否每次只解码一条,就返回 break; } } } catch (DecoderException e) { throw e; } catch (Exception cause) { throw new DecoderException(cause); } }
ByteToMessageDecoder#fireChannelRead传递消息列表中的消息
这个方法是个用来传递消息列表中的所有消息的,判断消息列表是不是CodecOutputList类型,是的话就调用相应的获取方法getUnsafe来传递,这个获取消息的方法可能是不安全的,因为没做索引的越界检查,可能会越界。如果是一般的列表,就直接调用get方法获得。
static void fireChannelRead(ChannelHandlerContext ctx, List<Object> msgs, int numElements) { if (msgs instanceof CodecOutputList) {//如果是CodecOutputList类型的 fireChannelRead(ctx, (CodecOutputList) msgs, numElements); } else {//正常获取对象,传递下去 for (int i = 0; i < numElements; i++) { ctx.fireChannelRead(msgs.get(i)); } } }
// 传递CodecOutputList中的每一个对象
static void fireChannelRead(ChannelHandlerContext ctx, CodecOutputList msgs, int numElements) {
for (int i = 0; i < numElements; i ++) {
ctx.fireChannelRead(msgs.getUnsafe(i));
}
}
decodeRemovalReentryProtection调用子类来解码
主要是调用子类实现的decode
方法来解码,最后会考虑处理器是否被删除了,做一些处理。
final void decodeRemovalReentryProtection(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { decodeState = STATE_CALLING_CHILD_DECODE;//设置为子类解码 try { decode(ctx, in, out);//调用子类解码 } finally { boolean removePending = decodeState == STATE_HANDLER_REMOVED_PENDING; decodeState = STATE_INIT;//处理完了设置为初始化 if (removePending) {//如果有被设置待删除状态,就马上处理 fireChannelRead(ctx, out, out.size());//把数据传出去 out.clear();//清空 handlerRemoved(ctx);//删除 } } }
FixedLengthFrameDecoder#decode
举个例子,拿这个固定长的来看看他的解码方法,其实就是调用自定义的解码方法decode,然后把结果放进消息队列out中。具体的解码就是看可读数据是否大于等于固定长,如果是,就进行缓冲区的保留切片,切出固定长的缓冲区,这里为什么要保留切片呢,因为切片是共享原缓冲区的数据的,如果源缓冲区用完了可能被释放,所以需要保留一下,增加引用计数,当然在切片释放的时候,也会释放源缓冲区的。注意如果没达到解码器要求的,可能不会去读取缓冲区数据。
@Override protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { Object decoded = decode(ctx, in); if (decoded != null) { out.add(decoded); } } /** * Create a frame out of the {@link ByteBuf} and return it. * * @param ctx the {@link ChannelHandlerContext} which this {@link ByteToMessageDecoder} belongs to * @param in the {@link ByteBuf} from which to read data * @return frame the {@link ByteBuf} which represent the frame or {@code null} if no frame could * be created. */ protected Object decode( @SuppressWarnings("UnusedParameters") ChannelHandlerContext ctx, ByteBuf in) throws Exception { if (in.readableBytes() < frameLength) { return null; } else { return in.readRetainedSlice(frameLength); } }
channelReadComplete读完成方法
当数据读取完成的时候,会尝试去丢弃discardSomeReadBytes累加缓冲区的已读信息,虽然可能要进行拷贝消耗点新能,但是放在那里浪费内存,所以就先丢弃了。之后判断是否有读取过缓存区的内容,如果没读到数据(可能没达到解码器要求,不读取数据),且没设置自动去读的,就手动设置一次监听读事件,可能后面还有部分没发过来,发过来了就可以解码拼成一个完整消息了。最后在传递读完成事件。
@Override public void channelReadComplete(ChannelHandlerContext ctx) throws Exception { numReads = 0; discardSomeReadBytes(); if (!firedChannelRead && !ctx.channel().config().isAutoRead()) {//如果没有读到数据,且没有自动开启读,就设置读事件 ctx.read(); } firedChannelRead = false; ctx.fireChannelReadComplete(); }
discardSomeReadBytes丢弃已读数据
如果缓冲区不为空,而且没有别的引用指向他,就丢弃已读的数据。
protected final void discardSomeReadBytes() { if (cumulation != null && !first && cumulation.refCnt() == 1) {//当引用值有1的时候丢弃,否则用户可能有其他用就不能直接丢弃 // discard some bytes if possible to make more room in the // buffer but only if the refCnt == 1 as otherwise the user may have // used slice().retain() or duplicate().retain(). // // See: // - https://github.com/netty/netty/issues/2327 // - https://github.com/netty/netty/issues/1764 cumulation.discardSomeReadBytes(); } }
decodeLast最后解码
在通道失效之前,会进行最后一次解码,以便于取出剩下的数据解码,当然如果没有数据,那等于什么都没做:
protected void decodeLast(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { if (in.isReadable()) {//如果还能读的话把剩下的解码 // Only call decode() if there is something left in the buffer to decode. // See https://github.com/netty/netty/issues/4386 decodeRemovalReentryProtection(ctx, in, out); } }
decodeLast 在 Channel 关闭后会被调用一次,主要用于处理 ByteBuf 最后剩余的字节数据。Netty 中 decodeLast 的默认实现只是简单调用了 decode() 方法。如果有特殊的业务需求,则可以通过重写 decodeLast() 方法扩展自定义逻辑。
MessageToMessageDecoder
MessageToMessageDecoder 与 ByteToMessageDecoder 作用类似,都是将一种消息类型的编码成另外一种消息类型。与 ByteToMessageDecoder 不同的是 MessageToMessageDecoder 并不会对数据报文进行缓存,它主要用作转换消息模型。比较推荐的做法是使用 ByteToMessageDecoder 解析 TCP 协议,解决拆包/粘包问题。解析得到有效的 ByteBuf 数据,然后传递给后续的 MessageToMessageDecoder 做数据对象的转换,具体流程如下图所示。
通信协议实战
如何判断 ByteBuf 是否存在完整的报文?最常用的做法就是通过读取消息长度 dataLength 进行判断。如果 ByteBuf 的可读数据长度小于 dataLength,说明 ByteBuf 还不够获取一个完整的报文。在该协议前面的消息头部分包含了魔数、协议版本号、数据长度等固定字段,共 14 个字节。固定字段长度和数据长度可以作为我们判断消息完整性的依据,具体编码器实现逻辑示例如下:
public class ByteToStringDecoder extends ByteToMessageDecoder { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { // 判断 ByteBuf 可读取字节 if (in.readableBytes() < 14) { return; } in.markReaderIndex(); // 标记 ByteBuf 读指针位置 in.skipBytes(2); // 跳过魔数 in.skipBytes(1); // 跳过协议版本号 byte serializeType = in.readByte(); in.skipBytes(1); // 跳过报文类型 in.skipBytes(1); // 跳过状态字段 in.skipBytes(4); // 跳过保留字段 int dataLength = in.readInt(); if (in.readableBytes() < dataLength) { in.resetReaderIndex(); // 重置 ByteBuf 读指针位置 return; } byte[] data = new byte[dataLength]; in.readBytes(data); String s = new String(data); if (s != null) { out.add(s); } } }
可以看到,网络通信的底层实现,Netty 都已经帮我们封装好了,我们只需要扩展 ChannelHandler 实现自定义的编解码逻辑即可。更加人性化的是,Netty 提供了很多开箱即用的解码器,这些解码器基本覆盖了 TCP 拆包/粘包的通用解决方案。
固定长度解码器 FixedLengthFrameDecoder
固定长度解码器 FixedLengthFrameDecoder 非常简单,直接通过构造函数设置固定长度的大小 frameLength,无论接收方一次获取多大的数据,都会严格按照 frameLength 进行解码。如果累积读取到长度大小为 frameLength 的消息,那么解码器认为已经获取到了一个完整的消息。如果消息长度小于 frameLength,FixedLengthFrameDecoder 解码器会一直等后续数据包的到达,直至获得完整的消息。下面我们通过一个例子感受一下使用 Netty 实现固定长度解码是多么简单。
public class EchoServer { public void startEchoServer(int port) throws Exception { EventLoopGroup bossGroup = new NioEventLoopGroup(); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override public void initChannel(SocketChannel ch) { ch.pipeline().addLast(new FixedLengthFrameDecoder(10)); ch.pipeline().addLast(new EchoServerHandler()); } }); ChannelFuture f = b.bind(port).sync(); f.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } public static void main(String[] args) throws Exception { new EchoServer().startEchoServer(8088); } } class EchoServerHandler extends ChannelInboundHandlerAdapter { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { System.out.println("Receive client : [" + ((ByteBuf) msg).toString(CharsetUtil.UTF_8) + "]"); } }
在上述服务端的代码中使用了固定 10 字节的解码器,并在解码之后通过 EchoServerHandler 打印结果。我们可以启动服务端,通过 telnet 命令像服务端发送数据,观察代码输出的结果。
客户端输入:
telnet localhost 8088 Trying ::1... Connected to localhost. Escape character is '^]'. 1234567890123 456789012
服务端输出:
Receive client : [1234567890]
Receive client : [123
45678]
源码
/** * A decoder that splits the received {@link ByteBuf}s by the fixed number * of bytes. For example, if you received the following four fragmented packets: * <pre> * +---+----+------+----+ * | A | BC | DEFG | HI | * +---+----+------+----+ * </pre> * A {@link FixedLengthFrameDecoder}{@code (3)} will decode them into the * following three packets with the fixed length: * <pre> * +-----+-----+-----+ * | ABC | DEF | GHI | * +-----+-----+-----+ * </pre> */ public class FixedLengthFrameDecoder extends ByteToMessageDecoder { private final int frameLength; /** * Creates a new instance. * * @param frameLength the length of the frame */ public FixedLengthFrameDecoder(int frameLength) { checkPositive(frameLength, "frameLength"); this.frameLength = frameLength; } @Override protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception { Object decoded = decode(ctx, in); if (decoded != null) { out.add(decoded); } } /** * Create a frame out of the {@link ByteBuf} and return it. * * @param ctx the {@link ChannelHandlerContext} which this {@link ByteToMessageDecoder} belongs to * @param in the {@link ByteBuf} from which to read data * @return frame the {@link ByteBuf} which represent the frame or {@code null} if no frame could * be created. */ protected Object decode( @SuppressWarnings("UnusedParameters") ChannelHandlerContext ctx, ByteBuf in) throws Exception { if (in.readableBytes() < frameLength) {//如果可读字节的小于固定长度,什么都不做 return null; } else { return in.readRetainedSlice(frameLength);//返回的是切片,会增加in引用计数,防止被回收了 } } }
特殊分隔符解码器 DelimiterBasedFrameDecoder
使用特殊分隔符解码器 DelimiterBasedFrameDecoder 之前我们需要了解以下几个属性的作用。
- delimiters
delimiters 指定特殊分隔符,通过写入 ByteBuf 作为参数传入。delimiters 的类型是 ByteBuf 数组,所以我们可以同时指定多个分隔符,但是最终会选择长度最短的分隔符进行消息拆分。
例如接收方收到的数据为:
+--------------+
| ABC
DEF
|
+--------------+
如果指定的多个分隔符为 和 ,DelimiterBasedFrameDecoder 会退化成使用 LineBasedFrameDecoder 进行解析,那么会解码出两个消息。
+-----+-----+
| ABC | DEF |
+-----+-----+
如果指定的特定分隔符只有 ,那么只会解码出一个消息:
+----------+
| ABC
DEF |
+----------+
- maxFrameLength
maxFrameLength 是报文最大长度的限制。如果超过 maxFrameLength 还没有检测到指定分隔符,将会抛出 TooLongFrameException。可以说 maxFrameLength 是对程序在极端情况下的一种保护措施。
- failFast
failFast 与 maxFrameLength 需要搭配使用,通过设置 failFast 可以控制抛出 TooLongFrameException 的时机,可以说 Netty 在细节上考虑得面面俱到。如果 failFast=true,那么在超出 maxLength 会立即抛出 TooLongFrameException,不再继续进行解码。如果 failFast=false,那么会等到解码出一个完整的消息后才会抛出 TooLongFrameException。
- stripDelimiter
stripDelimiter 的作用是判断解码后得到的消息是否去除分隔符。如果 stripDelimiter=false,特定分隔符为 ,那么上述数据包解码出的结果为:
+-------+---------+
| ABC
| DEF
|
+-------+---------+
下面我们还是结合代码示例学习 DelimiterBasedFrameDecoder 的用法,依然以固定编码器小节中使用的代码为基础稍做改动,引入特殊分隔符解码器 DelimiterBasedFrameDecoder:
initChannel(SocketChannel ch) 函数体修改为以下代码
ByteBuf delimiter = Unpooled.copiedBuffer("&".getBytes()); ch.pipeline().addLast(new DelimiterBasedFrameDecoder(10, true, true, delimiter)); ch.pipeline().addLast(new EchoServerHandler());
我们依然通过 telnet 模拟客户端发送数据,观察代码输出的结果,可以发现由于 maxLength 设置的只有 10,所以在解析到第三个消息时抛出异常。
客户端输入:
telnet localhost 8088 Trying ::1... Connected to localhost. Escape character is '^]'. hello&world&1234567890ab
服务端输出:
Receive client : [hello]
Receive client : [world]
一月 06, 2021 11:01:18 下午 io.netty.channel.DefaultChannelPipeline onUnhandledInboundException
警告: 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.
io.netty.handler.codec.TooLongFrameException: frame length exceeds 10: 12 - discarded
at io.netty.handler.codec.DelimiterBasedFrameDecoder.fail(DelimiterBasedFrameDecoder.java:298)
at io.netty.handler.codec.DelimiterBasedFrameDecoder.decode(DelimiterBasedFrameDecoder.java:284)
at io.netty.handler.codec.DelimiterBasedFrameDecoder.decode(DelimiterBasedFrameDecoder.java:214)
at io.netty.handler.codec.ByteToMessageDecoder.decodeRemovalReentryProtection(ByteToMessageDecoder.java:508)
at io.netty.handler.codec.ByteToMessageDecoder.callDecode(ByteToMessageDecoder.java:447)
at io.netty.handler.codec.ByteToMessageDecoder.channelRead(ByteToMessageDecoder.java:276)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:357)
at io.netty.channel.DefaultChannelPipeline$HeadContext.channelRead(DefaultChannelPipeline.java:1410)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:379)
at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:365)
at io.netty.channel.DefaultChannelPipeline.fireChannelRead(DefaultChannelPipeline.java:919)
at io.netty.channel.nio.AbstractNioByteChannel$NioByteUnsafe.read(AbstractNioByteChannel.java:166)
at io.netty.channel.nio.NioEventLoop.processSelectedKey(NioEventLoop.java:719)
at io.netty.channel.nio.NioEventLoop.processSelectedKeysOptimized(NioEventLoop.java:655)
at io.netty.channel.nio.NioEventLoop.processSelectedKeys(NioEventLoop.java:581)
at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:493)
at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
at java.lang.Thread.run(Thread.java:745)
decode内部方法
在看 DelimiterBasedFrameDecoder#decode 之前,我们先看下 LineBasedFrameDecoder#decode 方法。因为 DelimiterBasedFrameDecoder#decode 方法首先就是判断是否有定义 LineBasedFrameDecoder,如果定义了,就直接执行 DelimiterBasedFrameDecoder#decode
分四种情况:
上一次没有丢弃可读数据过的。如果找到分割符了,如果长度没超出,就根据是否要略过分隔符返回相应长度的切片,如果超出了就设置读索到分隔符之后并抛出异常。
上一次没有丢弃可读数据过的。如果没找到分隔符,长度又超过了最大长度就丢弃,设置丢弃数量,设置读索引到最后。根据需求抛出异常。
上一次有丢弃可读数据过的。如果找到分割符了,不处理,直接设置读索引到分隔符之后,因为这个是上一次丢弃的那部分所属的同一个消息的,都不要了。
上一次有丢弃可读数据过的。如果没有找到分割符了,继续丢弃,直接略过可读的数据。
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception { final int eol = findEndOfLine(buffer); if (!discarding) {//没有丢弃过可读的 if (eol >= 0) {//找到分割符了 final ByteBuf frame; final int length = eol - buffer.readerIndex(); final int delimLength = buffer.getByte(eol) == ' '? 2 : 1;//如果是' ',分割符是2个字节' ',否则是一个' ' if (length > maxLength) {//可读数据超过最大长度了,直接不要了 buffer.readerIndex(eol + delimLength);//设置读索引为分隔符索引之后 fail(ctx, length);//直接抛异常 return null; } if (stripDelimiter) {//如果略过分隔符的 frame = buffer.readRetainedSlice(length);//获取长度为length的切片 buffer.skipBytes(delimLength);//buffer略过分隔符 } else { frame = buffer.readRetainedSlice(length + delimLength);//包括分隔符在内的切片 } return frame; } else {//没找到分割符,不会读取,不改变读索引 final int length = buffer.readableBytes(); if (length > maxLength) {//超过最大长度,也没找到分隔符 discardedBytes = length;//丢弃可读的 buffer.readerIndex(buffer.writerIndex());//直接略过可读的,设置为不可读 discarding = true;//有丢弃了 offset = 0; if (failFast) { fail(ctx, "over " + discardedBytes); } } return null; } } else { if (eol >= 0) {//前面有丢弃的话,后面跟着的也不要了 final int length = discardedBytes + eol - buffer.readerIndex(); final int delimLength = buffer.getByte(eol) == ' '? 2 : 1; buffer.readerIndex(eol + delimLength);//直接略过前面的一部分了 discardedBytes = 0; discarding = false; if (!failFast) { fail(ctx, length); } } else {//还是没找到分隔符 discardedBytes += buffer.readableBytes();//增加丢弃数量 buffer.readerIndex(buffer.writerIndex());//直接略过可读的,设置为不可读 // We skip everything in the buffer, we need to set the offset to 0 again. offset = 0; } return null; } }
findEndOfLine寻找换行分隔符的位置
可能是换行符"
"
或者"
"
,所以位置会相差1
,如果找到了索引offset
就从0
开始,否则就从上一次长度的最后开始。
private int findEndOfLine(final ByteBuf buffer) { int totalLength = buffer.readableBytes(); int i = buffer.forEachByte(buffer.readerIndex() + offset, totalLength - offset, ByteProcessor.FIND_LF); if (i >= 0) {//找到了换行符 offset = 0; if (i > 0 && buffer.getByte(i - 1) == ' ') {//如果索引不是0,且前一个是' ',就返回前一个的索引 i--; } } else { offset = totalLength; } return i; }
fail抛出异常
private void fail(final ChannelHandlerContext ctx, int length) { fail(ctx, String.valueOf(length)); } private void fail(final ChannelHandlerContext ctx, String length) { ctx.fireExceptionCaught( new TooLongFrameException( "frame length (" + length + ") exceeds the allowed maximum (" + maxLength + ')')); }
DelimiterBasedFrameDecoder#decode
protected Object decode(ChannelHandlerContext ctx, ByteBuf buffer) throws Exception { if (lineBasedDecoder != null) {//使用换行符" "
或者" "
return lineBasedDecoder.decode(ctx, buffer); } // Try all delimiters and choose the delimiter which yields the shortest frame. int minFrameLength = Integer.MAX_VALUE; ByteBuf minDelim = null; for (ByteBuf delim: delimiters) { //利用定义的分隔符分隔 int frameLength = indexOf(buffer, delim);//分隔过的长度 if (frameLength >= 0 && frameLength < minFrameLength) {//使用更短的长度 minFrameLength = frameLength; minDelim = delim; } } if (minDelim != null) {//存在分隔结果 int minDelimLength = minDelim.capacity();//分隔符符号长度 ByteBuf frame; if (discardingTooLongFrame) {// 是否处于丢弃模式 // We've just finished discarding a very large frame. // Go back to the initial state. discardingTooLongFrame = false; buffer.skipBytes(minFrameLength + minDelimLength); int tooLongFrameLength = this.tooLongFrameLength; this.tooLongFrameLength = 0; if (!failFast) { fail(tooLongFrameLength); } return null; } if (minFrameLength > maxFrameLength) {//长度是否超过规定的最大长度 // Discard read frame. buffer.skipBytes(minFrameLength + minDelimLength); fail(minFrameLength); return null; } if (stripDelimiter) {//是否保留分隔符 frame = buffer.readRetainedSlice(minFrameLength); buffer.skipBytes(minDelimLength); } else { frame = buffer.readRetainedSlice(minFrameLength + minDelimLength); } return frame; } else { if (!discardingTooLongFrame) { if (buffer.readableBytes() > maxFrameLength) { // Discard the content of the buffer until a delimiter is found. tooLongFrameLength = buffer.readableBytes(); buffer.skipBytes(buffer.readableBytes()); discardingTooLongFrame = true; if (failFast) { fail(tooLongFrameLength); } } } else { // Still discarding the buffer since a delimiter is not found. tooLongFrameLength += buffer.readableBytes(); buffer.skipBytes(buffer.readableBytes()); } return null; } }
长度域解码器 LengthFieldBasedFrameDecoder
长度域解码器 LengthFieldBasedFrameDecoder 是解决 TCP 拆包/粘包问题最常用的解码器。它基本上可以覆盖大部分基于长度拆包场景,开源消息中间件 RocketMQ 就是使用 LengthFieldBasedFrameDecoder 进行解码的。LengthFieldBasedFrameDecoder 相比 FixedLengthFrameDecoder 和 DelimiterBasedFrameDecoder 要复杂一些,接下来我们就一起学习下这个强大的解码器。
LengthFieldBasedFrameDecoder 中的几个重要属性,这里我主要把它们分为两个部分:长度域解码器特有属性以及与其他解码器(如特定分隔符解码器)的相似的属性。
- 长度域解码器特有属性。
// 长度字段的偏移量,也就是存放长度数据的起始位置 private final int lengthFieldOffset; // 长度字段所占用的字节数 private final int lengthFieldLength; /* * 消息长度的修正值 * * 在很多较为复杂一些的协议设计中,长度域不仅仅包含消息的长度,而且包含其他的数据,如版本号、数据类型、数据状态等,那么这时候我们需要使用 lengthAdjustment 进行修正 * * lengthAdjustment = 包体的长度值 - 长度域的值 * */ private final int lengthAdjustment; // 解码后需要跳过的初始字节数,也就是消息内容字段的起始位置 private final int initialBytesToStrip; // 长度字段结束的偏移量,lengthFieldEndOffset = lengthFieldOffset + lengthFieldLength private final int lengthFieldEndOffset;
- 与固定长度解码器和特定分隔符解码器相似的属性。
private final int maxFrameLength; // 报文最大限制长度 private final boolean failFast; // 是否立即抛出 TooLongFrameException,与 maxFrameLength 搭配使用 private boolean discardingTooLongFrame; // 是否处于丢弃模式 private long tooLongFrameLength; // 需要丢弃的字节数 private long bytesToDiscard; // 累计丢弃的字节数
下面我们结合具体的示例来解释下每种参数的组合,其实在 Netty LengthFieldBasedFrameDecoder 源码的注释中已经描述得非常详细,一共给出了 7 个场景示例,理解了这些示例基本上可以真正掌握 LengthFieldBasedFrameDecoder 的参数用法。
示例 1:典型的基于消息长度 + 消息内容的解码。
BEFORE DECODE (14 bytes) AFTER DECODE (14 bytes)
+--------+----------------+ +--------+----------------+
| Length | Actual Content |----->| Length | Actual Content |
| 0x000C | "HELLO, WORLD" | | 0x000C | "HELLO, WORLD" |
+--------+----------------+ +--------+----------------+
上述协议是最基本的格式,报文只包含消息长度 Length 和消息内容 Content 字段,其中 Length 为 16 进制表示,共占用 2 字节,Length 的值 0x000C 代表 Content 占用 12 字节。该协议对应的解码器参数组合如下:
- lengthFieldOffset = 0,因为 Length 字段就在报文的开始位置。
- lengthFieldLength = 2,协议设计的固定长度。
- lengthAdjustment = 0,Length 字段只包含消息长度,不需要做任何修正。
- initialBytesToStrip = 0,解码后内容依然是 Length + Content,不需要跳过任何初始字节。
示例 2:解码结果需要截断。
BEFORE DECODE (14 bytes) AFTER DECODE (12 bytes)
+--------+----------------+ +----------------+
| Length | Actual Content |----->| Actual Content |
| 0x000C | "HELLO, WORLD" | | "HELLO, WORLD" |
+--------+----------------+ +----------------+
示例 2 和示例 1 的区别在于解码后的结果只包含消息内容,其他的部分是不变的。该协议对应的解码器参数组合如下:
- lengthFieldOffset = 0,因为 Length 字段就在报文的开始位置。
- lengthFieldLength = 2,协议设计的固定长度。
- lengthAdjustment = 0,Length 字段只包含消息长度,不需要做任何修正。
- initialBytesToStrip = 2,跳过 Length 字段的字节长度,解码后 ByteBuf 中只包含 Content字段。
示例 3:长度字段包含消息长度和消息内容所占的字节。
BEFORE DECODE (14 bytes) AFTER DECODE (14 bytes)
+--------+----------------+ +--------+----------------+
| Length | Actual Content |----->| Length | Actual Content |
| 0x000E | "HELLO, WORLD" | | 0x000E | "HELLO, WORLD" |
+--------+----------------+ +--------+----------------+
与前两个示例不同的是,示例 3 的 Length 字段包含 Length 字段自身的固定长度以及 Content 字段所占用的字节数,Length 的值为 0x000E(2 + 12 = 14 字节),在 Length 字段值(14 字节)的基础上做 lengthAdjustment(-2)的修正,才能得到真实的 Content 字段长度,所以对应的解码器参数组合如下:
- lengthFieldOffset = 0,因为 Length 字段就在报文的开始位置。
- lengthFieldLength = 2,协议设计的固定长度。
- lengthAdjustment = -2,长度字段为 14 字节,需要减 2 才是拆包所需要的长度。
- initialBytesToStrip = 0,解码后内容依然是 Length + Content,不需要跳过任何初始字节。
示例 4:基于长度字段偏移的解码。
BEFORE DECODE (17 bytes) AFTER DECODE (17 bytes)
+----------+----------+----------------+ +----------+----------+----------------+
| Header 1 | Length | Actual Content |----->| Header 1 | Length | Actual Content |
| 0xCAFE | 0x00000C | "HELLO, WORLD" | | 0xCAFE | 0x00000C | "HELLO, WORLD" |
+----------+----------+----------------+ +----------+----------+----------------+
示例 4 中 Length 字段不再是报文的起始位置,Length 字段的值为 0x00000C,表示 Content 字段占用 12 字节,该协议对应的解码器参数组合如下:
- lengthFieldOffset = 2,需要跳过 Header 1 所占用的 2 字节,才是 Length 的起始位置。
- lengthFieldLength = 3,协议设计的固定长度。
- lengthAdjustment = 0,Length 字段只包含消息长度,不需要做任何修正。
- initialBytesToStrip = 0,解码后内容依然是完整的报文,不需要跳过任何初始字节。
示例 5:长度字段与内容字段不再相邻。
BEFORE DECODE (17 bytes) AFTER DECODE (17 bytes)
+----------+----------+----------------+ +----------+----------+----------------+
| Length | Header 1 | Actual Content |----->| Length | Header 1 | Actual Content |
| 0x00000C | 0xCAFE | "HELLO, WORLD" | | 0x00000C | 0xCAFE | "HELLO, WORLD" |
+----------+----------+----------------+ +----------+----------+----------------+
示例 5 中的 Length 字段之后是 Header 1,Length 与 Content 字段不再相邻。Length 字段所表示的内容略过了 Header 1 字段,所以也需要通过 lengthAdjustment 修正才能得到 Header + Content 的内容。示例 5 所对应的解码器参数组合如下:
- lengthFieldOffset = 0,因为 Length 字段就在报文的开始位置。
- lengthFieldLength = 3,协议设计的固定长度。
- lengthAdjustment = 2,由于 Header + Content 一共占用 2 + 12 = 14 字节,所以 Length 字段值(12 字节)加上 lengthAdjustment(2 字节)才能得到 Header + Content 的内容(14 字节)。
- initialBytesToStrip = 0,解码后内容依然是完整的报文,不需要跳过任何初始字节。
示例 6:基于长度偏移和长度修正的解码。
BEFORE DECODE (16 bytes) AFTER DECODE (13 bytes)
+------+--------+------+----------------+ +------+----------------+
| HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
| 0xCA | 0x000C | 0xFE | "HELLO, WORLD" | | 0xFE | "HELLO, WORLD" |
+------+--------+------+----------------+ +------+----------------+
示例 6 中 Length 字段前后分为别 HDR1 和 HDR2 字段,各占用 1 字节,所以既需要做长度字段的偏移,也需要做 lengthAdjustment 修正,具体修正的过程与 示例 5 类似。对应的解码器参数组合如下:
- lengthFieldOffset = 1,需要跳过 HDR1 所占用的 1 字节,才是 Length 的起始位置。
- lengthFieldLength = 2,协议设计的固定长度。
- lengthAdjustment = 1,由于 HDR2 + Content 一共占用 1 + 12 = 13 字节,所以 Length 字段值(12 字节)加上 lengthAdjustment(1)才能得到 HDR2 + Content 的内容(13 字节)。
- initialBytesToStrip = 3,解码后跳过 HDR1 和 Length 字段,共占用 3 字节。
示例 7:长度字段包含除 Content 外的多个其他字段。
BEFORE DECODE (16 bytes) AFTER DECODE (13 bytes)
+------+--------+------+----------------+ +------+----------------+
| HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
| 0xCA | 0x0010 | 0xFE | "HELLO, WORLD" | | 0xFE | "HELLO, WORLD" |
+------+--------+------+----------------+ +------+----------------+
示例 7 与 示例 6 的区别在于 Length 字段记录了整个报文的长度,包含 Length 自身所占字节、HDR1 、HDR2 以及 Content 字段的长度,解码器需要知道如何进行 lengthAdjustment 调整,才能得到 HDR2 和 Content 的内容。所以我们可以采用如下的解码器参数组合:
- lengthFieldOffset = 1,需要跳过 HDR1 所占用的 1 字节,才是 Length 的起始位置。
- lengthFieldLength = 2,协议设计的固定长度。
- lengthAdjustment = -3,Length 字段值(16 字节)需要减去 HDR1(1 字节) 和 Length 自身所占字节长度(2 字节)才能得到 HDR2 和 Content 的内容(1 + 12 = 13 字节)。
- initialBytesToStrip = 3,解码后跳过 HDR1 和 Length 字段,共占用 3 字节。
以上 7 种示例涵盖了 LengthFieldBasedFrameDecoder 大部分的使用场景。
最后说下CodecOutputList
可以简单的理解为一个列表,里面存放我们解码后的消息,即我们处理器经常用到的msg。
解码后的对象就是放入这个表里的,解码后传递给后续处理器的也是这里面的消息对象。
属性:
private final CodecOutputListRecycler recycler; //回收器 private int size;//拥有的对象个数 private Object[] array;//对象数组 private boolean insertSinceRecycled;//是否有对象加入数组过
回收器:
private static final CodecOutputListRecycler NOOP_RECYCLER = new CodecOutputListRecycler() { @Override public void recycle(CodecOutputList object) { // drop on the floor and let the GC handle it. } };
线程本地变量:
线程本地变量可以跟着线程一起存活着,等于有了池化的作用,而且是线程安全的,存在IO
线程里。默认初始化是生成一个CodecOutputLists
,是个CodecOutputList
集合。
private static final FastThreadLocal<CodecOutputLists> CODEC_OUTPUT_LISTS_POOL = new FastThreadLocal<CodecOutputLists>() { @Override protected CodecOutputLists initialValue() throws Exception { // 16 CodecOutputList per Thread are cached. return new CodecOutputLists(16); } };
CodecOutputLists
里面放着很多CodecOutputList,然后实现了回收器接口CodecOutputListRecycler,用来回收CodecOutputList。
内部创建了一个CodecOutputList数组,默认每个CodecOutputList可以存放16个消息对象。如果获取的时候没有CodecOutputList了,就会创建一个不缓存的CodecOutputList,默认存放4个消息对象。
private static final class CodecOutputLists implements CodecOutputListRecycler { private final CodecOutputList[] elements; private final int mask;//取余掩码 private int currentIdx;//当前索引 private int count;//列表个数 CodecOutputLists(int numElements) { elements = new CodecOutputList[MathUtil.safeFindNextPositivePowerOfTwo(numElements)];//创建2的幂次个列表 for (int i = 0; i < elements.length; ++i) {//初始化 // Size of 16 should be good enough for the majority of all users as an initial capacity. elements[i] = new CodecOutputList(this, 16); } count = elements.length; currentIdx = elements.length; mask = elements.length - 1; } //如果没缓存就创建一个不缓存的,默认创建长度为4的数组 public CodecOutputList getOrCreate() { if (count == 0) { // Return a new CodecOutputList which will not be cached. We use a size of 4 to keep the overhead // low. return new CodecOutputList(NOOP_RECYCLER, 4); } --count; int idx = (currentIdx - 1) & mask;//从后往前取,取模,算出索引位置 CodecOutputList list = elements[idx]; currentIdx = idx; return list; } @Override public void recycle(CodecOutputList codecOutputList) { int idx = currentIdx; elements[idx] = codecOutputList; currentIdx = (idx + 1) & mask;//当前索引增加,取模 ++count; assert count <= elements.length; } }
newInstance获取CodecOutputList对象
外面是通过CodecOutputList 的newInstance来获得对象,其实是从线程本地变量的CodecOutputLists
里获取的。
static CodecOutputList newInstance() { return CODEC_OUTPUT_LISTS_POOL.get().getOrCreate(); }
get有检查获取消息对象
有检查的获取,外部使用
getUnsafe无检查获取消息对象
少了检查,内部使用的。
insert插入对象到指定位置
直接把对象放入指定数组的位置,设置标记。
private void insert(int index, Object element) { array[index] = element; insertSinceRecycled = true; }
recycle清空对象并回收到CodecOutputLists中
void recycle() { for (int i = 0 ; i < size; i ++) { array[i] = null; } size = 0; insertSinceRecycled = false; recycler.recycle(this); }