2020-6-17,开始重读《netty实战》一书

  这本书和前面那本《netty权威指南》不太一样的是,这本书开始先介绍netty中的各类组件,事实上这是这本书的第一部分, 然后介绍一些编解码器,接下来介绍各类网络协议在netty中的实现,最后研究了一些案例,整体上来说,这本书写得更好一些, 那本书里面粘贴了很多无用的代码,而且并不细致。

  一、异步和事件驱动

  Netty 的异步编程模型是建立在Future 和回调的概念之上。

  Netty 通过触发事件将Selector 从应用程序中抽象出来,消除了所有本来将需要手动编写的派发代码。这个dispatch代码就是经典的 Reactor模式下的将各个IO事件分发到各个handler中去,在内部,将会为每个Channel 分配一个EventLoop,用以处理所有事件,包括: 注册感兴趣的事件; 将事件派发给ChannelHandler; 安排进一步的动作。 EventLoop 本身只由一个线程驱动,其处理了一个Channel 的所有I/O 事件,并且在该EventLoop 的整个生命周期内都不会改变。

  问题1:channelRead方法和channelReadComplete方法分别是在什么时候调用的?什么时候会出现channelReadComplete 方法调用了而channelRead方法还没有调用?

  问题2:SimpleChannelInboundHandler 与ChannelInboundHandler的区别在于什么?大概是涉及到对于消息占用内存的释放

  三、netty组件设计   

  1、channel接口

  channel中一些基本的IO操作(bind()、connect()、read()、write())都依赖于Java socket的操作,当然本质上是底层操作系统的 API,主要用到的channel接口是NioServerSocketChannel、NioSocketChannel、NioDatagramChannel。

  2、EventLoop

  一般来说

  • 1) 一个EventLoopGroup包含一个或者多个EventLoop,这一点和Java自带的ThreadGroup和Thread之间的关系;
  • 2) 一个EventLoop在其生命周期内只和一个Thread绑定;
  • 3) 所有由EventLoop处理的IO事件都将在它专有的Thread上进行处理(因此没有同步的问题);
  • 4) 一个channel在其生命周期内只注册于一个EventLoop;
  • 5) 一个EventLoop可能会被分配给多个channel。

  3、ChannelFuture

  netty中所有的IO事件都是异步的,因此需要future来为结果添加相应的监听器,用于某个操作真正完成后的回调操作。

  4、ChannelPipeline接口

  这里你需要注意的是,不管是在服务端还是在客户端我们编写代码的时候,总是通过ChannelInitializer这个类来将自定义的ChannelHandler 安装到ChannelPipeline中。ChannelPipeline 提供了ChannelHandler 链的容器,当Channel 被创建时,它会被自动地分配到它专属的 ChannelPipeline。ChannelHandler 安装到ChannelPipeline 中的过程如下所示:

  • 1) 一个ChannelInitializer的实现被注册到了ServerBootstrap中(对应客户端则是Bootstrap);
  • 2) 当ChannelInitializer.initChannel()方法被调用时,ChannelInitializer将在ChannelPipeline 中安装一组自定义的 ChannelHandler;
  • 3) ChannelInitializer 将它自己从ChannelPipeline 中移除。这里需要理解的是ChannelInitializer自身就是一个 ChannelHandler,或者准确来说是一个ChannelInboundHandler,它需要将自己移除(无须业务逻辑关心这个移除步骤)。

  当ChannelHandler 被添加到ChannelPipeline 时,它将会被分配一个ChannelHandlerContext,其代表了ChannelHandler和 ChannelPipeline 之间的绑定。虽然这个对象可以被用于获取底层的Channel,但是它主要还是被用于写出站数据

  在Netty 中,有两种发送消息的方式。你可以直接写到Channel中,也可以写到和ChannelHandler相关联的ChannelHandlerContext对象 中。前一种方式将会导致消息从ChannelPipeline 的尾端开始流动(因为出站是从尾部开始的),而后者将导致消息从ChannelPipeline中 的下一个ChannelHandler开始流动。

  事实上,后面将要学习的编解码器,都是ChannelHandler,解码器往往是ChannelPipeline中的第一个ChannelHandler,编码器应该是 最后一个,对应着入站和出站数据流;除此以外还有一种ChannelHandler,叫做SimpleChannelInboundHandler,这个也比较重要。

  引导一个客户端只需要一个EventLoopGroup,但是一个服务端ServerBootstrap 则需要两个(也可以是同一个实例)。这个原因是: 服务器需要两组不同的Channel。第一组将只包含一个ServerChannel,代表服务器自身的已绑定到某个本地端口的正在监听的套接字。而第二组将 包含所有已创建的用来处理传入客户端连接(对于每个服务器已经接受的连接都有一个)的Channel。所以第一组的EventLoopGroup往往也只设置一 个EventLoop即可。

  四、传输(channel相关)

  netty内置了一些比较有用的传输,除了我们常用的NioSocketChannel和OioSocketChannel以外,还有一个专门的Epoll传输方式,由 JNI 驱动的 epoll()和非阻塞 IO。这个传输 只有在Linux 上可用的多种特性,如SO_REUSEPORT,比NIO 传输更快,而且是完全非阻塞的。它是Netty 特有的实现,更加适配Netty 现有的线程模型,具有更高的性能以及更低的垃圾回收压力, 但是似乎使用的并不如java.nio多,最常用的NioSocketChannel还是基于Java NIO的选择器那一套来实现的。下面一一介绍一下

  1、NIO:这就是基于Java NIO那一套channel和选择器来实现的,channel注册到选择器上,并注册自己感兴趣的事件,通过选择器的select方法,来选择出那些就绪的通道,然后一一遍历处理。 这里提到了一个新的知识点——零拷贝(zero copy),它支持用户快速高效的将数据从文件系统移动到网络接口(socket),而不需要将其从内核空间移动到用户空间,因为传统意义上,你要将一个数据从文件发送到 网络,首先需要read文件,这里涉及到系统调用,数据从内核态转移到用户态,然后需要向socket中写数据,又需要一次系统调用,数据再次由用户态转移到内核,但是零拷贝就可以省去这两个过程,在像FTP或者HTTP 这样 的协议中可以显著地提升性能。但是,并不是所有的操作系统都支持这一特性。而且零拷贝目前只有在使用NIO或者Epoll传输时才能用这个特性

  2、Epoll:Netty 的NIO 传输基于Java 提供的异步/非阻塞网络编程的通用抽象。虽然这保证了Netty 的非阻塞API 可以在任何平台上使用,但它也包含了相应的限制,因为JDK为了在所有系统上提供相同的功能, 必须做出妥协。因此诞生了仅用于Linux的本地非阻塞传输,只需要在代码中将NioEventLoopGroup替换为EpollEventLoopGroup,并且将NioServerSocketChannel.class替换为 EpollServerSocketChannel.class即可。那么这里有一个问题:前面Java NIO自带的选择器selector也是利用了多路复用IO,在Linux上使用的也是select或者epoll这个系统调用,和这个Epoll的区别在哪?

  answer:区别在于JDK 的实现是水平触发,而Netty 的(默认的)是边沿触发。使用Epoll传输也是基于选择器和channel来实现的,本质上和Java NIO一样,但是如果使用Java NIO,是在任何平台上都可以 使用的,比如说在freeBSD下有Kqueue,你用Java NIO也是可以的,但是用Epoll就不行了,因为这个传输方式只支持Linux。

  3、Oio:Netty 的OIO 传输实现代表了一种折中:它可以通过常规的传输API 使用,但是由于它是建立在java.net 包的阻塞实现之上的,所以它不是异步的。

  4、Local传输——用于JVM内部通信:Netty 提供了一个Local 传输,用于在同一个JVM 中运行的客户端和服务器程序之间的异步通信,在这个传输中,和服务器Channel 相关联的SocketAddress 并没有绑定物理网络地址。

  5、Embedded 传输:主要用于测试你的ChannelHandler的内部逻辑时。后面会详细介绍。

  五、ByteBuf

  netty自己构建了字节容器ByteBuf,使用比Java NIO自带的ByteBuffer更加简单,至少读写指针上就容易了不少。这里需要注意的是复合缓冲区,池化和非池化的缓冲区,堆和直接缓冲区。

  六、ChannelHandler和ChannelPipeline

  1、Channel的生命周期

  • ChannelUnregistered: Channel 已经被创建,但还未注册到EventLoop
  • ChannelRegistered: Channel 已经被注册到了EventLoop
  • ChannelActive: Channel 处于活动状态(已经连接到它的远程节点)。它现在可以接收和发送数据了
  • ChannelInactive: Channel 没有连接到远程节点

  Channel 的正常生命周期是从register—>active—>inactive—>unregister。当这些状态发生改变时,将会生成对应的事件。这些事件将会被转发给ChannelPipeline中的ChannelHandler, 其可以随后对它们做出响应。

  2、ChannelHandler的生命周期方法

  在ChannelHandler被添加到ChannelPipeline 中或者被从ChannelPipeline 中移除时会调用下面操作。这些方法中的每一个都接受一个ChannelHandlerContext 参数。

  • handlerAdded 当把ChannelHandler 添加到ChannelPipeline 中时被调用
  • handlerRemoved 当从ChannelPipeline 中移除ChannelHandler 时被调用
  • exceptionCaught 当处理过程中在ChannelPipeline 中有错误产生时被调用

  Netty 定义了下面两个重要的ChannelHandler 子接口:

  • 1) ChannelInboundHandler——处理入站数据以及各种状态变化;
  • 2) ChannelOutboundHandler——处理出站数据并且允许拦截所有的操作。

  3、ChannelInboundHandler的生命周期方法

  这些方法在数据被接收或者与其对应的Channel状态发生改变时被调用,它们和Channel的生命周期密切相关。

  • 1) channelRegistered:当channel已经注册到它的EventLoop并且能够处理IO时被调用;
  • 2) channelUnregistered:当channel从它的EventLoop注销并且无法处理任何IO时调用;
  • 3) channelActive:当channel处于活动状态,已经连接到远程节点时或者是已经绑定到某个IP地址时,被调用;
  • 4) channelInactive:当Channel 离开活动状态并且不再连接它的远程节点时被调用;
  • 5) channelReadComplete:当Channel上的一个读操作完成时被调用,当所有可读的字节都已经从Channel 中读取之后,将会调用 该回调方法;所以,可能在channelReadComplete()被调用之前看到多次调用channelRead();
  • 6) channelRead:当从Channel 读取数据时被调用;
  • 7) channelWritabilityChanged:当channel的可写状态发生改变时调用;
  • 8) userEventTriggered:当ChannelInboundHandler.fireUserEventTriggered()方法被调用时被调用,因为一个POJO 被传经了ChannelPipeline;

  当某个ChannelInboundHandler 的实现重写channelRead()方法时,它将负责显式地释放与池化的ByteBuf 实例相关的内存。 Netty 为此提供了一个实用方法ReferenceCountUtil.release()。

  解答前面问题2:一个更加简单的方式是使用SimpleChannelInboundHandler,它自动释放相关的内存,不用业务逻辑来关注了。

  4、ChannelOutboundHandler的生命周期方法

  出站操作和数据将由ChannelOutboundHandler 处理。它的方法将被Channel、ChannelPipeline 以及ChannelHandlerContext 调用。ChannelOutboundHandler的一个强大的功能是可以按需推迟操作或者事件,这使得可以通过一些复杂的方法来处理请求。

  • 1) bind(ChannelHandlerContext, SocketAddress, ChannelPromise): 当请求将Channel绑定到某个地址时被调用;
  • 2) connect(ChannelHandlerContext, SocketAddress, SocketAddress, ChannelPromise):当请求将Channel 连接到远程节点时被调用;
  • 3) disconnect(ChannelHandlerContext, ChannelPromise):当请求将Channel 从远程节点断开时被调用;
  • 4) close(ChannelHandlerContext, ChannelPromise):当请求关闭Channel 时被调用;
  • 5) deregister(ChannelHandlerContext, ChannelPromise): 当请求将Channel 从它的EventLoop 注销时被调用
  • 6) read(ChannelHandlerContext): 当请求从Channel 读取更多的数据时被调用
  • 7) flush(ChannelHandlerContext): 当请求通过Channel 将入队数据冲刷到远程节点时被调用
  • 8) write(ChannelHandlerContext, Object, ChannelPromise):当请求通过Channel 将数据写到远程节点时被调用

  ChannelOutboundHandler中的大部分方法都需要一个ChannelPromise参数,以便在操作完成时得到通知。ChannelPromise是ChannelFuture的一个子类,其定义了一些可写的方法,如setSuccess() 和setFailure(),从而使ChannelFuture不可变(这里借鉴了Scala的Promise设计)。

  每当通过调用ChannelInboundHandler.channelRead()或者ChannelOutboundHandler.write()方法来处理数据时,你都需要确保没有任何的资源泄漏。注意该释放内存时需要手动释放。

  总之,如果一个消息被消费或者丢弃了,并且没有传递给ChannelPipeline 中的下一个ChannelOutboundHandler,那么用户就有责任调用ReferenceCountUtil.release()。如果消息到达了实际的传 输层,那么当它被写入时或者Channel 关闭时,都将被自动释放。

  ChannelPipeline同时继承了ChannelInboundInvoker和ChannelOutboundInvoker,它的一系列方法用于触发位于这个ChannelPipeline中的相应ChannelHandler的相应事件,可能是入站也可能是 出站事件。

  整体来讲,这一章是比较繁杂的,也不是很容易理解,涉及到的类和接口众多

  七、EventLoop和线程模型

  一个EventLoop继承了Java的ScheduledExecutorService,另外,还自定义了一个parent()方法,用于返回当前EventLoop 实例所属的EventLoopGroup,每一个EventLoop仅由一个Thread驱动,单个EventLoop可以分配给多个channel,这里值得注意的是, EventLoop不仅可以用来执行channel上的一些IO任务,还可以执行一些定时任务。

  八、引导

  分别作用于客户端的Bootstrap和作用于服务端的ServerBootstrap,服务端致力于使用一个父channel来接受客户端的连接,并 创建子channel以用于它们之间的通信;客户端往往只需要一个单独的,没有父channel的channel来和服务端进行网络交互(对于无连接 的UDP协议也适用)

  大体上,这本书感觉翻译的还是有一些问题,中间有一些细节上的概念并没有完全搞懂,打算看一下李林峰的第二本书,从更多实战的 角度讲netty的,希望比那本权威指南要好一些吧。