一、网络编程为什么需要进行消息定界
封帧一般是指在一段数据的前后分别添加首部和尾部,以此形成数据帧。对数据帧而言,首部和尾部的重要作用之一,就是可以对消息进行定界。因此封帧的本身就是消息定界中的一种方式。那么我们为什么要对消息进行定界呢?其根本原因是应用层传输的对象虽然是逐个发出的,但是经过传输层之后,对象却不见得能被识别出来。所以才需要对消息进行定界。下面先来介绍下基于传输层的两种流行的协议的相关问题。
1、TCP
tcp是一种流式协议,和水流一样,其本身是没有界限的。当可操作的消息对象被序列化成字节流并通过TCP进行传输,那么是不是应该需要考虑一下,该如何从这没有界限的“水流”中识别出各个消息对象便是一个急需解决的问题。还有在TCP网络中传输对象的时候,会难以避免的出现消息不完整的现象,比如:半包、黏包。接下来还需要了解一下上面是半包黏包,以及产生的原因是什么。
什么是半包、黏包
譬如说我们发送两条消息——你好和世界。那么此时对方可能一次性接收到消息(你好世界),但是也有可能是分好几次才接收完的。也就是说所接受的消息是不完整的、零散的。对于一次性接收到消息我们一般称之为黏包,而多次接收到消息的则称之为半包。
产生半包和黏包的原因
1)产生黏包的原因
产生黏包的主要原因一般是因为每次写入的数据比较少,比如远小于套接字缓冲区的大小。此时,网卡不会立马立马,而是将数据合并后一起后再发送,这样效率也会高一些,但是对方接收到的可能就是黏包。还有对方接收数据不及时,也会产生黏包的现象。
2)产生半包的原因
相较于黏包,产生半包的现象的原因更多且更难克服。例如,当发送方数据大于套接字缓冲区的大小时,在底层数据必然会分多次发送,因此接收放收到的可能就是半包。另外一个非常重要的因素就是最大传输单元(MTU)。数据时按TCP/IP逐层封装后传输的,应用层数据在作为数据部分传递给数据链路层之前,是需要加上传输层的头,才能逐层封装传递。既然要封装,那就必然要涉及数据内容的大小控制。否则也就不存在封装的概念了,各层协议中报文内容的大小就是由MTU来控制的。当发送的数据大于协议各层的MTU时,那就必须要拆包了。
对于IP4而言,MTU限制的数据内容最多时64KB,对于Ethernet V2,MTU限制内容的最大字节是1500个字节。综合看来,只要传输层的数据超出个人的MTU的限制,必然要进行数据拆包。
协议 |
MTU |
IPv4 |
64~68KB |
IPv6 |
64~1280KB(当开启“特大包”时,最多可达40G) |
Ethernet V2 |
1500字节 |
2、UDP
相比TCP,UDP时无协议传输协议,不存在黏包、半包的问题。且发送方也不在意放的内容是否会成功,接收放接收的包也都是完成的。所以使用UDP的时候,便不需要考虑封帧的问题。当然代价也是有的,就是不能保证消息的可靠性,所以可靠性较高的场景便不适合。
二、常见的消息定界方式
1、TCP短连接方式
当使用TCP进行传输时,消息之所以不好区分,其主要原因发送的消息汇聚成消息流。计入每次发送完一个请求就断掉连接,那么便很容易界定消息,因为从建立连接到释放连接到这一段时间内发送的内容就是一条完整的消息。但是这种比较简单的方式,它的缺点就是效率低,频繁的发送消息所带来的创建、释放连接是有很大的开销的,同时也是没有充分利用TCP的面向连接的优势。
2、固定长度方式
还可以采用固定的长度作为消息的界定标准。比如,对于原始消息 你好 世界,如果固定长度为6个字节作为界定消息长度的标准,那么就可以得到“你好”和“世界”两条消息了。
这种方式虽然易于实现,效率也比较高,但是它又个致命的问题,也就是需要消息本身就是固定长度的,这肯定是不切实际的。但消息不满足长度要求时,便需要通过占位符来满足要求的长度,这样会导致浪费空间。
3、封帧
封帧(framing)时TCP消息定界的方式之一,它实际上也可以通过不同的方式来给消息定界,比如:定界符方式、显式长度方式。
定界符方式
从这个方式的命名上,就可以知道这是使用定界符来划分消息的边界,例如你好和世界,我们在发送消息时可以添加定界符(/),因此消息的模式就是“你好/世界”,这样接收方就可以以“/”为界,来找出“你好”和“世界”。
这种方法虽然简单,但是仍然会浪费空间,比如添加的界定符,而消息本身也是带有界定符的,那么还需要对消息本身的界定符进行转义,因此时需要扫描消息中的每个字符,这样效率必然时不高的。
显式长度方式
和前面提到的固定长度方式相比,显式长度方式时目前最灵活,也是最推荐的一种方式。关于显示长度方式实现的具体思路如下:
- 在编码时将消息的长度计算出来,然后将消息的长度存储到一个额外字段中,当解码的时候,需要先获取额外字段中存储的消息的长度的值,然后根据这个值来读取消息。
- 这种方式能够精确定位数据内容,并且不用转义符,但是数据内容的长度理论上应该是有限制的,需要预测可能的最大长度,从而定义长度字段占空间的大小。如果不进行估算,就直接将长度字段定义的特别大,那么当消息本身长度很小的情况下,长度字段也会浪费不少的空间,因此估算消息的长度是很必要的。
其实除了上面介绍的那些方式,还有其他的一些方式,但是使用的不太多。譬如混合方式和自定义方式。混合方式组合了界定符方式和显式长度方式。而自定义方式则和前面的完全不同,JsonObjectDecoder就是使用的自定义方式。
三、Netty中对封帧的支持
Netty支持了常见的三种封帧方式,如下表:
方式 |
解码 |
编码 |
固定长度方式 |
FixedLengthFrameDecoder |
N/A |
定界符方式 |
DelimiterBasedFrameDecoder |
N/A |
显式长度方式 |
LengthFieldBasedFrameDecoder |
LengthFieldPrepender |
上表中的这三种界定方式的解码器,都是继承自抽象类ByteToMessageDecoder,这个抽象类的主要作用是处理黏包、半包问题的。但是这三个子类一般只关注如何定界和解析一条完整的消息。对于Netty中为什么只有显式长度方式的解码器有对应的编码成都,而其他的两种没有,这是因为显式长度提供了许多额外的参数的控制。相比其他两种药复杂许多,其实另外两种是基本不需要提供任何逻辑或额外控制的,开发者也不需要借助Netty来完成,所以Netty 只提供了显式长度的编码器。最后看下这几个类的类图结构:
这里以FixedLengthFrameDecoder来看看Netty是如何找出消息边界并解决半包、黏包问题的。当消息接收到的时候,便会触发FixedLengthFrameDecoder父类ByteToMessageDecoder中的方法io.netty.handler.codec.ByteToMessageDecoder#channelRead,代码如下;
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof ByteBuf) {
CodecOutputList out = CodecOutputList.newInstance();
try {
ByteBuf data = (ByteBuf) msg;
first = cumulation == null;
if (first) {
cumulation = data;
} else {
cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
}
callDecode(ctx, cumulation, out);
} catch (DecoderException e) {
throw e;
} catch (Exception e) {
throw new DecoderException(e);
} finally {
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);
out.recycle();
}
} else {
ctx.fireChannelRead(msg);
}
}
上面这段代码的核心步骤逻辑如下:
- 追加数据
- 尝试解析出消息对象
- 传递解析出的消息对象
1、追加消息
追加消息的代码主要是由cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data)这行代码实现的。其中累加器的实现方式有两种,默认使用的是内存复制方式,代码如下:
io.netty.handler.codec.ByteToMessageDecoder#MERGE_CUMULATOR
public static final Cumulator MERGE_CUMULATOR = new Cumulator() {
@Override
public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
try {
final ByteBuf buffer;
if (cumulation.writerIndex() > cumulation.maxCapacity() - in.readableBytes()
|| cumulation.refCnt() > 1 || cumulation.isReadOnly()) {
buffer = expandCumulation(alloc, cumulation, in.readableBytes());
} else {
buffer = cumulation;
}
buffer.writeBytes(in);
return buffer;
} finally {
in.release();
}
}
};
执行完这一步后,ByteBuf中将包含之前可能残余的数据(半包数据)以及新来的数据。
2、尝试解析出消息对象
在通过上一步得到的“所有尚未找出消息的”ByteBuf之后,执行callDecode以尝试找出对象,并把解析结果(界定出来的完整消息)存放到 CodecOutputList 中,callDecode 方法中最后会调用 decode() 方法实现的情况,代码如下:
@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);
}
}
protected Object decode(
@SuppressWarnings("UnusedParameters") ChannelHandlerContext ctx, ByteBuf in) throws Exception {
if (in.readableBytes() < frameLength) {
return null;
} else {
return in.readRetainedSlice(frameLength);
}
}
从代码中可以得知,尝试解析出消息对象是会遇到两种情况:
- 如果积累的数据充足(大于或等于 frameLength),那么至少一个对象可以解析出,于是读取数据 (readRetainedSlice),解析出消息对象并存放到out中,另外读取工作本身会改变待累计数据的可读范围。
- 如果数据累积不够,那么返回null,不再读取数据,于是累积的数据保持不变。
3、传递解析出的消息对象
在执行完以上的步骤之后,CodecOutputList 中可能保存了一些完整的消息对象,为例把这些消息对象传递出去,将会执行ByteToMessageDecoder#fireChannelRead(io.netty.channel.ChannelHandlerContext, io.netty.handler.codec.CodecOutputList, int)方法,代码如下:
@Override
public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
numReads = 0;
discardSomeReadBytes();
if (!firedChannelRead && !ctx.channel().config().isAutoRead()) {
ctx.read();
}
firedChannelRead = false;
ctx.fireChannelReadComplete();
}
至此,便完成了消息对象的界定,也解决了半包、黏包的问题。从代码中可以发现,这里核心维护的就是ByteBuf,然后从中尝试解析出消息对象,而不是单独对请求的消息进行解析。