Netty 之旅二:口口相传的高性能 Netty 到底是什么?
高清思维导图原件(xmind/pdf/jpg
)可以关注公众号:一枝花算不算浪漫
回复netty01
即可。
前言
上一篇文章讲了NIO
相关的知识点,相比于传统IO
,NIO
已经做得很优雅了,为什么我们还要使用Netty
?
上篇文章最后留了很多坑,讲了NIO
使用的弊端,也是为了引出Netty
而设立的,这篇文章我们就来好好揭开Netty
的神秘面纱。
本篇文章的目的很简单,希望看过后你能看懂Netty
的示例代码,针对于简单的网络通信,自己也能用Netty
手写一个开发应用出来!
一个简单的Netty示例
以下是一个简单聊天室Server端的程序,代码参考自:http://www.imooc.com/read/82/article/2166
代码有点长,主要核心代码是在main()
方法中,这里代码也希望大家看懂,后面也会一步步剖析。
PS:我是用mac
系统,直接在终端输入telnet 127.0.0.1 8007
即可启动一个聊天框,如果提示找不到telnet
命令,可以通过brew
进行安装,具体步骤请自行百度。
代码有点长,执行完的效果如上图所示,下面所有内容都是围绕着如何看懂
以及如何写出
这样的代码来展开的,希望你看完 也能轻松手写Netty
服务端代码~。通过简单demo开发让大家体验了Netty
实现相比NIO
确实要简单的多,但优点不限于此,只需要知道选择Netty就对了。
Netty核心组件
对应着文章开头的思维导图,我们知道Netty
的核心组件主要有:
Bootstrap && ServerBootstrap
EventLoopGroup
EventLoop
ByteBuf
Channel
ChannelHandler
ChannelFuture
ChannelPipeline
ChannelHandlerContext
类图如下:
Bootstrap & ServerBootstrap
一看到BootStrap
大家就应该想到启动类、引导类这样的词汇,之前分析过[EurekaServer项目启动类时][1]介绍过EurekaBootstrap
, 他的作用就是上下文初始化、配置初始化。
在Netty
中我们也有类似的类,Bootstrap
和ServerBootstrap
它们都是Netty
程序的引导类,主要用于配置各种参数,并启动整个Netty
服务,我们看下文章开头的示例代码:
Bootstrap
和ServerBootstrap
是针对于Client
和Server
端定义的两套启动类,区别如下:
Bootstrap
是客户端引导类,而ServerBootstrap
是服务端引导类。Bootstrap
通常使用connect()
方法连接到远程的主机和端口,作为一个TCP客户端
。ServerBootstrap
通常使用bind()
方法绑定本地的端口,等待客户端来连接。ServerBootstrap
可以处理Accept
事件,这里面childHandler
是用来处理Channel
请求的,我们可以查看chaildHandler()
方法的注解:
Bootstrap
客户端引导只需要一个EventLoopGroup
,但是一个ServerBootstrap
通常需要两个(上面的boosGroup
和workerGroup
)。
EventLoopGroup && EventLoop
EventLoopGroup
及EventLoop
这两个类名称定义的很奇怪,对于初学者来说往往无法通过名称来了解其中的含义,包括我也是这样。
EventLoopGroup
可以理解为一个线程池,对于服务端程序,我们一般会绑定两个线程池,一个用于处理 Accept
事件,一个用于处理读写事件,看下EventLoop
系列的类目录:
通过上面的类图,我们才恍然大悟,我的亲娘咧,这不就是一个线程池嘛?(名字气的犄角拐弯的真是难认)
EventLoopGroup
是EventLoop
的集合,一个EventLoopGroup
包含一个或者多个EventLoop
。我们可以将EventLoop
看做EventLoopGroup
线程池中的一个个工作线程。
至于这里为什么要用到两个线程池,具体的其实可以参考Reactor
设计模式,这里暂时不做过多的讲解。
一个 EventLoopGroup 包含一个或多个 EventLoop ,即 EventLoopGroup : EventLoop = 1 : n
一个 EventLoop 在它的生命周期内,只能与一个 Thread 绑定,即 EventLoop : Thread = 1 : 1
所有有 EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,从而保证线程安全,即 Thread : EventLoop = 1 : 1
一个 Channel 在它的生命周期内只能注册到一个 EventLoop 上,即 Channel : EventLoop = n : 1
一个 EventLoop 可被分配至一个或多个 Channel ,即 EventLoop : Channel = 1 : n
当一个连接到达时,Netty
就会创建一个 Channel
,然后从 EventLoopGroup
中分配一个 EventLoop
来给这个 Channel
绑定上,在该 Channel
的整个生命周期中都是有这个绑定的 EventLoop
来服务的。
ByteBuf
在Java NIO
中我们有 ByteBuffer
缓冲池,对于它的操作我们应该印象深刻,往Buffer
中写数据时我们需要关注写入的位置,切换成读模式时我们还要切换读写状态,不然将会出现大问题。
针对于NIO
中超级难用的Buffer
类, Netty
提供了ByteBuf
来替代。ByteBuf
声明了两个指针:一个读指针,一个写指针,使得读写操作进行分离,简化buffer
的操作流程。
另外Netty
提供了发几种ByteBuf
的实现以供我们选择,ByteBuf
可以分为:
Pooled
和Unpooled
池化和非池化Heap 和 Direct,堆内存和堆外内存,NIO中创建Buffer也可以指定
Safe 和 Unsafe,安全和非安全
对于这么多种创建Buffer
的方式该怎么选择呢?Netty
也为我们处理好了,我们可以直接使用(真是暖男Ntetty
):
使用这种方式,Netty将最大努力的使用池化、Unsafe、对外内存的方式为我们创建buffer。
Channel
提起Channel
并不陌生,上一篇讲NIO
的三大组件提到过,最常见的就是java.nio.SocketChannel
和java.nio.ServerSocketChannel
,他们用于非阻塞的I/0操作。类似于NIO
的Channel
,Netty提供了自己的Channel
和其子类实现,用于异步I/0操作和其他相关的操作。
在 Netty
中, Channel
是一个 Socket
连接的抽象, 它为用户提供了关于底层 Socket
状态(是否是连接还是断开) 以及对 Socket
的读写等操作。每当 Netty
建立了一个连接后, 都会有一个对应的 Channel
实例。并且,有父子channel
的概念。 服务器连接监听的channel
,也叫 parent channel
。 对应于每一个 Socket
连接的channel
,也叫 child channel
。
既然channel
是 Netty 抽象出来的网络 I/O 读写相关的接口,为什么不使用 JDK NIO
原生的 Channel
而要另起炉灶呢,主要原因如下:
JDK
的SocketChannel
和ServersocketChannel
没有统一的Channel
接口供业务开发者使用,对一于用户而言,没有统一的操作视图,使用起来并不方便。JDK
的SocketChannel
和ScrversockctChannel
的主要职责就是网络 I/O 操作,由于他们是SPI
类接口,由具体的虚拟机厂家来提供,所以通过继承 SPI 功能直接实现ServersocketChannel
和SocketChannel
来扩展其工作量和重新Channel
功类是差不多的。Netty 的
ChannelPipeline Channel
需要够跟 Netty 的整体架构融合在一起,例如 I/O 模型、基的定制模型,以及基于元数据描述配置化的 TCP 参数等,这些JDK SocketChannel
和ServersocketChannel
都没有提供,需要重新封装。自定义的
Channel
,功实现更加灵活。
基于上述 4 原因,它的设计原理比较简单, Netty 重新设计了 Channel
接口,并且给予了很多不同的实现。但是功能却比较繁杂,主要的设计理念如下:
在
Channel
接口层,相关联的其他操作封装起来,采用Facade
模式进行统一封装,将网络 I/O 操作、网络 I/O 统一对外提供。Channel
接口的定义尽量大而全,统一的视图,由不同子类实现不同的功能,公共功能在抽象父类中实现,最大程度上实现接口的重用。具体实现采用聚合而非包含的方式,将相关的功类聚合在
Channel
中,由Channel
统一负责分配和调度,功能实现更加灵活。
Channel
的实现类非常多,继承关系复杂,从学习的角度我们抽取最重要的两个 NioServerSocketChannel
和 NioSocketChannel
。
服务端 NioServerSocketChannel
的继承关系类图如下:
客户端 NioSocketChannel
的继承关系类图如下:
后面文章源码系列会具体分析,这里就不进一步阐述分析了。
ChannelHandler
ChannelHandler
是Netty
中最常用的组件。ChannelHandler
主要用来处理各种事件,这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。
ChannelHandler
有两个核心子类 ChannelInboundHandler
和 ChannelOutboundHandler
,其中 ChannelInboundHandler
用于接收、处理入站( Inbound
)的数据和事件,而 ChannelOutboundHandler
则相反,用于接收、处理出站( Outbound
)的数据和事件。
ChannelInboundHandler
ChannelInboundHandler
处理入站数据以及各种状态变化,当Channel
状态发生改变会调用ChannelInboundHandler
中的一些生命周期方法.这些方法与Channel
的生命密切相关。
入站数据,就是进入socket
的数据。下面展示一些该接口的生命周期API
:
当某个 ChannelInboundHandler
的实现重写 channelRead()
方法时,它将负责显式地释放与池化的 ByteBuf
实例相关的内存。 Netty 为此提供了一个实用方法ReferenceCountUtil.release()
。
这种方式还挺繁琐的,Netty提供了一个SimpleChannelInboundHandler
,重写channelRead0()
方法,就可以在调用过程中会自动释放资源.
ChannelOutboundHandler
出站操作和数据将由 ChannelOutboundHandler
处理。它的方法将被 Channel
、 ChannelPipeline
以及 ChannelHandlerContext
调用。
ChannelOutboundHandler
的一个强大的功能是可以按需推迟操作或者事件,这使得可以通过一些复杂的方法来处理请求。例如, 如果到远程节点的写入被暂停了, 那么你可以推迟冲刷操作并在稍后继续。
ChannelPromise
与ChannelFuture
: ChannelOutboundHandler
中的大部分方法都需要一个ChannelPromise
参数, 以便在操作完成时得到通知。 ChannelPromise
是ChannelFuture
的一个子类,其定义了一些可写的方法,如setSuccess()
和setFailure()
,从而使ChannelFuture
不可变。
ChannelHandlerAdapter
ChannelHandlerAdapter
顾名思义,就是handler
的适配器。你需要知道什么是适配器模式,假设有一个A接口,我们需要A的subclass
实现功能,但是B类中正好有我们需要的功能,不想复制粘贴B中的方法和属性了,那么可以写一个适配器类Adpter
继承B实现A,这样一来Adapter
是A的子类并且能直接使用B中的方法,这种模式就是适配器模式。
就比如Netty中的SslHandler
类,想使用ByteToMessageDecoder
中的方法进行解码,但是必须是ChannelHandler
子类对象才能加入到ChannelPipeline
中,通过如下签名和其实现细节(SslHandler
实现细节就不贴了)就能够作为一个handler
去处理消息了。
ChannelHandlerAdapter
提供了一些实用方法isSharable()
如果其对应的实现被标注为 Sharable
, 那么这个方法将返回 true
, 表示它可以被添加到多个 ChannelPipeline
中 。如果想在自己的ChannelHandler
中使用这些适配器类,只需要扩展他们,重写那些想要自定义的方法即可。
ChannelPipeline
每一个新创建的 Channel
都将会被分配一个新的 ChannelPipeline
。这项关联是永久性的; Channel
既不能附加另外一个 ChannelPipeline
,也不能分离其当前的。在 Netty 组件的生命周期中,这是一项固定的操作,不需要开发人员的任何干预。
Netty 的 ChannelHandler
为处理器提供了基本的抽象, 目前你可以认为每个 ChannelHandler
的实例都类似于一种为了响应特定事件而被执行的回调。从应用程序开发人员的角度来看, 它充当了所有处理入站和出站数据的应用程序逻辑的拦截载体。ChannelPipeline
提供了 ChannelHandler
链的容器,并定义了用于在该链上传播入站和出站事件流的 API
。当 Channel
被创建时,它会被自动地分配到它专属的 ChannelPipeline
。
ChannelHandler
安装到 ChannelPipeline
中的过程如下所示:
一个
ChannelInitializer
的实现被注册到了ServerBootstrap
中当
ChannelInitializer.initChannel()
方法被调用时,ChannelInitializer
将在ChannelPipeline
中安装一组自定义的ChannelHandler
ChannelInitializer
将它自己从ChannelPipeline
中移除
如上图所示:这是一个同时具有入站和出站 ChannelHandler
的 ChannelPipeline
的布局,并且印证了我们之前的关于 ChannelPipeline
主要由一系列的 ChannelHandler
所组成的说法。 ChannelPipeline
还提供了通过 ChannelPipeline
本身传播事件的方法。如果一个入站事件被触发,它将被从 ChannelPipeline
的头部开始一直被传播到 Channel Pipeline 的尾端。
你可能会说, 从事件途经 ChannelPipeline
的角度来看, ChannelPipeline
的头部和尾端取决于该事件是入站的还是出站的。然而 Netty 总是将 ChannelPipeline
的入站口(图 的左侧)作为头部,而将出站口(该图的右侧)作为尾端。
当你完成了通过调用 ChannelPipeline.add*()
方法将入站处理器( ChannelInboundHandler
)和 出 站 处 理 器 ( ChannelOutboundHandler
) 混 合 添 加 到 ChannelPipeline
之 后 , 每 一 个ChannelHandler
从头部到尾端的顺序位置正如同我们方才所定义它们的一样。因此,如果你将图 6-3 中的处理器( ChannelHandler
)从左到右进行编号,那么第一个被入站事件看到的 ChannelHandler
将是1,而第一个被出站事件看到的 ChannelHandler
将是 5。
在 ChannelPipeline
传播事件时,它会测试 ChannelPipeline
中的下一个 ChannelHandler 的类型是否和事件的运动方向相匹配。如果不匹配, ChannelPipeline
将跳过该ChannelHandler
并前进到下一个,直到它找到和该事件所期望的方向相匹配的为止。 (当然, ChannelHandler
也可以同时实现ChannelInboundHandler
接口和 ChannelOutboundHandler
接口。)
修改`ChannelPipeline`
修改指的是添加或删除ChannelHandler
,见代码示例:
`ChannelPipeline`的出入站`API`
入站API
所示:
[图片上传失败...(image-6037f5-1598167949595)]
出站API
所示:
ChannelPipeline
这个组件上面所讲的大致只需要记住这三点即可:
ChannelPipeline
保存了与Channel
相关联的ChannelHandler
ChannelPipeline
可以根据需要,通过添加或者删除ChannelHandler
来动态地修改ChannelPipeline
有着丰富的API
用以被调用,以响应入站和出站事件
ChannelHandlerContext
当 ChannelHandler
被添加到 ChannelPipeline
时,它将会被分配一个 ChannelHandlerContext
,它代表了 ChannelHandler
和 ChannelPipeline
之间的绑定。ChannelHandlerContext
的主要功能是管理它所关联的 ChannelHandler
和在同一个 ChannelPipeline
中的其他 ChannelHandler
之间的交互。
如果调用Channel
或ChannelPipeline
上的方法,会沿着整个ChannelPipeline
传播,如果调用ChannelHandlerContext
上的相同方法,则会从对应的当前ChannelHandler
进行传播。
ChannelHandlerContext API
如下表所示:
ChannelHandlerContext
和ChannelHandler
之间的关联(绑定)是永远不会改变的,所以缓存对它的引用是安全的;如同在本节开头所解释的一样,相对于其他类的同名方法,
ChannelHandlerContext
的方法将产生更短的事件流, 应该尽可能地利用这个特性来获得最大的性能。
与`ChannelHandler`、`ChannelPipeline`的关联使用
从ChannelHandlerContext
访问channel
从ChannelHandlerContext
访问ChannelPipeline
有时候我们不想从头传递数据,想跳过几个handler
,从某个handler
开始传递数据.我们必须获取目标handler
之前的handler
关联的ChannelHandlerContext
。
好了,ChannelHandlerContext
的基本使用应该掌握了,但是你真的理解ChannelHandlerContext
,ChannelPipeline
和Channelhandler
之间的关系了吗?不理解也没关系,因为源码以后会帮你理解的更为深刻。
核心组件之间的关系
一个
Channel
对应一个ChannelPipeline
一个
ChannelPipeline
包含一条双向的ChannelHandlerContext
链一个
ChannelHandlerContext
中包含一个ChannelHandler
一个
Channel
会绑定到一个EventLoop
上一个
NioEventLoop
维护了一个Selector(
使用的是 Java 原生的 Selector)一个
NioEventLoop
相当于一个线程
粘包拆包问题
粘包拆包问题是处于网络比较底层的问题,在数据链路层、网络层以及传输层都有可能发生。我们日常的网络应用开发大都在传输层进行,由于UDP
有消息保护边界,不会发生粘包拆包问题,而因此粘包拆包问题只发生在TCP
协议中。具体讲TCP
是个”流"协议,只有流的概念,没有包的概念,对于业务上层数据的具体含义和边界并不了解,它只会根据TCP
缓冲区的实际情况进行包的划分。所以在业务上认为,一个完整的包可能会被TCP
拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP
粘包和拆包问题。
问题举例说明
下面针对客户端分别发送了两个数据表Packet1
和Packet2
给服务端的时候,TCP
粘包和拆包会出现的情况进行列举说明:
(1)第一种情况,服务端分两次正常收到两个独立数据包,即没有发生拆包和粘包的现象;
(2)第二种情况,接收端只收到一个数据包,由于TCP
是不会出现丢包的,所以这一个数据包中包含了客户端发送的两个数据包的信息,这种现象即为粘包。这种情况由于接收端不知道这两个数据包的界限,所以对于服务接收端来说很难处理。
(3)第三种情况,服务端分两次读取到了两个数据包,第一次读取到了完整的Packet1
和Packet2
包的部分内容,第二次读取到了Packet2
的剩余内容,这被称为TCP拆包;
(4)第四种情况,服务端分两次读取到了两个数据包,第一次读取到了部分的Packet1
内容,第二次读取到了Packet1
剩余内容和Packet2
的整包。
如果此时服务端TCP接收滑窗非常小,而数据包Packet1
和Packet2
比较大,很有可能服务端需要分多次才能将两个包接收完全,期间发生多次拆包。以上列举情况的背后原因分别如下:
应用程序写入的数据大于套接字缓冲区大小,这将会发生拆包。
应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘包。
进行
MSS
(最大报文长度)大小的TCP
分段,当TCP
报文长度-TCP
头部长度>MSS
的时候将发生拆包。接收方法不及时读取套接字缓冲区数据,这将发生粘包。
*如何基于Netty处理粘包、拆包问题*
由于底层的TCP
无法理解上层的业务数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈设计来解决,根据业界的主流协议的解决方案,可以归纳如下:
消息定长,例如每个报文的大小为固定长度200字节,如果不够,空位补空格;
在包尾增加回车换行符进行分割,例如
FTP
协议;将消息分为消息头和消息体,消息头中包含表示消息总长度的字段,通常设计思路为消息头的第一个字段使用
int32
来表示消息的总长度;更复杂的应用层协议。
之前Netty示例中其实并没有考虑读半包问题,这在功能测试往往没有问题,但是一旦请求数过多或者发送大报文之后,就会存在该问题。如果代码没有考虑,往往就会出现解码错位或者错误,导致程序不能正常工作,下面看看Netty是如何根据主流的解决方案进行抽象实现来帮忙解决这一问题的。
如下表所示,Netty为了找出消息的边界,采用封帧方式:
| 方式 | 解码 | 编码 |
| ------------------ | ------------------------------ | ---------------------- |
| 固定长度 | FixedLengthFrameDecoder
| 简单 |
| 分隔符 | DelimiterBasedFrameDecoder
| 简单 |
| 专门的 length 字段 | LengthFieldBasedFrameDecoder
| LengthFieldPrepender
|
注意到,Netty提供了对应的解码器来解决对应的问题,有了这些解码器,用户不需要自己对读取的报文进行人工解码,也不需要考虑TCP的粘包和半包问题。为什么这么说呢?下面列举一个包尾增加分隔符的例子:
添加到ChannelPipeline
的DelimiterBasedFrameDecoder
用于对使用分隔符结尾的消息进行自动解码,当然还有没有用到的FixedLengthFrameDecoder
用于对固定长度的消息进行自动解码等解码器。正如上门的代码使用案例,有了Netty提供的几码器可以轻松地完成对很多消息的自动解码,而且不需要考虑TCP粘包/拆包导致的读半包问题,极大地提升了开发效率。
Netty示例代码详解
相信看完上面的铺垫,你对Netty编码有了一定的了解了,下面再来整体梳理一遍吧。
1、设置EventLoopGroup
线程组(Reactor
线程组)
上面我们说过Netty
中使用Reactor
模式,bossGroup
表示服务器连接监听线程组,专门接受 Accept
新的客户端client
连接。另一个workerGroup
表示处理每一连接的数据收发的线程组,来处理消息的读写事件。
2、服务端引导器
集成所有配置,用来启动Netty
服务端。
3、设置ServerBootstrap
信息
将两个线程组设置到ServerBootstrap
中。
4、设置ServerSocketChannel
类型
设置通道的IO
类型,Netty
不止支持Java NIO
,也支持阻塞式IO
,例如OIO
OioServerSocketChannel.class)
5、设置参数
通过option()
方法可以设置很多参数,这里SO_BACKLOG
标识服务端接受连接的队列长度,如果队列已满,客户端连接将被拒绝。默认值,Windows
为200,其他为128,这里设置的是100。
6、设置Handler
设置 ServerSocketChannel
对应的Handler
,这里只能设置一个,它会在SocketChannel
建立起来之前执行。
7、设置子Handler
Netty
中提供了一种可以设置多个Handler
的途径,即使用ChannelInitializer
方式。ChannelPipeline
是Netty
处理请求的责任链,这是一个ChannelHandler
的链表,而ChannelHandler
就是用来处理网络请求的内容的。
每一个channel
,都有一个处理器流水线。装配child channel
流水线,调用childHandler()
方法,传递一个ChannelInitializer
的实例。
在 child channel
创建成功,开始通道初始化的时候,在bootstrap启动器中配置的ChannelInitializer
实例就会被调用。
这个时候,才真正的执行去执行 initChannel
初始化方法,开始通道流水线装配。
流水线装配,主要是在流水线pipeline
的后面,增加负责数据读写、处理业务逻辑的handler
。
处理器 ChannelHandler
用来处理网络请求内容,有ChannelInboundHandler
和ChannelOutboundHandler
两种,ChannlPipeline
会从头到尾顺序调用ChannelInboundHandler
处理网络请求内容,从尾到头调用ChannelOutboundHandler
处理网络请求内容
8、绑定端口号
绑定端口号
9、等待服务端端口号关闭
等待服务端监听端口关闭,sync()
会阻塞主线程,内部调用的是 Object
的 wait()
方法
10、关闭EventLoopGroup线程组
总结
这篇文章主要是从一个demo
作为引子,然后介绍了Netty
的包结构、Reactor
模型、编程规范等等,目的很简单,希望你能够读懂这段demo
并写出来。
后面开始继续Netty
源码解析部分,敬请期待。
参考资料
《Netty in Action》书籍
慕课Netty专栏
掘金闪电侠Netty小册
芋道源码Netty专栏
Github[fork from krcys]
感谢Netty专栏作者们优秀的文章内容~
[1]:https://juejin.im/post/6844903972843552776
版权声明: 本文为 InfoQ 作者【一枝花算不算浪漫】的原创文章。
原文链接:【http://xie.infoq.cn/article/2a04929d17cd1ca585c348206】。文章转载请联系作者。
评论