netty 问题总结

机器学习及相关算法
机器学习及相关算法
YOU-SAY

 

netty的线程模型

线程类型

总结来说netty 自身包含两种种线程,最后一种是自己加的。

1)boss线程

监听客户端accept事件的线程,轮询select,接收到accept事件后负责协议握手(tcp)等等操作,把与客户端的cannel绑定到一个单独的sub线程中,此线程称为boss线程。此类型线程可以多个线程监听一个端口(有待验证!!!)。一般来说一个线程即可,但是如果有频繁的连接断开,建议多个线程。

2)sub线程

监听客户端读写事件,负责网络与应用内存之间的数据读写,通过各种handler进行处理,包括编码、解码、认证、业务处理等等操作。如果客户端服务端 数据交互频繁、数据传输量大建议 多线程。

 

3)业务工作线程

在每个handler中的线程,如果自定义的handler中的业务处理非常耗时,建议使用线程池、多线程处理。

线程模型类型

参考:https://www.jianshu.com/p/738095702b75

Netty通过Reactor模型基于多路复用器接收并处理用户请求,内部实现了两个线程池,boss线程池和work线程池,其中boss线程池的线程负责处理请求的accept事件,当接收到accept事件的请求时,把对应的socket封装到一个NioSocketChannel中,并交给work线程池,其中work线程池负责请求的read和write事件,由对应的Handler处理。

另外如果handler中的业务非常耗时,还可以在handler自定义线程池对业务处理。

1. 单线程模型

所有I/O操作都由一个线程完成,即多路复用、事件分发和处理都是在一个Reactor线程上完成的。既要接收客户端的连接请求,向服务端发起连接,又要发送/读取请求或应答/响应消息。一个NIO线程同时处理成百上千的链路,性能上无法支撑,速度慢,若线程进入死循环,整个程序不可用,对于高负载、大并发的应用场景不合适。

单线程模型

实现方式

 

private EventLoopGroup group = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap()
                .group(group)
                .childHandler(new HeartbeatInitializer());

2. 多线程模型

有一个NIO线程(Acceptor)只负责监听服务端,接收客户端的TCP连接请求;NIO线程池负责网络IO的操作,即消息的读取、解码、编码和发送;1个NIO线程可以同时处理N条链路,但是1个链路只对应1个NIO线程,这是为了防止发生并发操作问题。但在并发百万客户端连接或需要安全认证时,一个Acceptor线程可能会存在性能不足问题。

多线程模型

实现方式

 

private EventLoopGroup boss = new NioEventLoopGroup(1);
private EventLoopGroup work = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap()
                .group(boss,work)
                .childHandler(new HeartbeatInitializer());

3. 主从多线程模型

Acceptor线程用于绑定监听端口,接收客户端连接,将SocketChannel从主线程池的Reactor线程的多路复用器上移除,重新注册到Sub线程池的线程上,用于处理I/O的读写等操作,从而保证mainReactor只负责接入认证、握手等操作。

主从多线程模型实现方式

 

private EventLoopGroup boss = new NioEventLoopGroup();
private EventLoopGroup work = new NioEventLoopGroup();
ServerBootstrap bootstrap = new ServerBootstrap()
                .group(boss,work)
                .childHandler(new HeartbeatInitializer());


注意如果NioEventLoopGroup()如果不写线程数量,参考如下,经过debug之后得知,默认的最大线程数为8,如果客户端连接小于8,则每个channel都对应单独的一个线程,直到超过8个channel再复用线程:

 

主从多线程的实验

 启动10个客户端连接

启动一个服务端

服务端输出结果如下:

11->CLIENT:表示id为11的sub副线程接绑定了客户端的连接的channel

11->服务端--接收到数据:表示id为11的sub副线程接收到客户端发送的数据

 

11->CLIENT:/127.0.0.1:54545 接入连接
11->服务端--接收到数据: netty rocks客户端发送数据:1
12->CLIENT:/127.0.0.1:54559 接入连接
12->服务端--接收到数据: netty rocks
12->服务端--接收到数据: 客户端发送数据:1
11->服务端--接收到数据: 客户端发送数据:2
13->CLIENT:/127.0.0.1:54566 接入连接
13->服务端--接收到数据: netty rocks
13->服务端--接收到数据: 客户端发送数据:1
12->服务端--接收到数据: 客户端发送数据:2
11->服务端--接收到数据: 客户端发送数据:3
11->CLIENT:/127.0.0.1:54576 接入连接
11->服务端--接收到数据: netty rocks
11->服务端--接收到数据: 客户端发送数据:1
13->服务端--接收到数据: 客户端发送数据:2
12->服务端--接收到数据: 客户端发送数据:3
12->CLIENT:/127.0.0.1:54585 接入连接
12->服务端--接收到数据: netty rocks
12->服务端--接收到数据: 客户端发送数据:1
11->服务端--接收到数据: 客户端发送数据:4
13->CLIENT:/127.0.0.1:54592 接入连接
11->服务端--接收到数据: 客户端发送数据:2
13->服务端--接收到数据: netty rocks
13->服务端--接收到数据: 客户端发送数据:1
13->服务端--接收到数据: 客户端发送数据:3
12->服务端--接收到数据: 客户端发送数据:4
12->服务端--接收到数据: 客户端发送数据:2
11->服务端--接收到数据: 客户端发送数据:5
11->服务端--接收到数据: 客户端发送数据:3
13->服务端--接收到数据: 客户端发送数据:2
13->服务端--接收到数据: 客户端发送数据:4
12->服务端--接收到数据: 客户端发送数据:5
12->服务端--接收到数据: 客户端发送数据:3

netty支持的通讯协议(Channel )类型

参考:https://www.cnblogs.com/duanxz/p/3724432.html

不同协议不同的阻塞类型的连接都有不同的 Channel 类型与之对应下面是一些常用的 Channel 类型:

  • NioSocketChannel, 代表异步的客户端 TCP Socket 连接.

  • NioServerSocketChannel, 异步的服务器端 TCP Socket 连接.

  • NioDatagramChannel, 异步的 UDP 连接

  • NioSctpChannel, 异步的客户端 Sctp 连接.

  • NioSctpServerChannel, 异步的 Sctp 服务器端连接.

  • OioSocketChannel, 同步的客户端 TCP Socket 连接.

  • OioServerSocketChannel, 同步的服务器端 TCP Socket 连接.

  • OioDatagramChannel, 同步的 UDP 连接

  • OioSctpChannel, 同步的 Sctp 服务器端连接.

  • OioSctpServerChannel, 同步的客户端 TCP Socket 连接.

使用方法

  

handler之InboundHandler 与OutboundHandler 的顺序

参考:https://blog.csdn.net/songfei_dream/article/details/102749344

实验过程

 

实验一:在InboundHandler中不触发fire方法,后续的InboundHandler还能顺序执行吗?

如果某个InboundHandler中没有调用fireChannelRead ,那么往后的Pipeline就断掉了。

实验二:InboundHandler和OutboundHandler的执行顺序是什么?

加入Pipeline的ChannelHandler的顺序如上图所示,那么最后执行的顺序如何呢?执行结果如下:

最终执行顺序为:

InboundHandler1 => InboundHandler2 => OutboundHandler1 => OutboundHander2 => OutboundHandler3 => InboundHandler3

所以,我们得到以下几个结论:

1、InboundHandler是按照Pipleline的加载顺序,顺序执行。

2、OutboundHandler是按照Pipeline的加载顺序,逆序执行。

3、先顺序执行3-1个 in,然后再逆序执行out,最后执行第3个in。

实验三:如果把OutboundHandler放在InboundHandler的后面,OutboundHandler会执行吗?

1、有效的InboundHandler是指通过fire事件能触达到的最后一个InboundHander。

2、如果想让所有的OutboundHandler都能被执行到,那么必须把OutboundHandler放在最后一个有效的InboundHandler之前。

3、推荐的做法是通过addFirst加载所有OutboundHandler,再通过addLast加载所有InboundHandler。

实验四:如果其中一个OutboundHandler没有执行write方法,那么消息会不会发送出去?

我们把OutboundHandler2的write方法注掉

执行结果如下:

OutboundHandler3并没有被执行到,另外,客户端也没有收到发送的消息。

所以,我们得到以下几个结论:

1、OutboundHandler是通过write方法实现Pipeline的串联的。

2、如果OutboundHandler在Pipeline的处理链上,其中一个OutboundHandler没有调用write方法,最终消息将不会发送出去。

实验五:ctx.writeAndFlush 的OutboundHandler的执行顺序是什么?

我们设定ChannelHandler在Pipeline中的加载顺序如下:

OutboundHandler3 => InboundHandler1 => OutboundHandler2 => InboundHandler2 => OutboundHandler1 => InboundHandler3

在InboundHander2中调用ctx.writeAndFlush:

结果是:依次执行了OutboundHandler2和OutboundHandler3,为什么会这样呢?因为ctx.writeAndFlush是从当前的ChannelHandler开始,向前依次执行OutboundHandler的write方法,所以分别执行了OutboundHandler2和OutboundHandler3。

所以,我们得到如下结论:

1、ctx.writeAndFlush是从当前ChannelHandler开始,逆序向前执行OutboundHandler。

2、ctx.writeAndFlush所在ChannelHandler后面的OutboundHandler将不会被执行。

实验六:ctx.channel().writeAndFlush 的OutboundHandler的执行顺序是什么?

还是实验五的代码,不同之处只是把ctx.writeAndFlush修改为ctx.channel().writeAndFlush。

结果是:所有OutboundHandler都执行了,由此我们得到结论:

1、ctx.channel().writeAndFlush 是从最后一个OutboundHandler开始,依次逆序向前执行其他OutboundHandler,即使最后一个ChannelHandler是OutboundHandler,在InboundHandler之前,也会执行该OutbondHandler。

2、千万不要在OutboundHandler的write方法里执行ctx.channel().writeAndFlush,否则就死循环了。

总结

1、InboundHandler是通过fire事件决定是否要执行下一个InboundHandler,如果哪个InboundHandler没有调用fire事件,那么往后的Pipeline就断掉了。
2、InboundHandler是按照Pipleline的加载顺序,顺序执行。
3、OutboundHandler是按照Pipeline的加载顺序,逆序执行。
4、有效的InboundHandler是指通过fire事件能触达到的最后一个InboundHander。
5、如果想让所有的OutboundHandler都能被执行到,那么必须把OutboundHandler放在最后一个有效的InboundHandler之前。
6、推荐的做法是通过addFirst加载所有OutboundHandler,再通过addLast加载所有InboundHandler。
7、OutboundHandler是通过write方法实现Pipeline的串联的。
8、如果OutboundHandler在Pipeline的处理链上,其中一个OutboundHandler没有调用write方法,最终消息将不会发送出去。
9、ctx.writeAndFlush是从当前ChannelHandler开始,逆序向前执行OutboundHandler。
10、ctx.writeAndFlush所在ChannelHandler后面的OutboundHandler将不会被执行。
11、ctx.channel().writeAndFlush 是从最后一个OutboundHandler开始,依次逆序向前执行其他OutboundHandler,即使最后一个ChannelHandler是OutboundHandler,在InboundHandler之前,也会执行该OutbondHandler。
12、千万不要在OutboundHandler的write方法里执行ctx.channel().writeAndFlush,否则就死循环了。

13、只有调用ctx.writeAndFlush(或者分别调用 write,flush)才能发出去消息。

netty编码解码与粘包拆包半包

注意:一个客户端连接对应固定的一个channel,一个channel 只能绑定一个线程,而一个线程可以管理多个固定的channel,固定是指不关联关系不变化直到消亡。而编解码中的ChannelHandlerContext 记录着每个channel中的流,这样就能保证一个负责client的读写线程可以获得此 client上发送的完整数据。

序列化与编解码顺序

序列化->编码->解码->反序列化

粘包拆包半包产生的原因

什么是粘包拆包

在 TCP/IP 协议模型中,TCP 和 UDP 协议属于传输层协议,这两个协议在数据传输过程中存在很大的差异。

对于 UDP 协议而言,它传输的数据是基于数据报来进行收发的,在 UDP 协议的头中,会有一个 16bit 的字段来表示 UDP 数据报文的长度,在应用层能很好的将不同的数据报文区分开。可以理解为,UDP 协议传输的数据是有边界的,因此它不会存在粘包、半包的问题。

而对于 TCP 协议而言,它传输数据是基于字节流传输的。应用层在传输数据时,实际上会先将数据写入到 TCP 套接字的缓冲区,当缓冲区被写满后,数据才会被写出去,这就可能造成粘包、半包的问题。而且当接收方接收到数据后,实际上接收到的是一个字节流,所谓的流,可以理解为河流一样。既然是流,多个数据包相互之间是没有边界的,而且在 TCP 的协议头中,没有一个单独的字段来表示数据包的长度,这样在接收方的应用层,从字节流中读取到数据后,是没办法将两个数据包区分开的。

粘包、半包示意图

当发送方连续向接收方发送两个完整的数据包时,如果使用 TCP 协议进行传输,就可能存在以下几种情况。下图中 packet1 和 packet2 分别表示发送方发送的两个完整的数据包。

第一种情况,没有发生粘包、半包的现象,即接收方正常接收到两个独立的完整数据包 packet1、packet2,这种情况是属于正常情况。如图 1 所示。

Netty源码分析系列之TCP粘包、半包问题以及Netty是如何解决的

 

第二种情况,发生了粘包现象,即发送方将数据包 packet1 写入到自己的 TCP 套接字的缓冲区后,TCP 并没有立即将数据发送出去,因为此时缓冲区可能还没有慢。接着发送方又发送了一个数据包 packet2,仍然是先写入到 TCP 套接字的缓冲区,此时缓冲区满了,然后 TCP 才将缓冲区的数据一起发送出去,这时候接收方接收到的数据看起来只有一个数据包。在 TCP 的协议头中,没有一个单独的字段来表示数据包的长度,这样接收方根本就无法区分出 packet1 和 packet2,这就是所谓的粘包问题。另外,当接收方的 TCP 层接收到数据后,由于应用层没有及时从 TCP 套接字中读取数据,也会造成粘包现象。如图 2 所示。

Netty源码分析系列之TCP粘包、半包问题以及Netty是如何解决的

 

第三种情况,发生了半包现象,即发送方依旧是先后发送了两个数据包 packet1 和 packet2,但是 TCP 在传输时,分了几次传输,每次传输的内容中包含的不是 packet1 和 packet2 的完整包,只是 packet1 或者 packet2 的一部分,就相当于把两个数据包的内容拆分了,因此也称之为拆包现象。如图 3 所示。

Netty源码分析系列之TCP粘包、半包问题以及Netty是如何解决的

 

产生粘包、半包的原因

从上面的示意图中,我们大致可以知道产生粘包、半包的主要原因如下。

  • 粘包原因 发送方每次写入的数据小于套接字缓冲区大小; 接收方读取套接字缓冲区的数据不够及时。
  • 半包原因 发送方写入的数据大于套接字缓冲区的大小; 发送的数据大于协议的 MSS 或者 MTU,必须拆包。(MSS 是 TCP 层的最大分段大小,TCP 层发送给 IP 层的数据不能超过该值;MTU 是最大传输单元,是物理层提供给上层一次最大传输数据的大小,用来限制 IP 层的数据传输大小)。

但归根结底,产生粘包、半包的根本原因是因为 TCP 是基于字节流来传输数据的,数据包相互之间没有边界,导致接收方无法准确的分辨出每一个单独的数据包。

netty 解决粘包拆包的原理

源码解析

作为一个应用层的开发者,我们无法去改变 TCP 基于字节流来传输数据的特性,除非我们自定义一个类似于 TCP 的协议,但是难度太大,设计出来的性能还不一定比现有的 TCP 协议性能好,况且目前 TCP 协议的使用十分广泛。而 netty 作为一款高性能的网络框架,必然就要有对 TCP 协议的支持,既然支持 TCP 协议,那就要解决 TCP 中粘包、半包的问题,否则如果开发人员自己去解决,那就费时费力了。

netty 中通过提供一系列的编解码器来解决 TCP 的粘包、半包问题,顾名思义,编解码器就是通过将从 TCP 套接字中读取的字节流通过一定的规则,将其进行编码或者解码,编码成二进制字节流或者解析出一个个完整的数据包。在 netty 中提供了很多通用的编解码器,对于解码器而言,它们均继承自抽象类ByteToMessageDecoder;对于编码器而言,它们均继承与抽象类MessageToByteEncoder

今天主要先简单分析下抽象类解码器ByteToMessageDecoder类的源码,对于具体的解码器实现将在后面两篇文章中详细分析其原理,对于编码器而言,编码过程与解码过程恰好相反,因此就不再单独赘述,有兴趣的朋友可以自行阅读,欢迎分享。

ByteToMessageDecoder实际上就是一个 ChannelHandler,它的具体实现类需要被添加到 pipeline 中才会起作用。当将解码器添加到 pipeline 中后,当出现 OP_READ 事件时,就会通过 pipeline 传播执行所有 handler 的 channelRead() 方法。在抽象类解码器中,就定义了 channelRead()方法。其源码如下。

@Override
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 {
            // 省略部分代码...

            // size是解码出来的数据对象的数量,
            int size = out.size();
            decodeWasNull = !out.insertSinceRecycled();
            // 向下传播,如果size为0,就表示没有解码出一个对象,因此不会向下传播,而是等到下一次继续读到数据后解码
            fireChannelRead(ctx, out, size);
            out.recycle();
        }
    } else {
        ctx.fireChannelRead(msg);
    }
}

netty 中解码器的核心逻辑就是先通过一个累加器将读到的字节流数据累计,然后调用解码器的实现类对累加到的数据进行解码,如果能解码出一个数据对象,就表示读到了一个完整的数据包,那就将解码出来的数据对象沿着 pipeline 向下传播,交由业务代码去执行后面的业务逻辑。如果没能解码出一个数据对象,那就表示还没有读到一个完整的数据包,就不向下进行传播,而是等待下一次继续有数据读,继续累加字节流数据,直到累加到的数据能解码出一个数据对象,然后再向下传播。

这里有 3 个比较重要的点,第一:字节流数据的累加器;第二:具体的解码操作,这一步是在具体的解码器实现类中完成的;第三:解码出来的数据对象,在由具体的解码器将字节流数据解码出数据对象后,会将对象存放到一个 list 中,然后将数据对象沿着 pipeline 向下传播。接下来我们结合上面的源码,来分析下这几个步骤。

首先会判断 msg 是否是 ByteBuf 类型的对象,对于已经解码出来的字节流数据,此时不会是 ByteBuf 类型,因此也不需要进行解码,从而进入 else 逻辑中,直接将数据对象向下进行传播。对于没有被解码的字节流数据,此时 msg 就是 ByteBuf 类型,因此会进入到 if 逻辑块中进行解码。

在 if 逻辑中,先定义了一个out对象,这个对象可以简单的把它当做一个集合对象,它用来存放成功解码出来的数据对象,也就是上面第三点中提到的 list。接下来会碰到 cumulation 这个对象,它就是一个字节流数据累加器,默认值为MERGE_CUMULATOR,通过判断它是否为空,从而知道是否是第一次读取数据,如果为空,表示前面没有累加数据,因此直接让 msg 等于 cumulation,意思就是将当前的字节流数据 msg 全部累加到累加器 cumulation 中;如果累加器不为空,表示前面累加器中存在一部分数据(前面出现了 TCP 半包现象),因此需要将当前读到的字节流数据 msg 累加到累加器中。

如何累加字节流数据到累加器中的呢?那就是调用累加器的 cumulate() 方法,这里采用的是策略设计模式,默认的累加器为MERGE_CUMULATOR,其源码如下。

public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
    try {
        final ByteBuf buffer;
        // 如果因为空间满了写不了本次的新数据 就扩容
        // cumulation.writerIndex() > cumulation.maxCapacity() - in.readableBytes() 可以装换为如下:
        // cumulation.writeIndex() + in.readableBytes()>cumulation.maxCapacity
        // 即 写指针的位置+可读的数据的长度,如果超过了ByteBuf的最大长度,那么就需要扩容
        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 {
        // 释放内存,防止OOM
        in.release();
    }
}

在代码中可以看到,在将数据累加到累加器中之前,会先判断是否需要扩容,如果需要扩容,就调用 expandCumulation() 方法先进行扩容。最后调用 writeBytes() 方法将数据写入到累加器中,然后将累加器返回。关于扩容的方法,由于这里的累加器是MERGE_CUMULATOR,因此其底层就是进行内存复制。在 netty 中还提供了另一种类型的累加器:COMPOSITE_CUMULATOR,它扩容的时候不需要进行内存复制,而是通过组合 ByteBuf,即CompositeByteBuf类来实现扩容的。

那么问题来了,显然基于内存复制的操作会更慢一点,那 netty 为什么会默认使用基于内存复制的累加器呢?netty 源码里面给的解释如下:

/**
 * Cumulate {@link ByteBuf}s by add them to a {@link CompositeByteBuf} and so do no memory copy whenever possible.
 * Be aware that {@link CompositeByteBuf} use a more complex indexing implementation so depending on your use-case
 * and the decoder implementation this may be slower then just use the {@link #MERGE_CUMULATOR}.
 */

大致意思就是:累加器只是累加数据,具体的解码操作是由抽象解码器的实现类来做的,对于解码器的实现类,此时我们并不知道解码器的实现类具体是如何进行解码的,可能它基于 CompositeByteBuf 类型数据结构,解码起来会更慢,并不一定比直接使用 ByteBuf 快,效率不一定高,因此 netty 默认就直接使用了MERGE_CUMULATOR,也就是基于内存复制的累加器。

当将数据累加到累计器后,就会调用callDecode(ctx, cumulation, out) 来进行解码了。其精简后的源码如下。

protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
    try {
        // 只要有数据可读,就循环读取数据
        while (in.isReadable()) {
            int outSize = out.size();
            // 如果out中有对象,这说明已经解码出一个数据对象了,可以向下传播了
            if (outSize > 0) {
                // 向下传播,并清空out
                fireChannelRead(ctx, out, outSize);
                out.clear();
                if (ctx.isRemoved()) {
                    break;
                }
                outSize = 0;
            }
            // 记录下解码之前的可读字节数
            int oldInputLength = in.readableBytes();
            // 调用解码的方法
            decodeRemovalReentryProtection(ctx, in, out);
            if (ctx.isRemoved()) {
                break;
            }
            // 如果解码前后,out中对象的数量没变,这表明没有解码出新的对象
            if (outSize == out.size()) {
                // 当没解码出新的对象时,累计器中可读的字节数在解码前后也没变,说明本次while循环读到的数据,
                // 不够解码出一个对象,因此中断循环,等待下一次读到数据
                if (oldInputLength == in.readableBytes()) {
                    break;
                } else {
                    continue;
                }
            }
            // out中的对象数量变了,说明解码除了新的对象,但是解码前后,累计器中的可读数据并没有变化,这表示出现了异常
            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);
    }
}

该方法的第二个参数 in 就是前面我们提到的累积器,第三个参数 out,就是前面提到的存储成功解码出来的数据对象。该方法的逻辑可以参考上面代码中的注释,解码的核心代码在这一行:

// 调用解码的方法
decodeRemovalReentryProtection(ctx, in, out);

这个方法的源代码如下。在它的代码中,会就会真正去调用解码器子类的 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) {
            handlerRemoved(ctx);
        }
    }
}

decode(ctx, in, out) 方法是一个抽象方法,它由解码器的具体实现类去实现具体的逻辑。显然,在父类中,调用一个抽象方法,抽象方法具体的逻辑由子类自己实现,这里用到的是模板方法设计模式。netty 中常用的解码器有如下几种,如下所示。关于子类解码的核心逻辑,后面两篇文章分析。

FixedLengthFrameDecoder(基于固定长度的解码器)
LineBasedFrameDecoder (基于行分隔符的解码器)
DelimiterBasedFrameDecoder (基于自定义分割符的解码器)
LengthFieldBasedFrameDecoder (基于长度字段的解码器)

最后回到channelRead() 方法的 finally 语句块中(代码如下),会先获取 out 中解码出来的数据对象的数量,然后调用 fireChannelRead() 方法将解析出来的数据对象向下进行传播处理。如果 size 为 0,就表示没有解码出一个对象,因此不会向下传播,而是等到下一次继续读到数据后解码。

finally {
    // 省略部分代码...

    // size是解码出来的数据对象的数量,
    int size = out.size();
    decodeWasNull = !out.insertSinceRecycled();
    // 向下传播,如果size为0,就表示没有解码出一个对象,因此不会向下传播,而是等到下一次继续读到数据后解码
    fireChannelRead(ctx, out, size);
    out.recycle();
}

总结

本文先介绍了什么是 TCP 的粘包、半包现象,就是将多个独立的数据包合成或者拆分成多个数据包发送,以及产生粘包、半包现象的根本原因是 TCP 协议是基于字节流传输数据的。然后结合源码介绍了 netty 通过编解码器是如何来解决粘包、半包问题。

解决粘包半包拆包问题

netty使用HTTP协议解决粘包问题

关于原理参考:https://liuhuiyao.blog.csdn.net/article/details/107742524

client

server

 

FixedLengthFrameDecoder(基于固定长度的解码器)

注意:试用与传输字符串,如果不达固定长度,使用空格补全。如果不补全可能会出现以下问题:

例如设置的长度为2,发送了abc三个字节数据。则服务端的handler中会打印两次:ab 和ca。

参考:https://blog.csdn.net/Howinfun/article/details/81059952


LineBasedFrameDecoder (基于行分隔符的解码器额)

适用于传输字符串,每条数据最后要加换行分隔符,否则接收消息端收不到消息,如果手写换行分割,要记得区分不同系统得适配。

DelimiterBasedFrameDecoder (基于自定义分割符的解码器)

适用于传输字符串,要保证传输数据 不能包含特殊字符,

参考:https://www.jianshu.com/p/c90ec659397c


LengthFieldBasedFrameDecoder (基于长度字段的解码器)

适用于各种序列化后对象byte流。

自定义协议

使用与各种序列化后的对象byte流。

 

netty重要组件类及运行流程

参考https://www.cnblogs.com/free-wings/p/9309148.html

重要服务端组件类:

NioEventLoopGroup:一个线程组,包含了一组NIO线程,专门用于网络事件处理,实际上它们就是Reactor线程组。这里创建两个,一个用于服务端接受客户端的连接,另一个用于SocketChannel的网络读写。

ServerBootstrap:Netty用于启动NIO服务端的辅助启动类,降低开发复杂度。它的group方法将两个NIO线程组当作入参传递到ServerBootstrap中。backlog:TCP参数,这里设置为1024.

NioServerSocketChannel:功能对应于JDK NIO类库中的ServerSocketChannel类。

ChildChannelHandler:绑定I/O事件的处理类,作用类似于Reactor模式中的Handler类,主要用于处理网络I/O事件,例如记录日志、对消息进行编解码等。

服务端启动辅助类配置完成后,调用它的bind方法绑定监听端口,随后调用它的同步阻塞方法sync等待绑定操作完成。完成之后Netty会返回一个ChannelFuture,它的功能类似于JDK的java.util.concurrent.Future,主要用于异步操作的通知回调。

future.channel().closeFuture.sync()是阻塞方法(一直阻塞,直到服务关闭),等待服务端链路关闭后才退出。

shutdownGracefully方法:优雅退出,释放关联资源。

ByteBuf:类似于JDK中的java.nio.ByteBuffer对象,不过它提供了更加强大和灵活的功能。通过ByteBuf的readableBytes方法可获取缓冲区可读字节数,根据可读字节数创建byte数组,通过ByteBuf的readBytes方法将缓冲区字节数组复制到新建byte数组中,通过ChannelHandlerContext的write方法异步发送应答消息给客户端。

ChannelHandlerContext的flush方法:将消息发送队列中的消息写入到SocketChannel中发送给对方。从性能角度考虑,为了防止频繁唤醒Selector进行消息发送(Writable事件),Netty的write方法并不直接将消息写入SocketChannel中,只是把待发送的消息放到发送缓冲数组中,再通过调用flush方法,将发送缓冲区中的消息全部写到SocketChannel中。

服务端创建、客户端接入源码与流程

1)创建ServerBootstrap实例。ServerBootstrap是Netty服务端启动辅助类,提供了一系列方法用于设置服务端启动相关参数,底层通过门面模式对各种能力进行抽象和封装,降低用户开发难度。ServerBootstrap只有一个无参构造函数,因为需要设置的参数太多了,且可能发生变化,故采用的是Builder模式。

2)设置并绑定Reactor线程池。ServerBootstrap的线程池是EventLoopGroup,实际就是EventLoop数组。EventLoop的职责是处理所有注册到本线程多路复用器Selector上的Channel,Selector轮询操作由绑定的EventLoop线程run方法驱动,在一个循环体内循环执行。EventLoop不仅仅处理网络I/O事件,用户自定义Task和定时任务Task也统一由EventLoop负责处理,实现了线程模型统一。从调度层面看,也不存在从EventLoop线程中再启动其他类型线程用于异步执行其他任务。避免了多线程并发操作和锁竞争,提升了I/O线程的处理和调度性能。

创建两个EventLoopGroup(不是必需两个,可只创建一个共享),前一个是父,后一个是子。父被传入父类构造函数。

3)设置并绑定服务端Channel.作为NIO服务端,需创建ServerSocketChannel.Netty对原生NIO类库进行了封装,对应实现是NioServerSocketChannel.Netty的ServerBootstrap提供了channel方法用于指定服务端Channel类型,通过工厂类,利用反射创建NioServerSocketChannel对象。

指定NioServerSocketChannel后,设置TCP的backlog参数,底层C对应接口:

int listen(int fd,int backlog);

backlog指定内核为此套接口排队的最大连接个数。对于给定监听套接口,内核要维护两个队列:未链接队列和已连接队列,根据TCP三路握手过程中的三个分节来分隔这两个队列。服务器处于listen状态时,收到客户端syn分节(connect)时在未完成队列中创建 一个新条目,然后后三路握手的第二个分节即服务器syn响应客户端,此条目在第三分节到达前(客户端对服务器syn的ack)一直保留在未完成 连接队列中。三路握手完成,条目从未完成连接队列搬到已完成连接队列尾部。当进程调用accept时,从已完成队列中的头部取出一个条目给进程。当已完成队列未空时进程将 睡眠,直到有条目才唤醒。backlog被规定为两个队列总和最大值。大多数实现默认为5,高并发下不够,未完成连接队列长度可能因客户端syn的到达及等待三路握手 第三分节的到达延时而增大。Netty默认backlog为100,用户可根据实际场景和网络状况 灵活设置。

4)链路建立时创建并初始化ChannelPipeline.ChannelPipeline不是NIO服务端必需,本质是一个负责处理网络事件的职责链,管理和执行ChannelHandler.网络事件以事件流形式流转,根据ChannelHandler执行策略调度执行。典型网络事件:

a.链路注册 b.链路激活 c.链路断开 d.接收到请求消息 e.请求消息接收并处理完毕 f.发送应答消息 g.链路发生异常 h.发生用户自定义事件

5)添加并设置ChannelHandler.ChannelHandler是Netty提供给用户定制和扩展的关键接口,例如消息编解码,心跳,安全认证,TSL/SSL认证,流量控制,流量整形等。Netty提供了大量系统ChannelHandler,比较实用的如下:

a.系统编解码框架:ByteToMessageCodec b.通用基于长度的半包解码器:LengthFieldBasedFrameDecoder c.码流日志打印:LoggingHandler d.SSL安全认证:SslHandler e.链路空闲检测:IdleStateHandler f.流量整形:ChannelTrafficShapingHandler g.Base64编解码:Base64Decoder和Base64Encoder

创建和添加ChannelHandler示例代码:

 bossGroup = new NioEventLoopGroup();
        workerGroup = new NioEventLoopGroup();
        try {
            bootstrap = new ServerBootstrap();
            bootstrap.group(bossGroup,workerGroup)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG,1024)
                    .childHandler(new         ChannelInitializer<SocketChannel>(){
                        @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        socketChannel.pipeline().addLast(new LineBasedFrameDecoder(1024));
        socketChannel.pipeline().addLast(new StringDecoder());
        socketChannel.pipeline().addLast(new TimeServerHandler());//由简入难,不断调试、琢磨的框架
                }
            });
            //同步等待绑定端口成功
            ChannelFuture future = bootstrap.bind(port).sync();
            System.out.println("NettyServer Successfully Started in port:" + port);
            logger.info("NettyServer Successfully Started in port:" + port);
            //同步等待服务器端口关闭
            future.channel().closeFuture().sync();//经实验,这是阻塞方法,一直阻塞
        }catch (Exception e) {
            //优雅退出,释放线程池资源
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }    

用户可为启动辅助类和其父类分别指定Handler.两类Handler的用途不同:子类Handler是NioServerSocketChannel对应的ChannelPipeline的Handler,父类中的Handler是客户端新接入的连接SocketChannel对应的ChannelPipeline的Handler.

本质区别就是:ServerBootstrap中的Handler是NioServerSocketChannel使用的,所有连接该监听端口的客户端都会执行它;父类AbstractBootstrap中的Handler是个工厂类,为每个新接入的客户端都创建一个新的Handler.

6)绑定并启动监听端口。将ServerSocketChannel注册到Selector上监听客户端连接。

创建新的NioServerSocketChannel,两个参数:第一个参数是从父类NIO线程池中顺序获取的一个NioEventLoop,它就是服务端用于监听和接收客户端连接的Reactor线程,第二个参数是所谓workerGroup线程池,是处理I/O读写的Reactor线程组。

NioServerSocketChannel创建成功后的初始化:

a.设置Socket参数和NioServerSocketChannel附加属性 b.将AbstractBootstrap的Handler添加到NioServerSocketChannel的ChannelPipeline c.将用于服务端注册的Handler(ServerBootstrapAcceptor)添加到ChannelPipeline

NioServerSocketChannel注册:封装成Task投递到NioEventLoop,将NioServerSocketChannel注册到NioEventLoop的Selector上(此时注册0,不监听任何网络操作)

注册成功后,触发ChannelRegistered事件,判断监听是否成功,成功则触发ChannelActive事件,根据配置决定是否自动触发Channel的读事件,最终触发ChannelPipeline读操作,调用到HeadHandler的读方法(业务处理)。不同Channel(无论客户端还是服务端)对读操作准备工作不同,因此doBeginRead是个多态方法,这里都要修改网络监听操作位为自身感兴趣的,NioServerSocketChannel感兴趣的为OP_ACCEPT(16)

用4 bit表示所有4种网络操作类型:OP_READ 0001 OP_WRITE 0010 OP_CONNECT 0100 OP_ACCEPT 1000

好处是方便通过位操作进行操作位判断和状态修改,提升操作性能。

7)Selector轮询。由Reactor线程NioEventLoop负责调度和执行Selector轮询操作,选择就绪的Channel集合。

根据就绪的操作位,执行不同操作。NioServerSocketChannel监听的是连接操作,执行的是NioUnsafe(接口)的read方法,这里使用的是NioMessageUnsafe(实现类,还有一个NioByteUnsafe)

doReadMessages方法:实际就是接收新的客户端连接并创建NioSocketChannel.

接收到新的客户端连接后,触发ChannelPipeline的ChannelRead方法,执行headChannelHandlerContext的fireChannelRead方法(触发事件),事件在ChannelPipeline中传递,执行ServerBootstrapAcceptor的channelRead方法,该方法分三个步骤:

a.将启动时传入的childHandler加入到客户端SocketChannel的ChannelPipeline中

b.设置客户端SocketChannel的TCP参数 c.注册SocketChannel到多路复用器

NioSocketChannel注册:仍注册操作位为0,触发ChannelReadComplete事件,执行ChannelPipeline的read方法,执行HeadHandler的read方法,将网络操作位改为OP_READ.

到此,新接入客户端连接处理完成,可进行网络读写I/O操作

8)轮询到准备就绪的Channel后,由Reactor线程NioEventLoop执行ChannelPipeline相应方法(fire各种事件,触发各种ChannelHandler的事件回调,观察者模式),最终调度并执行ChannelHandler.

9)根据网络事件类型,调度执行Netty系统ChannelHandler和用户定制ChannelHandler.

 

其他问题

关于主从多线程boss线程为多个时的问题

EventLoopGroup bossGroup=new NioEventLoopGroup(2);//accept线程池

EventLoopGroup workerGroup=new NioEventLoopGroup(2);//具体负责读写及业务处理的工作线程池


ServerBootstrap b = new ServerBootstrap();

b.group(bossGroup, workerGroup);

 

handler()和childHandler()、childOption()和option有什么区别

childHandler()和childOption()都是给workerGroup (也就是group方法中的childGroup参数)进行设置的,option()和handler()都是给bossGroup(也就是group方法中的parentGroup参数)设置的。

option()和handler()是在server启动时就会对boss进行设置和调用

childHandler()和childOption()是在连接建立是设置和调用的。其实都是对的。

 bossGroup是在服务器一启动就开始工作,负责监听客户端的连接请求。当建立连接后就交给了workGroup进行事务处理。

 

运行报错:is not a @Sharable handler, so can't be added or removed multiple times.

(1)对于childHandler

childHandler(new ChannelInitializer<SocketChannel>() {
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            socketChannel.pipeline().addLast(new EchoServerHandler());//这里要是new的,多线程不能共用1个实例
                        }
                    });

(2)对于handler

handler(new EchoServerHandler2())

并在自定义handler上加@ChannelHandler.Sharable

关于客户端 设置多线程不起作用

EventLoopGroup group=new NioEventLoopGroup(2); 

但是发现每次获取服务器返回的消息的线程都是同一个,不知为何,有待研究??????

©️2020 CSDN 皮肤主题: 成长之路 设计师:Amelia_0503 返回首页
实付 19.90元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值