什么是TCP粘包和拆包?

TCP是一个“流”协议,所谓流就是没有界限的一串数据。TCP协议会根据TCP缓冲区的实际情况对包进行划分,所以业务上的一个包,可能被TCP拆分为多个包发送(拆包),多个小的包也可能被TCP合并为一个包进行发送(粘包)。

TCP粘包和拆包发生的原因

原因有三个:

  1. 应用程序write写入的字节大小大于套接字缓冲区的大小
  2. 进行MSS(maximum segment size,最大分节大小,为TCP数据包每次传输的最大数据分段大小)大小的TCP分段
  3. 以太帧的payload大于MTU(maximum transmission unit,最大传输单元,由硬件规定)进行IP分片

粘包问题的解决策略

由于底层的TCP无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组,这个问题只能通过上层的应用协议栈设计来解决。
一些解决方案如下:

  1. 消息定长,每个报文的长度固定,如果不够,空位补空格。
  2. 在包尾增加回车换行符进行分割,如FTP协议。
  3. 将消息分为消息头和消息体,消息头中包含一个表示消息总长度的字段。
  4. 更复杂的应用层协议。

如何使用Netty解决粘包问题

解决粘包问题的关键就在于要解决服务器端每次读取数据长度的问题。可以使用自定义协议+编解码器来解决。

比如使用LineBasedFrameDecoderStringDecoder来解决TCP粘包导致的读半包问题。
其原理就是依次遍历ByteBuf中的可读字节,判断是否有”\n”或者“\r\n”,如果有,就以此为结束位置,从可读索引到结束位置区间的字节就组成了一行。

上面是采用分隔符的方式,我们还可以通过实现自己的编解码器利用定长包来处理粘包。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class XDecoder extends ByteToMessageDecoder {

//包的长度
static final int PACKET_SIZE = 220;

// 用来临时保留没有处理过的请求报文
ByteBuf tempMsg = Unpooled.buffer();

/**
* @param ctx
* @param in 请求的数据
* @param out 将粘在一起的报文拆分后的结果保留起来
* @throws Exception
*/
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
System.out.println(Thread.currentThread() + "收到了一次数据包,长度是:" + in.readableBytes());

// 合并报文
ByteBuf message = null;
int tmpMsgSize = tempMsg.readableBytes();
// 如果暂存有上一次余下的请求报文,则合并
if (tmpMsgSize > 0) {
message = Unpooled.buffer();
message.writeBytes(tempMsg);
message.writeBytes(in);
System.out.println("合并:上一数据包余下的长度为:" + tmpMsgSize + ",合并后长度为:" + message.readableBytes());
} else {
message = in;
}

int size = message.readableBytes();
//判断当前包含几个数据包
int counter = size / PACKET_SIZE;
for (int i = 0; i < counter; i++) {
byte[] request = new byte[PACKET_SIZE];
// 每次从总的消息中读取220个字节的数据
message.readBytes(request);

// 将拆分后的结果放入out列表中,交由后面的业务逻辑去处理
out.add(Unpooled.copiedBuffer(request));
}

//如果有多余的报文则存起来
size = message.readableBytes();
if (size != 0) {
System.out.println("多余的数据长度:" + size);
// 剩下来的数据放到tempMsg暂存
tempMsg.clear();
tempMsg.writeBytes(message.readBytes(size));
}
}
}

ChannelPipeline和ChannelHandler

ChannelPipeline是什么,它有什么作用?

ChannelPipeline是ChannleHandler的容器,它负责ChannelHandler的管理和事件拦截与调度。

ChannlePipeline的事件处理

下面梳理一下,一个消息被ChannelPipeline的ChannelHandler链拦截和处理的全过程。

  1. 底层的SocketChannel read方法读取到ByteBuf,触发了ChannelRead事件,由IO线程NioEventLoop调用ChannelPipeline的fireChannelRead方法,将消息传输到ChannelPipeline中。
  2. 消息依次被Headhandler、ChannelHandler1,ChannelHandler2…TailHandler拦截和处理,在整个过程中,任何的ChannelHandler都可以中断当前的流程,结束消息的传递。
  3. 调用ChannelHandlerContext的write方法发送消息,消息从TailHandler开始,逆着拦截链传递,最终被添加到消息发送缓冲区中,等待刷新和发送。

38zJnP.png

netty中的事件分为inbound事件和outbound事件。inbound事件通常由IO线程触发,例如TCP链路建立事件,链路关闭事件、读事件、异常通知事件等。而OUtbound事件通常是由用户主动发起的网络IO操作,例如用户发起的连接操作,绑定操作、消息发送等操作。

如何自定义拦截器

ChannelPipeline通过ChannelHandler接口来实现事件的拦截和处理。因为我们往往只需要关系ChannelHandler接口中的一些事件,因此一般继承ChannelHandlerAdapter类覆盖自己关心的方法即可。

pipeline是如何构建的

在使用ServerBootstrap或者Bootstrap启动服务端或者客户端时,Netty会为每个Channel连接创建一个独立的pipeline。

ChannelPipeline的主要特性

  • ChannelPipeline支持运行态动态的添加或者删除ChannelHandler。
  • ChannelPipeline是线程安全的,这意味着N个业务线程可以并发的操作ChannelPipeline而不存在多线程并发问题。但是ChannelHandler却不是线程安全的。

ChannelPipeline源码分析

类继承关系图

3GgEB6.png

实现原理

我们以这行代码为例,来看一下,一个ChannelHandler是如何添加到ChannelPipeline中的。
pipeline.addLast(new MyByteToLongDecoder());

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public final ChannelPipeline addLast(EventExecutorGroup executor, ChannelHandler... handlers) {
if (handlers == null) {
throw new NullPointerException("handlers");
}
for (ChannelHandler h: handlers) {
if (h == null) {
break;
}
addLast(executor, null, h);
}

return this;
}

这个方法主要做了参数校验,实际上最终是调用了 addLast(executor, null, h);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
 @Override
public final ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler) {
final AbstractChannelHandlerContext newCtx;
synchronized (this) { //进行了方法同步,是线程安全的体现
//判断该handler是否以及添加过了
checkMultiplicity(handler);

//创建一个新的AbstractChannelHandlerContext
newCtx = newContext(group, filterName(name, handler), handler);

//将当前的Context添加到AbstractChannelHandlerContext链表尾部
addLast0(newCtx);

// If the registered is false it means that the channel was not registered on an eventLoop yet.
// In this case we add the context to the pipeline and add a task that will call
// ChannelHandler.handlerAdded(...) once the channel is registered.
if (!registered) {
newCtx.setAddPending();
callHandlerCallbackLater(newCtx, true);
return this;
}

EventExecutor executor = newCtx.executor();
if (!executor.inEventLoop()) {
callHandlerAddedInEventLoop(newCtx, executor);
return this;
}
}
callHandlerAdded0(newCtx);
return this;
}

ChannelHandler有什么作用

ChannelHandler类似于Servlet的Filter过滤器,负责对IO事件或者IO操作进行拦截和处理,它可以选择性的拦截和处理自己感兴趣的事件,也可以透传和终止事件的传递。
ChannelHandler支持注解。

  • Sharable:多个ChannelPipeline公用一个ChannelHandler
  • Skip:被Skip注解的方法不会被调用,直接被忽略。

ChannelHandlerAdapter有什么用

在实际开发中,ChannelHandler会选择性的拦截和处理。如果用户直接实现ChannelHandler接口就必须实现所有的方法,引入ChannelHandlerAdapter后用户只需要覆盖自己关心的方法即可。

什么是Netty零拷贝

Netty零拷贝主要包含三个方面:

  • Netty的接收和发送ByteBUffer采用DIRECT BUFFERS,使用堆外的直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。
  • Netty提供了组合Buffer对象,可以聚合多个ByteBuffer对象,用户可以像操作一个Buffer那样方便的对组合Buffer进行操作,避免了传统的通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer。
  • Netty的文件传输采用transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。