常规的网络通信程序引用的是TCP/IP协议的传输层接口,对于面向连接的TCP协议,数据的传输形式是流(Stream),其特点是传输的数据无消息边界。套接字就像管道的两端,数据从一端流入,从另一端流出,数据的流动是连续的。

TCP 协议提供了发送和接收数据的接口,发送操作每次发送一串字节,接收操作每次接收一串字节,TCP协议保证发送数据的顺序和接收数据的顺序相同,比如发送数据为012345678910个字节),只要套接字不出现错误,另一端接收到的数据一定是012345678910个字节)。但接收数据的操作却不一定要和发送数据的操作一一对应。这主要是因为 TCP 协议是字节流形式的、无消息边界的协议,受网络传输中的不确定因素的影响,不能保证每次sendrecv操作能够一一对应。上述情况可以形象表示为:

情况1

发送:

012

3456789


接收:

012

3456789

情况2

发送:

012

3456789


接收:

0123456789

情况3

发送:

012

3456789


接收:

01

234

56789

......

情况N

发送:

012

3456789


接收:

......

有些时候传输的数据具有一定的协议语义,为了保证接收方不出现解析错误, 编程时必须要考虑消息边界问题, 否则就可能会出现命令格式解析错误、丢失命令等后果。实际应用中,解决 TCP 协议消息边界问题的方式有三种:

第一种方式是发送固定长度的消息。该方法类似于CPURISC架构,每条指令的长度相同,这种处理方式最简单,但需要仔细设计指令体系。

第二种方式是各种指令具有不同的头标识,具体到每种指令的长度则固定,收到指令后根据头部标识判断指令的总长度。该方法类似于CPUCISC架构,各种指令的长度不同,但每种指令的长度是已知固定的。

第三种方式是将消息长度在头部进行标识,相当于对第二种方式的优化,将整条指令划分为头部和数据部分,头部长度固定,包含指令类型和数据部分长度,解析后一次读取数据部分长度。这种方式降低了指令解析的复杂度。

以第三种方式为例,分别使用java.nio.ByteBufferio.netty.buffer.ByteBuf演示指令解析的过程。

使用java.nio.ByteBuffer演示网络流的读取、解析过程:

// 读取数据.
int cnt = channel.read(src);
if(cnt < 0) {
// ...
} elseif (cnt == 0) {
// ...
}
// 解析指令循环,一次读取可能会读到多条指令
ByteBuffer buf = src.duplicate();
buf.flip();
while (buf.remaining() >= PDU_HEAD_LEN) {
int pduLen = buf.getInt(buf.position());
if (buf.remaining() >= pduLen) {
// 循环处理指令的各个字段
// ...
}
// buf中剩下的数据为下一个指令的数据,下一次循环将继续解析,直到数据不足一条指令
}
// buf中剩下的数据不足一条指令,等待下次读取数据后再次处理
// 将数据转移到src的头部,供读取添加,每次都需执行
bufTmp.clear();
bufTmp.put(buf);
bufTmp.flip();
src.clear();
src.put(bufTmp);

使用io.netty.buffer.ByteBuf演示网络流的读取、解析过程:

// 读取数据.
src.clear();
int cnt = channel.read(src);
if(cnt < 0) {
// ...
} elseif (cnt == 0) {
// ...
}
src.flip();
buf.writeBytes(src);
// 解析指令循环,一次读取可能会读到多条指令
while (buf.readableBytes() >= PDU_HEAD_LEN) {
int pduLen = buf.getInt(buf.readerIndex());
if (buf.readableBytes() >= pduLen) {
// 循环处理指令的各个字段
// ...
}
// buf中剩下的数据为下一个指令的数据,下一次循环将继续解析,直到数据不足一条指令
}
// buf中剩下的数据不足一条指令,等待下次读取数据后再次处理
// 根据必要进行buf的压缩,根据系统内存进行权衡,可以每500次执行一次压缩
if (buf.writerIndex() > ALLOW_CAP) {
buf.writeBytes(buf);
}

结论:从上述代码看,使用io.netty.buffer.ByteBuf进行网络流的读取、解析,不仅代码简洁、易懂而且高效。最好的代码不是使用了丰富的注释,而是不需过多注释就通俗易懂。这其中的一个重要原因在于io.netty.buffer.ByteBufreaderIndexwriterIndex分开标记,并使用了准确的接口命名带来的。而java.nio.ByteBuffer的接口设计中,getput则使用了同一个位置标识position,用limit标识有效数据的末尾,如果是交替读写,其代码反复调用duplicateflip将让人感到莫名其妙,或者使用getXXX(index)或者put(index, value)就更让人摸不着头脑。另外putget的命名也让人奇怪,为什么不使用writeread更直接的单词命名呢?同样是开源工程,显然netty这一小步,给网络编程的进化带来了一大步。Netty带来的优势远不至于此,后续将花时间逐步介绍。在此祝愿会有更多更好的像netty这样的开源工程涌现出来!