Netty架构原理,不怕你看不懂!
在分布式系统被广泛应用的今天,服务有可能分布在网络中的各个节点中。因此,服务之间的调用对分布式系统来说,就显得尤为重要。
图片来自 Pexels
对于高性能的 RPC 框架,Netty 作为异步通信框架,几乎成为必备品。例如,Dubbo 框架中通信组件,还有 RocketMQ 中生产者和消费者的通信,都使用了 Netty。今天,我们来看看 Netty 的基本架构和原理。
Netty 的特点与 NIO
Netty 是一个异步的、基于事件驱动的网络应用框架,它可以用来开发高性能服务端和客户端。
以前编写网络调用程序的时候,我们都会在客户端创建一个 Socket,通过这个 Socket 连接到服务端。
服务端根据这个 Socket 创建一个 Thread,用来发出请求。客户端在发起调用以后,需要等待服务端处理完成,才能继续后面的操作。这样线程会出现等待的状态。
使用阻赛 I/O 处理多个连接
为了解决上述的问题,推出了 NIO 的概念,也就是(Non-blocking I/O)。其中,Selector 机制就是 NIO 的核心。
当每次客户端请求时,会创建一个 Socket Channel,并将其注册到 Selector 上(多路复用器)。
然后,Selector 关注服务端 IO 读写事件,此时客户端并不用等待 IO 事件完成,可以继续做接下来的工作。
一旦,服务端完成了 IO 读写操作,Selector 会接到通知,同时告诉客户端 IO 操作已经完成。
NIO 机制与 Selector
上面描述的过程有点异步的意思,不过,Selector 实现的并不是真正意义上的异步操作。
因为 Selector 需要通过线程阻塞的方式监听 IO 事件变更,只是这种方式没有让客户端等待,是 Selector 在等待 IO 返回,并且通知客户端去获取数据。真正“异步 IO”(AIO)这里不展开介绍,有兴趣可以自行查找。
说好了 NIO 再来谈谈 Netty,Netty 作为 NIO 的实现,它适用于服务器/客户端通讯的场景,以及针对于 TCP 协议下的高并发应用。
对于开发者来说,它具有以下特点:
对 NIO 进行封装,开发者不需要关注 NIO 的底层原理,只需要调用 Netty 组件就能够完成工作。
对网络调用透明,从 Socket 建立 TCP 连接到网络异常的处理都做了包装。
对数据处理灵活, Netty 支持多种序列化框架,通过“ChannelHandler”机制,可以自定义“编/解码器”。
对性能调优友好,Netty 提供了线程池模式以及 Buffer 的重用机制(对象池化),不需要构建复杂的多线程模型和操作队列。
从一个简单的例子开始
服务端代码
构建服务器端,假设服务器接受客户端传来的信息,然后在控制台打印。首先,生成 EchoServer,在构造函数中传入需要监听的端口号。
接下来就是服务的启动方法:
创建 EventLoopGroup。
创建 ServerBootstrap。
指定所使用的 NIO 传输 Channel。
使用指定的端口设置套接字地址。
添加一个 ServerHandler 到 Channel 的 ChannelPipeline。
异步地绑定服务器;调用 sync() 方法阻塞等待直到绑定完成。
获取 Channel 的 CloseFuture,并且阻塞当前线程直到它完成。
关闭 EventLoopGroup,释放所有的资源。
可以通过 ChannelInboundHandlerAdapter 实现,具体内容如下:
当接收到消息时的操作,channelRead。
消息读取完成时的方法,channelReadComplete。
出现异常时的方法,exceptionCaught。
客户端代码
客户端和服务端的代码基本相似,在初始化时需要输入服务端的 IP 和 Port。
同样在客户端启动函数中包括以下内容:
创建 Bootstrap。
指定 EventLoopGroup 用来监听事件。
定义 Channel 的传输模式为 NIO(Non-BlockingInputOutput)。
设置服务器的 InetSocketAddress。
在创建 Channel 时,向 ChannelPipeline 中添加一个 EchoClientHandler 实例。
连接到远程节点,阻塞等待直到连接完成。
阻塞,直到 Channel 关闭。
关闭线程池并且释放所有的资源。
客户端在完成以上操作以后,会与服务端建立连接从而传输数据。同样在接受到 Channel 中触发的事件时,客户端会触发对应事件的操作。
从代码结构上看还是比较简单的。服务端和客户端分别初始化创建监听和连接。然后分别定义各自的 Handler 处理对方的请求。
Netty 核心组件
①Channel
②EventLoop 和 EventLoopGroup
每个 EventLoop 会占用一个 Thread,同时这个 Thread 会处理 EventLoop 上面发生的所有 IO 操作和事件(Netty 4.0)。
一个 EventLoopGroup 中包含了多个 EventLoop 对象。
EventLoopGroup 要做的就是创建一个新的 Channel,并且给它分配一个 EventLoop。
③ChannelHandler,ChannelPipeline 和 ChannelHandlerContext
ChannelInBoundHandler(入站事件处理器)
ChannelOutBoundHandler(出站事件处理器)
假设每次请求都会触发事件,而由 ChannelHandler 来处理这些事件,这个事件的处理顺序是由 ChannelPipeline 来决定的。
同时,ChannelPipeline 也可以添加或者删除 ChannelHandler,管理整个队列。
不知道大家注意到没有,开始的例子中 ChannelHandler 中处理事件函数,传入的参数就是 ChannelHandlerContext。
ChannelHandlerContext 参数贯穿 ChannelPipeline,将信息传递给每个 ChannelHandler,是个合格的“通讯员”。
把上面提到的几个核心组件归纳一下,用下图表示方便记忆他们之间的关系。
Netty 的数据容器
ByteBuf 工作原理
同样,当写 ByteBuf 时,它的 writerIndex 也会根据写入的字节数进行递增。
ByteBuf 使用模式
堆缓冲区,ByteBuf 将数据存储在 JVM 的堆中,通过数组实现,可以做到快速分配。
由于在堆上被 JVM 管理,在不被使用时可以快速释放。可以通过 ByteBuf.array() 来获取 byte[] 数据。
直接缓冲区,在 JVM 的堆之外直接分配内存,用来存储数据。其不占用堆空间,使用时需要考虑内存容量。
它在使用 Socket 传递时性能较好,因为间接从缓冲区发送数据,在发送之前 JVM 会先将数据复制到直接缓冲区再进行发送。
由于,直接缓冲区的数据分配在堆之外,通过 JVM 进行垃圾回收,并且分配时也需要做复制的操作,因此使用成本较高。
复合缓冲区,顾名思义就是将上述两类缓冲区聚合在一起。Netty 提供了一个 CompsiteByteBuf,可以将堆缓冲区和直接缓冲区的数据放在一起,让使用更加方便。
ByteBuf 的分配
PooledByteBufAllocator,实现了 ByteBuf 的对象的池化,提高性能减少内存碎片。
Unpooled-ByteBufAllocator,没有实现对象的池化,每次会生成新的对象实例。
TinySubPage
SmallSubPage
ChunkList
Netty 的 Bootstrap
说完了 Netty 的核心组件以及数据存储。再回到最开始的例子程序,在程序最开始的时候会 new 一个 Bootstrap 对象,后面所有的配置都是基于这个对象展开的。
从 Bootstrap 的继承结构来看,分为两类分别是 Bootstrap 和 ServerBootstrap,一个对应客户端的引导,另一个对应服务端的引导。
在 bind() 之后,通过调用 connect() 方法来创建 Channel 连接。
服务端引导 ServerBootstrap,与客户端不同的是在 Bind() 方法之后会创建一个 ServerChannel,它不仅会创建新的 Channel 还会管理已经存在的 Channel。
ServerBootstrap(服务端引导)绑定一个端口,用来监听客户端的连接请求。而 Bootstrap(客户端引导)只要知道服务端 IP 和 Port 建立连接就可以了。
Bootstrap(客户端引导)需要一个 EventLoopGroup,但是 ServerBootstrap(服务端引导)则需要两个 EventLoopGroup。
因为服务器需要两组不同的 Channel。第一组 ServerChannel 自身监听本地端口的套接字。第二组用来监听客户端请求的套接字。
总结
简介:十六年开发和架构经验,曾担任过惠普武汉交付中心技术专家,需求分析师,项目经理,后在创业公司担任技术/产品经理。善于学习,乐于分享。目前专注于技术架构与研发管理。
编辑:陶家龙、孙淑娟
征稿:有投稿、寻求报道意向技术人请联络 editor@51cto.com
精彩文章推荐: