6. Netty的原理与基础

首先引用Netty官网的内容对Netty进行一个正式的介绍。

Netty是为了快速开发可维护的高性能、高可扩展、网络服务器和客户端程序而提供的异步事件驱动基础框架和工具。换句话说,Netty是一个Java NIO客户端/服务器框架。基于Netty,可以快速轻松地开发网络服务器和客户端的应用程序。与直接使用Java NIO相比,Netty给大家造出了一个非常优美的轮子,它可以大大简化了网络编程流程。例如,Netty极大地简化TCP、UDP套接字、HTTP Web服务程序的开发。

Netty的目标之一,是要使开发可以做到“快速和轻松”。除了做到“快速和轻松”的开发TCP/UDP等自定义协议的通信程序之外,Netty经过精心设计,还可以做到“快速和轻松”地开发应用层协议的程序,如FTP,SMTP,HTTP以及其他的传统应用层协议。

Netty的目标之二,是要做到高性能、高可扩展性。基于Java的NIO,Netty设计了一套优秀的Reactor反应器模式。后面会详细介绍Netty中反应器模式的实现。在基于Netty的反应器模式实现中的Channel(通道)、Handler(处理器)等基类,能快速扩展以覆盖不同协议、完成不同业务处理的大量应用类。

6.1 第一个Netty的实践案例DiscardServer

在开始实践之前,第一步就是要准备Netty的版本,配置好开发环境。

6.1.1 创建第一个Netty项目

首先我们需要创建项目,项目名称是NettyDemos。这是一个丢弃服务器(DiscardServer),功能很简单:读取客户端的输入数据,直接丢弃,不给客户端任何回复。

在使用Netty前,首先需要考虑一下JDK的版本,官网建议使用JDK1.6以上,本书使用的是JDK1.8。然后是Netty自己的版本,建议使用Netty 4.0以上的版本。虽然Netty在不断升级,但是4.0以上的版本使用比较广泛。Netty曾经升级到5.0,不过出现了一些问题,版本又回退了。本书使用的Netty版本是4.1.6。

使用maven导入Netty以依赖到工程(或项目),Netty的maven依赖如下:



    io.netty
    netty-all
    4.1.6.Final

那么现在可以正式开始编写第一个项目程序了。

6.1.2 第一个Netty服务器端程序

这里创建一个服务端类:NettyDiscardServer,用以实现消息的Discard“丢弃”功能,它的源代码如下:

//...
public class NettyDiscardServer {
    private final int serverPort;
    ServerBootstrap b = new ServerBootstrap();
    public NettyDiscardServer(int port) {
        this.serverPort = port;
    }
    public void runServer() {
        //创建反应器线程组
        EventLoopGroup bossLoopGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerLoopGroup = new NioEventLoopGroup();
        try {
           //1 设置反应器线程组
           b.group(bossLoopGroup, workerLoopGroup);
           //2 设置nio类型的通道
           b.channel(NioServerSocketChannel.class);
           //3 设置监听端口
           b.localAddress(serverPort);
           //4 设置通道的参数
           b.option(ChannelOption.SO_KEEPALIVE, true);
           b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
           //5 装配子通道流水线
           b.childHandler(new ChannelInitializer() {
               //有连接到达时会创建一个通道
               protected void initChannel(SocketChannelch) throws Exception {
                   // 流水线管理子通道中的Handler处理器
                   // 向子通道流水线添加一个handler处理器
                   ch.pipeline().addLast(new NettyDiscardHandler());
               }
           });
           // 6 开始绑定服务器
           // 通过调用sync同步方法阻塞直到绑定成功
           ChannelFuturechannelFuture = b.bind().sync();
           Logger.info(" 服务器启动成功,监听端口: " + 
                                channelFuture.channel().localAddress());
           // 7 等待通道关闭的异步任务结束
           // 服务监听通道会一直等待通道关闭的异步任务结束
           ChannelFuturecloseFuture = channelFuture.channel().closeFuture();
           closeFuture.sync();
        } catch (Exception e) {
           e.printStackTrace();
        } finally {
           // 8关闭EventLoopGroup,
           // 释放掉所有资源包括创建的线程
           workerLoopGroup.shutdownGracefully();
           bossLoopGroup.shutdownGracefully();
          }
       }
       public static void main(String[] args) throws InterruptedException {
       int port = NettyDemoConfig.SOCKET_SERVER_PORT;
       new NettyDiscardServer(port).runServer();
    }
}

如果是第一次看Netty开发的程序,上面的代码是看不懂的,因为代码里边涉及很多的Netty组件。

Netty是基于反应器模式实现的。还好,大家已经非常深入地了解了反应器模式,现在大家顺藤摸瓜学习Netty的结构就相对简单了。

首先要说的是Reactor反应器。前面讲到,反应器的作用是进行一个IO事件的select查询和dispatch分发。Netty中对应的反应器组件有多种,应用场景不同,用到的反应器也各不相同。一般来说,对应于多线程的Java NIO通信的应用场景,Netty的反应器类型为:NioEventLoopGroup。

在上面的例子中,使用了两个NioEventLoopGroup实例。第一个通常被称为“包工头”,负责服务器通道新连接的IO事件的监听。第二个通常被称为“工人”,主要负责传输通道的IO事件的处理。

其次要说的是Handler处理器(也称为处理程序)。Handler处理器的作用是对应到IO事件,实现IO事件的业务处理。Handler处理器需要专门开发,稍后,将专门对它进行介绍。

再次,在上面的例子中,还用到了Netty的服务启动类ServerBootstrap,它的职责是一个组装和集成器,将不同的Netty组件组装在一起。另外,ServerBootstrap能够按照应用场景的需要,为组件设置好对应的参数,最后实现Netty服务器的监听和启动。服务启动类ServerBootstrap也是本章重点之一,稍候另起一小节进行详细的介绍。

6.1.3 业务处理器NettyDiscardHandler

在反应器(Reactor)模式中,所有的业务处理都在Handler处理器中完成。这里编写一个新类:NettyDiscardHandler。NettyDiscardHandler的业务处理很简单:把收到的任何内容直接丢弃(discard),也不会回复任何消息。代码如下:

//...
public class NettyDiscardHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContextctx, Object msg) throws Exception {
        ByteBuf in = (ByteBuf) msg;
        try {
            Logger.info("收到消息,丢弃如下:");
            while (in.isReadable()) {
                System.out.print((char) in.readByte());
            }
        System.out.println();
        } finally {
            ReferenceCountUtil.release(msg);
        }
    }
}

首先说明一下,这里将引入一个新的概念:入站和出站。简单来说,入站指的是输入,出站指的是输出。后面也会有详细介绍。

Netty的Handler处理器需要处理多种IO事件(如可读、可写),对应于不同的IO事件,Netty提供了一些基础的方法。这些方法都已经提前封装好,后面直接继承或者实现即可。比如说,对于处理入站的IO事件的方法,对应的接口为ChannelInboundHandler入站处理接口,而ChannelInboundHandlerAdapter则是Netty提供的入站处理的默认实现。

也就是说,如果要实现自己的入站处理器Handler,只要继承ChannelInboundHandlerAdapter入站处理器,再写入自己的入站处理的业务逻辑。如果要读取入站的数据,只要写在了入站处理方法channelRead中即可。

在上面例子中的channelRead方法,它读取了Netty的输入数据缓冲区ByteBuf。Netty的ByteBuf,可以对应到前面介绍的NIO的数据缓冲区。它们在功能上是类似的,不过相对而言,Netty的版本性能更好,使用也更加方便。后面会另开一节进行详细的介绍。

6.1.4 运行NettyDiscardServer

在上面的例子中,出现了Netty中的各种组件:服务器启动器、缓冲区、反应器、Handler业务处理器、Future异步任务监听、数据传输通道等。这些Netty组件都是需要掌握的,也都是我们在后面要专项学习的。

如果看不懂这个NettyDiscardServer程序,一点儿也没关系。这个程序在本章的目的,仅仅是为大家展示一下Netty开发中会涉及什么内容,给大家留一个初步的印象。

接下来,大家可以启动NettyDiscardServer服务器,体验一下Netty程序的运行。

找到服务器类NettyDiscardServer。启动它的main方法,就启动了这个服务器。

但是,如果要看到最终的丢弃效果,不能仅仅启动服务器,还需要启动客户端,由客户端向服务器发送消息。客户端在哪儿呢?

这里的客户端,只要能发消息到服务器即可,不需要其他特殊的功能。因此,可以直接使用前面示例中的EchoClient程序来作为客户端运行即可,因为端口是一致的。

找到发送消息到服务器的客户端类:EchoClient。启动它的main方法,就启动了这个客户端。然后在客户端的标准化输入窗口,不断输入要发送的消息,发送到服务器即可。

虽然EchoClient客户端是使用Java NIO编写的,而NettyDiscardServer服务端是使用Netty编写的,但是不影响它们之间的相互通信。因为NettyDiscardServer的底层也是使用Java NIO。

client执行结果如下:

[main|EchoClient.start]:客户端启动成功!
[Thread-0|EchoClient$Processer.run]:请输入发送内容:
neetyDiscardServer

服务器执行结果如下:

[main|NettyDiscardServer.runServer] |>  服务器启动成功,监听端口: /0:0:0:0:0:0:0:0:18899 
[nioEventLoopGroup-3-1|NettyDiscardHandler.channelRead] |>  收到消息,丢弃如下: 
2019-11-21 11:37:55 >>neetyDiscardServer

6.2 解密Netty中的Reactor反应器模式

在前面的章节中,已经反复说明:设计模式是Java代码或者程序的重要组织方式,如果不了解设计模式,学习Java程序往往找不到头绪,上下求索而不得其法。故而,在学习Netty组件之前,我们必须了解Netty中的反应器模式是如何实现的。

现在,先回顾一下Java NIO中IO事件的处理流程和反应器模式的基础内容。

6.2.1 回顾Reactor反应器模式中IO事件的处理流程

一个IO事件从操作系统底层产生后,在Reactor反应器模式中的处理流程如图6-1所示。

图6-1 Java Reactor反应器模式中IO事件的处理流程

整个流程大致分为4步,具体如下:

第1步:通道注册。IO源于通道(Channel)。IO是和通道(对应于底层连接而言)强相关的。一个IO事件,一定属于某个通道。但是,如果要查询通道的事件,首先要将通道注册到选择器。只需通道提前注册到Selector选择器即可,IO事件会被选择器查询到。

第2步:查询选择。在反应器模式中,一个反应器(或者SubReactor子反应器)会负责一个线程;不断地轮询,查询选择器中的IO事件(选择键)。

第3步:事件分发。如果查询到IO事件,则分发给与IO事件有绑定关系的Handler业务处理器。

第4步:完成真正的IO操作和业务处理,这一步由Handler业务处理器负责。

以上4步,就是整个反应器模式的IO处理器流程。其中,第1步和第2步,其实是Java NIO的功能,反应器模式仅仅是利用了Java NIO的优势而已。

题外话:上面的流程比较重要,是学习Netty的基础。如果这里看不懂,作为铺垫,请先回到反应器模式的详细介绍部分,回头再学习一下反应器模式。

6.2.2 Netty中的Channel通道组件

Channel通道组件是Netty中非常重要的组件,为什么首先要说的是Channel通道组件呢?原因是:反应器模式和通道紧密相关,反应器的查询和分发的IO事件都来自于Channel通道组件。

Netty中不直接使用Java NIO的Channel通道组件,对Channel通道组件进行了自己的封装。在Netty中,有一系列的Channel通道组件,为了支持多种通信协议,换句话说,对于每一种通信连接协议,Netty都实现了自己的通道。

另外一点就是,除了Java的NIO,Netty还能处理Java的面向流的OIO(Old-IO,即传统的阻塞式IO)。

总结起来,Netty中的每一种协议的通道,都有NIO(异步IO)和OIO(阻塞式IO)两个版本。

对应于不同的协议,Netty中常见的通道类型如下:

  • NioSocketChannel:异步非阻塞TCP Socket传输通道。

  • NioServerSocketChannel:异步非阻塞TCP Socket服务器端监听通道。

  • NioDatagramChannel:异步非阻塞的UDP传输通道。

  • NioSctpChannel:异步非阻塞Sctp传输通道。

  • NioSctpServerChannel:异步非阻塞Sctp服务器端监听通道。

  • OioSocketChannel:同步阻塞式TCP Socket传输通道。

  • OioServerSocketChannel:同步阻塞式TCP Socket服务器端监听通道。

  • OioDatagramChannel:同步阻塞式UDP传输通道。

  • OioSctpChannel:同步阻塞式Sctp传输通道。

  • OioSctpServerChannel:同步阻塞式Sctp服务器端监听通道。

图6-2 NioSocketChannel的继承关系图

一般来说,服务器端编程用到最多的通信协议还是TCP协议。对应的传输通道类型为NioSocketChannel类,服务器监听类为NioServerSocketChannel。在主要使用的方法上,其他的通道类型和这个NioSocketChannel类在原理上基本是相通的,因此,本书的很多案例都以NioSocketChannel通道为主。

在Netty的NioSocketChannel内部封装了一个Java NIO的SelectableChannel成员。通过这个内部的Java NIO通道,Netty的NioSocketChannel通道上的IO操作,最终会落地到Java NIO的SelectableChannel底层通道。NioSocketChannel的继承关系图,如图6-2所示。

6.2.3 Netty中的Reactor反应器

在反应器模式中,一个反应器(或者SubReactor子反应器)会负责一个事件处理线程,不断地轮询,通过Selector选择器不断查询注册过的IO事件(选择键)。如果查询到IO事件,则分发给Handler业务处理器。

Netty中的反应器有多个实现类,与Channel通道类有关系。对应于NioSocketChannel通道,Netty的反应器类为:NioEventLoop。

NioEventLoop类绑定了两个重要的Java成员属性:一个是Thread线程类的成员,一个是Java NIO选择器的成员属性。NioEventLoop的继承关系和主要的成员属性,如下图6-3所示。

图6-3 NioEventLoop的继承关系和主要的成员

通过这个关系图,可以看出:NioEventLoop和前面章节讲到反应器,在思路上是一致的:一个NioEventLoop拥有一个Thread线程,负责一个Java NIO Selector选择器的IO事件轮询。

在Netty中,EventLoop反应器和Netty Channel通道,关系如何呢?理论上来说,一个EventLoopNetty反应器和NettyChannel通道是一对多的关系:一个反应器可以注册成千上万的通道。

图6-4 EventLoop反应器和通道(Channel)的关系

6.2.4 Netty中的Handler处理器

在前面的章节,解读Java NIO的IO事件类型时讲到,可供选择器监控的通道IO事件类型包括以下4种:

• 可读:SelectionKey.OP_READ

• 可写:SelectionKey.OP_WRITE

• 连接:SelectionKey.OP_CONNECT

• 接收:SelectionKey.OP_ACCEPT

在Netty中,EventLoop反应器内部有一个Java NIO选择器成员执行以上事件的查询,然后进行对应的事件分发。事件分发(Dispatch)的目标就是Netty自己的Handler处理器。

Netty的Handler处理器分为两大类:第一类是ChannelInboundHandler通道入站处理器;第二类是ChannelOutboundHandler通道出站处理器。二者都继承了ChannelHandler处理器接口。Netty中的Handler处理器的接口与继承关系,如图6-5所示。

图6-5 Netty中的Handler处理器的接口与继承关系

Netty中的入站处理,不仅仅是OP_READ输入事件的处理,还是从通道底层触发,由Netty通过层层传递,调用ChannelInboundHandler通道入站处理器进行的某个处理。以底层的Java NIO中的OP_READ输入事件为例:在通道中发生了OP_READ事件后,会被EventLoop查询到,然后分发给ChannelInboundHandler通道入站处理器,调用它的入站处理的方法read。在ChannelInboundHandler通道入站处理器内部的read方法可以从通道中读取数据。

Netty中的入站处理,触发的方向为:从通道到ChannelInboundHandler通道入站处理器。

Netty中的出站处理,本来就包括Java NIO的OP_WRITE可写事件。注意,OP_WRITE可写事件是Java NIO的底层概念,它和Netty的出站处理的概念不是一个维度,Netty的出站处理是应用层维度的。那么,Netty中的出站处理,具体指的是什么呢?指的是从ChanneOutboundHandler通道出站处理器到通道的某次IO操作,例如,在应用程序完成业务处理后,可以通过ChanneOutboundHandler通道出站处理器将处理的结果写入底层通道。它的最常用的一个方法就是write()方法,把数据写入到通道。

这两个业务处理接口都有各自的默认实现:ChannelInboundHandler的默认实现为ChannelInboundHandlerAdapter,叫作通道入站处理适配器。ChanneOutboundHandler的默认实现为ChanneloutBoundHandlerAdapter,叫作通道出站处理适配器。这两个默认的通道处理适配器,分别实现了入站操作和出站操作的基本功能。如果要实现自己的业务处理器,不需要从零开始去实现处理器的接口,只需要继承通道处理适配器即可。

6.2.5 Netty的流水线(Pipeline)

来梳理一下Netty的反应器模式中各个组件之间的关系:

(1)反应器(或者SubReactor子反应器)和通道之间是一对多的关系:一个反应器可以查询很多个通道的IO事件。

(2)通道和Handler处理器实例之间,是多对多的关系:一个通道的IO事件被多个的Handler实例处理;一个Handler处理器实例也能绑定到很多的通道,处理多个通道的IO事件。

问题是:通道和Handler处理器实例之间的绑定关系,Netty是如何组织的呢?

Netty设计了一个特殊的组件,叫作ChannelPipeline(通道流水线),它像一条管道,将绑定到一个通道的多个Handler处理器实例,串在一起,形成一条流水线。ChannelPipeline(通道流水线)的默认实现,实际上被设计成一个双向链表。所有的Handler处理器实例被包装成了双向链表的节点,被加入到了ChannelPipeline(通道流水线)中。

重点申明:一个Netty通道拥有一条Handler处理器流水线,成员的名称叫作pipeline。

问题来了:这里为什么将pipeline翻译成流水线,而不是翻译成为管道呢?这是有原因的。具体来说,与流水线内部的Handler处理器之间处理IO事件的先后次序有关。

以入站处理为例。每一个来自通道的IO事件,都会进入一次ChannelPipeline通道流水线。在进入第一个Handler处理器后,这个IO事件将按照既定的从前往后次序,在流水线上不断地向后流动,流向下一个Handler处理器。

在向后流动的过程中,会出现3种情况:

(1)如果后面还有其他Handler入站处理器,那么IO事件可以交给下一个Handler处理器,向后流动。

(2)如果后面没有其他的入站处理器,这就意味着这个IO事件在此次流水线中的处理结束了。

(3)如果在流水线中间需要终止流动,可以选择不将IO事件交给下一个Handler处理器,流水线的执行也被终止了。

为什么说Handler的处理是按照既定的次序,而不是从前到后的次序呢?Netty是这样规定的:入站处理器Handler的执行次序,是从前到后;出站处理器Handler的执行次序,是从后到前。总之,IO事件在流水线上的执行次序,与IO事件的类型是有关系的,如图6-6所示。

图6-6 流水线上入站处理器和出站处理器的执行次序

除了流动的方向与IO操作的类型有关之外,流动过程中经过的处理器节点的类型,也是与IO操作的类型有关。入站的IO操作只会且只能从Inbound入站处理器类型的Handler流过;出站的IO操作只会且只能从Outbound出站处理器类型的Handler流过。

总之,流水线是通道的“大管家”,为通道管理好了它的一大堆Handler“小弟”。

了解完了流水线之后,大家应该对Netty中的通道、EventLoop反应器、Handler处理器,以及三者之间的协作关系,有了一个清晰的认知和了解。至此,大家基本可以动手开发简单的Netty程序了。不过,为了方便开发者,Netty提供了一个类把上面的三个组件快速组装起来,这个系列的类叫作Bootstrap启动器。严格来说,不止一个类名字为Bootstrap,例如在服务器端的启动类叫作ServerBootstrap类。

下面,为大家详细介绍一下这个提升开发效率的Bootstrap启动器类。

6.3 详解Bootstrap启动器类

Bootstrap类是Netty提供的一个便利的工厂类,可以通过它来完成Netty的客户端或服务器端的Netty组件的组装,以及Netty程序的初始化。当然,Netty的官方解释是,完全可以不用这个Bootstrap启动器。但是,一点点去手动创建通道、完成各种设置和启动、并且注册到EventLoop,这个过程会非常麻烦。通常情况下,还是使用这个便利的Bootstrap工具类会效率更高。

在Netty中,有两个启动器类,分别用在服务器和客户端。如图6-7所示。

图6-7 Netty中的两个启动器类

这两个启动器仅仅是使用的地方不同,它们大致的配置和使用方法都是相同的。下面以ServerBootstrap服务器启动类作为重点的介绍对象。

在介绍ServerBootstrap的服务器启动流程之前,首先介绍一下涉及到的两个基础概念:父子通道、EventLoopGroup线程组(事件循环线程组)。

6.3.1 父子通道

在Netty中,每一个NioSocketChannel通道所封装的是Java NIO通道,再往下就对应到了操作系统底层的socket描述符。理论上来说,操作系统底层的socket描述符分为两类:

• 连接监听类型。连接监听类型的socket描述符,放在服务器端,它负责接收客户端的套接字连接;在服务器端,一个“连接监听类型”的socket描述符可以接受(Accept)成千上万的传输类的socket描述符。

• 传输数据类型。数据传输类的socket描述符负责传输数据。同一条TCP的Socket传输链路,在服务器和客户端,都分别会有一个与之相对应的数据传输类型的socket描述符。

在Netty中,异步非阻塞的服务器端监听通道NioServerSocketChannel,封装在Linux底层的描述符,是“连接监听类型”socket描述符;而NioSocketChannel异步非阻塞TCP Socket传输通道,封装在底层Linux的描述符,是“数据传输类型”的socket描述符。

在Netty中,将有接收关系的NioServerSocketChannel和NioSocketChannel,叫作父子通道。其中,NioServerSocketChannel负责服务器连接监听和接收,也叫父通道(Parent Channel)。对应于每一个接收到的NioSocketChannel传输类通道,也叫子通道(Child Channel)。

6.3.2 EventLoopGroup线程组

Netty中的Reactor反应器模式,肯定不是单线程版本的反应器模式,而是多线程版本的反应器模式。Netty的多线程版本的反应器模式是如何实现的呢?

在Netty中,一个EventLoop相当于一个子反应器(SubReactor)。大家已经知道,一个NioEventLoop子反应器拥有了一个线程,同时拥有一个Java NIO选择器。Netty如何组织外层的反应器呢?答案是使用EventLoopGroup线程组。多个EventLoop线程组成一个EventLoopGroup线程组。

反过来说,Netty的EventLoopGroup线程组就是一个多线程版本的反应器。而其中的单个EventLoop线程对应于一个子反应器(SubReactor)。

Netty的程序开发不会直接使用单个EventLoop线程,而是使用EventLoopGroup线程组。EventLoopGroup的构造函数有一个参数,用于指定内部的线程数。在构造器初始化时,会按照传入的线程数量,在内部构造多个Thread线程和多个EventLoop子反应器(一个线程对应一个EventLoop子反应器),进行多线程的IO事件查询和分发。

如果使用EventLoopGroup的无参数的构造函数,没有传入线程数或者传入的线程数为0,那么EventLoopGroup内部的线程数到底是多少呢?默认的EventLoopGroup内部线程数为最大可用的CPU处理器数量的2倍。假设电脑使用的是4核的CPU,那么在内部会启动8个EventLoop线程,相当8个子反应器(SubReactor)实例。

从前文可知,为了及时接受(Accept)到新连接,在服务器端,一般有两个独立的反应器,一个反应器负责新连接的监听和接受,另一个反应器负责IO事件处理。对应到Netty服务器程序中,则是设置两个EventLoopGroup线程组,一个EventLoopGroup负责新连接的监听和接受,一个EventLoopGroup负责IO事件处理。

那么,两个反应器如何分工呢?负责新连接的监听和接受的EventLoopGroup线程组,查询父通道的IO事件,有点像负责招工的包工头,因此,可以形象地称为“包工头”(Boss)线程组。另一个EventLoopGroup线程组负责查询所有子通道的IO事件,并且执行Handler处理器中的业务处理——例如数据的输入和输出(有点儿像搬砖),这个线程组可以形象地称为“工人”(Worker)线程组。

至此,已经介绍完了两个基础概念:父子通道、EventLoopGroup线程组。下一节将介绍ServerBootstrap的启动流程。

6.3.3 Bootstrap的启动流程

Bootstrap的启动流程,也就是Netty组件的组装、配置,以及Netty服务器或者客户端的启动流程。在本节中对启动流程进行了梳理,大致分成了8个步骤。本书仅仅演示的是服务器端启动器的使用,用到的启动器类为ServerBootstrap。正式使用前,首先创建一个服务器端的启动器实例。

//创建一个服务器端的启动器
ServerBootstrap b = new ServerBootstrap();

接下来,结合前面的NettyDiscardServer服务器的程序代码,给大家详细介绍一下Bootstrap启动流程中精彩的8个步骤。

第1步:创建反应器线程组,并赋值给ServerBootstrap启动器实例

//创建反应器线程组
//boss线程组
EventLoopGroupbossLoopGroup = new NioEventLoopGroup(1);
//worker线程组
EventLoopGroupworkerLoopGroup = new NioEventLoopGroup();
//...
//1 设置反应器线程组
b.group(bossLoopGroup, workerLoopGroup);

在设置反应器线程组之前,创建了两个NioEventLoopGroup线程组,一个负责处理连接监听IO事件,名为bossLoopGroup;另一个负责数据IO事件和Handler业务处理,名为workerLoopGroup。

在线程组创建完成后,就可以配置给启动器实例,调用的方法是b.group(bossGroup,workerGroup),它一次性地给启动器配置了两大线程组。

不一定非得配置两个线程组,可以仅配置一个EventLoopGroup反应器线程组。具体的配置方法是调用b.group(workerGroup)。在这种模式下,连接监听IO事件和数据传输IO事件可能被挤在了同一个线程中处理。这样会带来一个风险:新连接的接受被更加耗时的数据传输或者业务处理所阻塞。

在服务器端,建议设置成两个线程组的工作模式。

第2步:设置通道的IO类型

Netty不止支持Java NIO,也支持阻塞式的OIO(也叫BIO,Block-IO,即阻塞式IO)。下面配置的是Java NIO类型的通道类型,方法如下:

//2 设置nio类型的通道
b.channel(NioServerSocketChannel.class);

如果确实需要指定Bootstrap的IO模型为BIO,那么这里配置上Netty的OioServerSocketChannel.class类即可。由于NIO的优势巨大,通常不会在Netty中使用BIO。

第3步:设置监听端口

//3 设置监听端口
b.localAddress(new InetSocketAddress(port));

这是最为简单的一步操作,主要是设置服务器的监听地址。

第4步:设置传输通道的配置选项

//4 设置通道的参数
b.option(ChannelOption.SO_KEEPALIVE, true);
b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);

这里用到了Bootstrap的option()选项设置方法。对于服务器的Bootstrap而言,这个方法的作用是:给父通道(Parent Channel)接收连接通道设置一些选项。

如果要给子通道(Child Channel)设置一些通道选项,则需要用另外一个childOption()设置方法。

可以设置哪些通道选项(ChannelOption)呢?在上面的代码中,设置了一个底层TCP相关的选项ChannelOption.SO_KEEPALIVE。该选项表示:是否开启TCP底层心跳机制,true为开启,false为关闭。

其他的通道设置选项,参见下一小节。

第5步:装配子通道的Pipeline流水线

上一节介绍到,每一个通道的子通道,都用一条ChannelPipeline流水线。它的内部有一个双向的链表。装配流水线的方式是:将业务处理器ChannelHandler实例加入双向链表中。

装配子通道的Handler流水线调用childHandler()方法,传递一个ChannelInitializer通道初始化类的实例。在父通道成功接收一个连接,并创建成功一个子通道后,就会初始化子通道,这里配置的ChannelInitializer实例就会被调用。

在ChannelInitializer通道初始化类的实例中,有一个initChannel初始化方法,在子通道创建后会被执行到,向子通道流水线增加业务处理器。

//5 装配子通道流水线
b.childHandler(new ChannelInitializer() {
//有连接到达时会创建一个通道的子通道,并初始化
protected void initChannel(SocketChannelch) throws Exception {
// 流水线管理子通道中的Handler业务处理器
// 向子通道流水线添加一个Handler业务处理器
ch.pipeline().addLast(new NettyDiscardHandler());
}
});

为什么仅装配子通道的流水线,而不需要装配父通道的流水线呢?原因是:父通道也就是NioServerSocketChannel连接接受通道,它的内部业务处理是固定的:接受新连接后,创建子通道,然后初始化子通道,所以不需要特别的配置。如果需要完成特殊的业务处理,可以使用ServerBootstrap的handler(ChannelHandler handler)方法,为父通道设置ChannelInitializer初始化器。

说明一下,ChannelInitializer处理器器有一个泛型参数SocketChannel,它代表需要初始化的通道类型,这个类型需要和前面的启动器中设置的通道类型,一一对应起来。

第6步:开始绑定服务器新连接的监听端口

// 6 开始绑定端口,通过调用sync同步方法阻塞直到绑定成功
ChannelFuturechannelFuture = b.bind().sync();
Logger.info(" 服务器启动成功,监听端口: " +
channelFuture.channel().localAddress());

这个也很简单。b.bind()方法的功能:返回一个端口绑定Netty的异步任务channelFuture。在这里,并没有给channelFuture异步任务增加回调监听器,而是阻塞channelFuture异步任务,直到端口绑定任务执行完成。

在Netty中,所有的IO操作都是异步执行的,这就意味着任何一个IO操作会立刻返回,在返回的时候,异步任务还没有真正执行。什么时候执行完成呢?Netty中的IO操作,都会返回异步任务实例(如ChannelFuture实例),通过自我阻塞一直到ChannelFuture异步任务执行完成,或者为ChannelFuture增加事件监听器的两种方式,以获得Netty中的IO操作的真正结果。上面使用了第一种。

至此,服务器正式启动。

第7步:自我阻塞,直到通道关闭

// 7 等待通道关闭
// 自我阻塞,直到通道关闭的异步任务结束
ChannelFuturecloseFuture = channelFuture.channel().closeFuture();
closeFuture.sync();

如果要阻塞当前线程直到通道关闭,可以使用通道的closeFuture()方法,以获取通道关闭的异步任务。当通道被关闭时,closeFuture实例的sync()方法会返回。

第8步:关闭EventLoopGroup

// 8关闭EventLoopGroup,
// 释放掉所有资源,包括创建的反应器线程
workerLoopGroup.shutdownGracefully();
bossLoopGroup.shutdownGracefully();

关闭Reactor反应器线程组,同时会关闭内部的subReactor子反应器线程,也会关闭内部的Selector选择器、内部的轮询线程以及负责查询的所有的子通道。在子通道关闭后,会释放掉底层的资源,如TCP Socket文件描述符等。

6.3.4 ChannelOption通道选项

无论是对于NioServerSocketChannel父通道类型,还是对于NioSocketChannel子通道类型,都可以设置一系列的ChannelOption选项。在ChannelOption类中,定义了一大票通道选项。下面介绍一些常见的选项。

1. SO_RCVBUF,SO_SNDBUF

此为TCP参数。每个TCP socket(套接字)在内核中都有一个发送缓冲区和一个接收缓冲区,这两个选项就是用来设置TCP连接的这两个缓冲区大小的。TCP的全双工的工作模式以及TCP的滑动窗口便是依赖于这两个独立的缓冲区及其填充的状态。

2. TCP_NODELAY

此为TCP参数。表示立即发送数据,默认值为True(Netty默认为True,而操作系统默认为False)。该值用于设置Nagle算法的启用,该算法将小的碎片数据连接成更大的报文(或数据包)来最小化所发送报文的数量,如果需要发送一些较小的报文,则需要禁用该算法。Netty默认禁用该算法,从而最小化报文传输的延时。

说明一下:这个参数的值,与是否开启Nagle算法是相反的,设置为true表示关闭,设置为false表示开启。通俗地讲,如果要求高实时性,有数据发送时就立刻发送,就设置为true,如果需要减少发送次数和减少网络交互次数,就设置为false。

3. SO_KEEPALIVE

此为TCP参数。表示底层TCP协议的心跳机制。true为连接保持心跳,默认值为false。启用该功能时,TCP会主动探测空闲连接的有效性。可以将此功能视为TCP的心跳机制,需要注意的是:默认的心跳间隔是7200s即2小时。Netty默认关闭该功能。

4. SO_REUSEADDR

此为TCP参数。设置为true时表示地址复用,默认值为false。有四种情况需要用到这个参数设置:

• 当有一个有相同本地地址和端口的socket1处于TIME_WAIT状态时,而我们希望启动的程序的socket2要占用该地址和端口。例如在重启服务且保持先前端口时。

• 有多块网卡或用IP Alias技术的机器在同一端口启动多个进程,但每个进程绑定的本地IP地址不能相同。

• 单个进程绑定相同的端口到多个socket(套接字)上,但每个socket绑定的IP地址不同。

• 完全相同的地址和端口的重复绑定。但这只用于UDP的多播,不用于TCP。

5. SO_LINGER

此为TCP参数。表示关闭socket的延迟时间,默认值为-1,表示禁用该功能。-1表示socket.close()方法立即返回,但操作系统底层会将发送缓冲区全部发送到对端。0表示socket.close()方法立即返回,操作系统放弃发送缓冲区的数据,直接向对端发送RST包,对端收到复位错误。非0整数值表示调用socket.close()方法的线程被阻塞,直到延迟时间到来、发送缓冲区中的数据发送完毕,若超时,则对端会收到复位错误。

6. SO_BACKLOG

此为TCP参数。表示服务器端接收连接的队列长度,如果队列已满,客户端连接将被拒绝。默认值,在Windows中为200,其他操作系统为128。

如果连接建立频繁,服务器处理新连接较慢,可以适当调大这个参数。

7. SO_BROADCAST

此为TCP参数。表示设置广播模式。

6.4 详解Channel通道

先介绍一下,在使用Channel通道的过程中所涉及的主要成员和方法。然后,为大家介绍一下Netty所提供了一个专门的单元测试通道——EmbeddedChannel(嵌入式通道)。

6.4.1 Channel通道的主要成员和方法

在Netty中,通道是其中的核心概念之一,代表着网络连接。通道是通信的主题,由它负责同对端进行网络通信,可以写入数据到对端,也可以从对端读取数据。

通道的抽象类AbstractChannel的构造函数如下:

protected AbstractChannel(Channel parent) {
this.parent = parent; //父通道
id = newId();
unsafe = newUnsafe(); //底层的NIO 通道,完成实际的IO操作
pipeline = newChannelPipeline(); //一条通道,拥有一条流水线
}

AbstractChannel内部有一个pipeline属性,表示处理器的流水线。Netty在对通道进行初始化的时候,将pipeline属性初始化为DefaultChannelPipeline的实例。这段代码也表明,每个通道拥有一条ChannelPipeline处理器流水线。

AbstractChannel内部有一个parent属性,表示通道的父通道。对于连接监听通道(如NioServerSocketChannel实例)来说,其父亲通道为null;而对于每一条传输通道(如NioSocketChannel实例),其parent属性的值为接收到该连接的服务器连接监听通道。

几乎所有的通道实现类都继承了AbstractChannel抽象类,都拥有上面的parent和pipeline两个属性成员。

再来看一下,在通道接口中所定义的几个重要方法:

方法1.ChannelFuture connect(SocketAddress address)

此方法的作用为:连接远程服务器。方法的参数为远程服务器的地址,调用后会立即返回,返回值为负责连接操作的异步任务ChannelFuture。此方法在客户端的传输通道使用。

方法2.ChannelFuture bind(SocketAddress address)

此方法的作用为:绑定监听地址,开始监听新的客户端连接。此方法在服务器的新连接监听和接收通道使用。

方法3.ChannelFuture close()

此方法的作用为:关闭通道连接,返回连接关闭的ChannelFuture异步任务。如果需要在连接正式关闭后执行其他操作,则需要为异步任务设置回调方法;或者调用ChannelFuture异步任务的sync( )方法来阻塞当前线程,一直等到通道关闭的异步任务执行完毕。

方法4.Channel read()

此方法的作用为:读取通道数据,并且启动入站处理。具体来说,从内部的Java NIO Channel通道读取数据,然后启动内部的Pipeline流水线,开启数据读取的入站处理。此方法的返回通道自身用于链式调用。

方法5.ChannelFuture write(Object o)

此方法的作用为:启程出站流水处理,把处理后的最终数据写到底层Java NIO通道。此方法的返回值为出站处理的异步处理任务。

方法6.Channel flush()

此方法的作用为:将缓冲区中的数据立即写出到对端。并不是每一次write操作都是将数据直接写出到对端,write操作的作用在大部分情况下仅仅是写入到操作系统的缓冲区,操作系统会将根据缓冲区的情况,决定什么时候把数据写到对端。而执行flush()方法立即将缓冲区的数据写到对端。

上面的6种方法,仅仅是比较常见的方法。在Channel接口中以及各种通道的实现类中,还定义了大量的通道操作方法。在一般的日常的开发中,如果需要用到,请直接查阅Netty API文档或者Netty源代码。

6.4.2 EmbeddedChannel嵌入式通道

在Netty的实际开发中,通信的基础工作,Netty已经替大家完成。实际上,大量的工作是设计和开发ChannelHandler通道业务处理器,而不是开发Outbound出站处理器,换句话说就是开发Inbound入站处理器。开发完成后,需要投入单元测试。单元测试的大致流程是:需要将Handler业务处理器加入到通道的Pipeline流水线中,接下来先后启动Netty服务器、客户端程序,相互发送消息,测试业务处理器的效果。如果每开发一个业务处理器,都进行服务器和客户端的重复启动,这整个的过程是非常的烦琐和浪费时间的。如何解决这种徒劳的、低效的重复工作呢?

Netty提供了一个专用通道——名字叫EmbeddedChannel(嵌入式通道)。

EmbeddedChannel仅仅是模拟入站与出站的操作,底层不进行实际的传输,不需要启动Netty服务器和客户端。除了不进行传输之外,EmbeddedChannel的其他的事件机制和处理流程和真正的传输通道是一模一样的。因此,使用它,开发人员可以在开发的过程中方便、快速地进行ChannelHandler业务处理器的单元测试。

为了模拟数据的发送和接收,EmbeddedChannel提供了一组专门的方法,具体如表6-1所示。

表6-1 EmbeddedChannel单元测试的辅助方法

最为重要的两个方法为:writeInbound和readOutbound方法。

方法1.writeInbound入站数据写到通道

它的使用场景是:测试入站处理器。在测试入站处理器时(例如测试一个解码器),需要读取Inbound(入站)数据。可以调用writeInbound方法,向EmbeddedChannel写入一个入站二进制ByteBuf数据包,模拟底层的入站包。

方法2.readOutbound读取通道的出站数据

它的使用场景是:测试出站处理器。在测试出站处理器时(例如测试一个编码器),需要查看处理过的结果数据。可以调用readOutbound方法,读取通道的最终出站结果,它是经过流水线一系列的出站处理后,最终的出站数据包。比较绕口,重复一遍,通过readOutbound,可以读取完成EmbeddedChannel最后一个出站处理器,处理后的ByteBuf二进制出站包。

总之,这个EmbeddedChannel类,既具备通道的通用接口和方法,又增加了一些单元测试的辅助方法,在开发时是非常有用的。它的具体用法,后面还会结合其他的Netty组件的实例反复提到。

6.5 详解Handler业务处理器

在Reactor反应器经典模型中,反应器查询到IO事件后,分发到Handler业务处理器,由Handler完成IO操作和业务处理。

整个的IO处理操作环节包括:从通道读数据包、数据包解码、业务处理、目标数据编码、把数据包写到通道,然后由通道发送到对端,如图6-8所示。

前后两个环节,从通道读数据包和由通道发送到对端,由Netty的底层负责完成,不需要用户程序负责。

图6-8 整个的IO处理操作环节

用户程序主要在Handler业务处理器中,Handler涉及的环节为:数据包解码、业务处理、目标数据编码、把数据包写到通道中。

前面已经介绍过,从应用程序开发人员的角度来看,有入站和出站两种类型操作。

• 入站处理,触发的方向为:自底向上,Netty的内部(如通道)到ChannelInboundHandler入站处理器。

• 出站处理,触发的方向为:自顶向下,从ChannelOutboundHandler出站处理器到Netty的内部(如通道)。

按照这种方向来分,前面数据包解码、业务处理两个环节——属于入站处理器的工作;后面目标数据编码、把数据包写到通道中两个环节——属于出站处理器的工作。

6.5.1 ChannelInboundHandler通道入站处理器

当数据或者信息入站到Netty通道时,Netty将触发入站处理器ChannelInboundHandler所对应的入站API,进行入站操作处理。

ChannelInboundHandler的主要操作,如图6-9所示,具体的介绍如下:

图6-9 ChannelInboundHandler的主要操作

1. channelRegistered

当通道注册完成后,Netty会调用fireChannelRegistered,触发通道注册事件。通道会启动该入站操作的流水线处理,在通道注册过的入站处理器Handler的channelRegistered方法,会被调用到。

2. channelActive

当通道激活完成后,Netty会调用fireChannelActive,触发通道激活事件。通道会启动该入站操作的流水线处理,在通道注册过的入站处理器Handler的channelActive方法,会被调用到。

3. channelRead

当通道缓冲区可读,Netty会调用fireChannelRead,触发通道可读事件。通道会启动该入站操作的流水线处理,在通道注册过的入站处理器Handler的channelRead方法,会被调用到。

4. channelReadComplete

当通道缓冲区读完,Netty会调用fireChannelReadComplete,触发通道读完事件。通道会启动该入站操作的流水线处理,在通道注册过的入站处理器Handler的channelReadComplete方法,会被调用到。

5. channelInactive

当连接被断开或者不可用,Netty会调用fireChannelInactive,触发连接不可用事件。通道会启动对应的流水线处理,在通道注册过的入站处理器Handler的channelInactive方法,会被调用到。

6. exceptionCaught

当通道处理过程发生异常时,Netty会调用fireExceptionCaught,触发异常捕获事件。通道会启动异常捕获的流水线处理,在通道注册过的处理器Handler的exceptionCaught方法,会被调用到。注意,这个方法是在通道处理器中ChannelHandler定义的方法,入站处理器、出站处理器接口都继承到了该方法。

上面介绍的并不是ChanneInboundHandler的全部方法,仅仅介绍了其中几种比较重要的方法。在Netty中,它的默认实现为ChannelInboundHandlerAdapter,在实际开发中,只需要继承这个ChannelInboundHandlerAdapter默认实现,重写自己需要的方法即可。

6.5.2 ChannelOutboundHandler通道出站处理器

当业务处理完成后,需要操作Java NIO底层通道时,通过一系列的ChannelOutboundHandler通道出站处理器,完成Netty通道到底层通道的操作。比方说建立底层连接、断开底层连接、写入底层Java NIO通道等。ChannelOutboundHandler接口定义了大部分的出站操作,如图6-10所示,具体的介绍如下:

如图6-10 ChannelOutboundHandler的主要操作

再强调一下,出站处理的方向:是通过上层Netty通道,去操作底层Java IO通道。主要出站(Outbound)的操作如下:

1. bind

监听地址(IP+端口)绑定:完成底层Java IO通道的IP地址绑定。如果使用TCP传输协议,这个方法用于服务器端。

2. connect

连接服务端:完成底层Java IO通道的服务器端的连接操作。如果使用TCP传输协议,这个方法用于客户端。

3. write

写数据到底层:完成Netty通道向底层Java IO通道的数据写入操作。此方法仅仅是触发一下操作而已,并不是完成实际的数据写入操作。

4. flush

腾空缓冲区中的数据,把这些数据写到对端:将底层缓存区的数据腾空,立即写出到对端。

5. read

从底层读数据:完成Netty通道从Java IO通道的数据读取。

6. disConnect

断开服务器连接:断开底层Java IO通道的服务器端连接。如果使用TCP传输协议,此方法主要用于客户端。

7. close

主动关闭通道:关闭底层的通道,例如服务器端的新连接监听通道。

上面介绍的并不是ChannelOutboundHandler的全部方法,仅仅介绍了其中几个比较重要的方法。在Netty中,它的默认实现为ChannelOutboundHandlerAdapter,在实际开发中,只需要继承这个ChannelOutboundHandlerAdapter默认实现,重写自己需要的方法即可。

6.5.3 ChannelInitializer通道初始化处理器

在前面已经讲到,通道和Handler业务处理器的关系是:一条Netty的通道拥有一条Handler业务处理器流水线,负责装配自己的Handler业务处理器。装配Handler的工作,发生在通道开始工作之前。现在的问题是:如果向流水线中装配业务处理器呢?这就得借助通道的初始化类——ChannelInitializer。

首先回顾一下NettyDiscardServer丢弃服务端的代码,在给接收到的新连接装配Handler业务处理器时,使用childHandler()方法设置了一个ChannelInitializer实例:

//5 装配子通道流水线
b.childHandler(new ChannelInitializer() {
//有连接到达时会创建一个通道
protected void initChannel(SocketChannelch) throws Exception {
// 流水线管理子通道中的Handler业务处理器
// 向子通道流水线添加一个Handler业务处理器
ch.pipeline().addLast(new NettyDiscardHandler());
}
});

上面的ChannelInitializer也是通道初始化器,属于入站处理器的类型。在示例代码中,使用了ChannelInitializer的initChannel()方法。它是何方神圣呢?

initChannel()方法是ChannelInitializer定义的一个抽象方法,这个抽象方法需要开发人员自己实现。在父通道调用initChannel()方法时,会将新接收的通道作为参数,传递给initChannel()方法。initChannel()方法内部大致的业务代码是:拿到新连接通道作为实际参数,往它的流水线中装配Handler业务处理器。

6.5.4 ChannelInboundHandler的生命周期的实践案例

6.6 详解Pipeline流水线

6.6.1 Pipeline入站处理流程

6.6.2 Pipeline出站处理流程

6.6.3 ChannelHandlerContext上下文

6.6.4 截断流水线的处理

6.6.5 Handler业务处理器的热拔插

6.7 详解ByteBuf缓冲区

6.7.1 ByteBuf的优势

6.7.2 ByteBuf的逻辑部分

6.7.3 ByteBuf的重要属性

6.7.4 ByteBuf的三组方法

6.7.5 ByteBuf基本使用的实践案例

6.7.6 ByteBuf的引用计数

6.7.7 ByteBuf的Allocator分配器

6.7.8 ByteBuf缓冲区的类型

6.7.9 三类ByteBuf使用的实践案例]

6.7.10 ByteBuf的自动释放

6.8 ByteBuf浅层复制的高级使用方式

6.8.1 slice切片浅层复制

6.8.2 duplicate整体浅层复制

6.8.3 浅层复制的问题

6.9 EchoServer回显服务器的实践案例

6.9.1 NettyEchoServer回显服务器的服务器端

6.9.2 共享NettyEchoServerHandler处理器

6.9.3 NettyEchoClient客户端代码

6.9.4 NettyEchoClientHandler处理器

6.10 本章小结

你可能感兴趣的:(6. Netty的原理与基础)