Netty网络编程第八卷
- 整体架构
-
- ByteBuf
- Channel
- EventLoop和EventLoopGroup
- ChannelFuture
- ChannelHandler和ChannelPipeline
- SimpleChannelInboundHandler
- 关于入站和出站处理器是如何识别的问题
- BootStrap
本卷在于对ChannelHandler,ChannelHandlerContext,ChannelPipeline三大组件的详细讲解,力争做到从架构到源码的深入剖析
整体架构
Netty整体由以下几大组件组成:
- ByteBuf
- Channel
- EventLoop和EventLoopGroup
- ChannelFuture
- ChannelHandler和ChannelPipeline
- Bootstrap
ByteBuf
ByteBuf之前几卷中都进行过详细分析,这里只做简单的回顾
从开始学习Java网络编程开始,不知道大家有没有发现API所规定的的数据传输最小单元就是字节,比如NIO中的IntBuffer,LongBuffer等等都是基于ByteBuffer而来的,因此Netty中对NIO中的ByteBuffer类进行了进一步的封装和优化。
ByteBuf的构造如下图所示,它维护了两个指针,一个是读指针,一个是写指针,如果是数据读取操作读指针会自动后移,如果是写操作,写指针会自动后移。因此,这样就不用手动的进行flip()操作了,减少了操作Buffer的复杂性。
它的一些操作方法一般都ByteBuf接口中有所规定的。
ByteBuf的几种模式:
- 堆缓冲区: 将数据存在JVM堆里的一个字节数组,在没有使用内存池的情况下可以提供快速的分配和释放,也被成为支撑数组(backing array)
- 直接缓冲区:直接缓冲区模式可以避免不必要的中间内存拷贝,它的内存分配并不在JVM堆里,相对于堆来说它的分配和释放代价比较大,而且如果要对它里面的数据进行操作的话还需要将里面的内容拷贝到一个数组里操作。它比较适合大数据量的传输且不带有数据处理的一些操作。
- 复合缓冲区:它可以看做是多个ByteBuf聚合后的视图,可以根据需要进行ByteBuf实例的添加和删除(这个在JDK中的复合缓冲区是没有这个特性的),Netty中通过CompositeByteBuf(ByteBuf的子类)实现了这个模式,里面可以同时包含有直接缓冲区和非直接缓冲区。它的结构如下图所示,相当于是一个链表结构,它的内部也带有迭代器。但是它也有缺点,也就是它不支持直接访问数组,和直接缓冲区一样,需要先转化为数组,才能进行操作。
Channel
在传统的BIO编程中,我们都会使用Socket进行端口绑定,连接等操作,但是在NIO中我们使用的是SocketChannel(可以简单的理解为Socket+Channel),它也可以进行绑定,连接,读写等操作,也可以完成Channel的关闭操作,因此不难发现Channel的一些增强类提供了一些API让我们不需要直接去使用Socket,减小了开发的复杂性。
下图为netty下的Channel接口里的方法,可以看到它直接调用Unsafe方法来完成Socket的功能
如果对jdk源码有所了解的小伙伴,应该知道jdk底层也有一个Unsafe对象,用来直接和底层操作系统打交道,分配内存的,但是jdk底层的Unsafe对象和这里不是一个对象,不要混淆
进入io.netty.channel包下可以看到它有明显的包结构划分,这也就是常说的Netty给提供的一些数据传输方式
- Embedded:不需要一个真正的基于网络的传输,但是可以使用ChannelHandler,一般用来测试ChannelHandler。
- Epoll:它是完全非阻塞IO,比普通NIO传输要快,只能在支持Linux环境下应用
- ChannelGroup:是一个线程安全的集合,里面包含一些开放的Channel并可以对这些Channel实施批量操作,可以将一些满足某些条件的Channel放到一个Group,关闭Channel的时候自动会从set里移除,因此它可以用来进行群发(广播)
- Local:可以在虚拟机内部通过管道进行本地传输通信
- NIO:在java.nio.channels包基础下,使用选择器方式(Selector)
- OIO:在java.net包的基础下,使用阻塞IO流的方式
- ChannelPool:就是Channel池(通道连接池),实现连接复用,此实现对通道池中的通道使用后进先出顺序。
- RXTX:可以实现Java与串口应用的通信
- SCTP:流控制传输协议(SCTP,Stream Control Transmission Protocol)是一种在网络连接两端之间同时传输多个数据流的协议,所提供的服务类似于TCP和UDP
- Socket:里面包含nio的一些Socket还有oio的一些Socket,比如NioServerSocketChannel,OioServerSocketChannel等等
- UDT:UDP-based Data Transfer Protocol,简称UDT,UDT建于UDP之上,并引入新的拥塞控制和数据可靠性控制机制,因此是一个面向连接的。它同时支持可靠的数据流传输和部分可靠的数据报传输。
- Unix:下面放的是一些Unix系统下支持传输方式
EventLoop和EventLoopGroup
用于处理连接生命周期内所发生的的事件,就不用手动注册和消除事件监听和处理逻辑的调用了。
下图为EventLoop的UML图(后面进行解释)
先说说Channel,EventLoop,EventLoopGroup之间的关系,如下图所示:
可以对上图之间的包含关系做一个整理:
- 一个EventLoopGroup包含一个或多个EventLoop
- 一个EventLoop在它生命周期中只和一个Thread绑定
- 所有有EventLoop处理的IO事件都将在它专有线程中被处理(因此就不会有线程同步问题)
- 可以被分配给一个或多个Channel(regist方法),但一个Channel在它的生命周期中只能注册一个到EventLoop
因此,在前文说到EventLoop很像是一个Selector。 - 接下来就来看看EventLoop的类图,我借助书上的一张图(和上面的图是一样的,只不过书上的图将不同的类别进行了分块,易于阅读)
可以从上图中很明显的看到,它的实现是借助了java.util.concurrent包下的线程池来完成的,线程池主要就是用来提供任务执行器,因此netty的EventLoopGroup是netty与jdk的协同设计。
下面这句话是摘抄于书上(将的很透彻):
在这个模型中,一个EventLoop 将由一个永远都不会改变的 Thread 驱动,可以将任务(Runnable 或者Callable)直接提交给EventLoop,以及执行或者调度执行。根据配置和可用核心的不 同,可能会创建多个EventLoop 实例用以优化资源的使用,并且单 个EventLoop 可能会被指派用于服务多个Channel 。
ChannelFuture
ChannelFuture可以看做是一个线程执行结果的占位符,因为它的执行不确定性因素十分大,谁也不能确定异步信息什么时候会得到结果。
在前文也提到过,在ChannelFuture中扩展了J.U.C的Future,它可以使用addListener()注册一个ChannelFutureListener的一个监听器,以便于可以在某个操作完成之后得到结果(无论是成功还是失败)。
在同一个Channel的异步任务是可以保证它们的顺序调用执行
ChannelHandler和ChannelPipeline
ChannelHandler,它可以对出站入站的数据进行处理,相应的网络事件的出发也就伴随着相应的ChannelHandler的执行。
- ChannelInboundHandler:对入站的数据处理
- ChannelOutboundHandler:对出站的数据处理
ChannelPipeline则是它的容器,在ChannelPipeline中包含有处理对应Channel的所有ChannelHandler,在这个ChannelPipeline中规定有数据的进站和出站处理的一些列ChannelHandler,如下图所示
采用的是责任链模式,这种模式简化了手动的逻辑判断,比如在Tomcat中也是使用到了责任链模式
下面详细剖析一下这些组件:
首先先分析一下ChannelHandler,ChannelHandler是我们日常开发中使用最多的组件了,大概我们平时写的最多的组件就是Handler了,继承图如下
我们平时继承的最多的就是ChannelInboundHandlerAdapter和ChannelOutboundHandlerAdapter,这两个不是接口也不是抽象类,所以我们可以仅仅重写我们需要的方法,没有必须要实现的方法,当然我们也会使用SimpleChannelInboundHandler,这个类我们上个小节也稍微讲了它的优缺点,这里不赘述
ChannelHandler,ChannelHandlerContext,ChannelPipeline
这三者的关系很特别,相辅相成,
一个ChannelPipeline中可以有多个ChannelHandler实例,而每一个ChannelHandler实例与ChannelPipeline之间的桥梁就是ChannelHandlerContext实例
,如图所示:
看图就知道,ChannelHandlerContext的重要性了,如果你获取到了ChannelHandlerContext的实例的话,你可以获取到你想要的一切,你可以根据ChannelHandlerContext执行ChannelHandler中的方法,我们举个例子来说,我们可以看下ChannelHandlerContext部分API:
这几个API都是使用比较频繁的,都是调用当前handler之后同一类型的channel中的某个方法,这里的同一类型指的是同一个方向,比如inbound调用inbound,outbound调用outbound类型的channel,一般来说,都是一个channel的ChannnelActive方法中调用fireChannelActive来触发调用下一个handler中的ChannelActive方法
ChannelHandlerContext负责包装一个ChannelHandler对象,然后pipeline使用双向链表的形式将这一个一个ChannelHandlerContext串联在一起,每当有一个客户端连接传入时,所有工作线程共享这一套pipeline工作流体系
这里我们追踪一下源码:
可以知道findContextOutbound返回的是拥有下一个channelHanlder的channelHanlderContext对象
next.invokeChannelActive()这里当前的this对象已经发生了改变,这在下面条件不满足时,继续寻找链表上下一个ChannelHandlerContext的findContextInBound()方法中起到了作用
不满足时会继续找下一个ContextHandlerContext.,然后执行其内部维护的ChannelHandler的channelActive方法
上面以active事件为切入点进行了调用链分析,其他事件类似,大家可以参考
分析了那么多,下面讲讲pipeline的作用体现
我们下面看看是怎么放进去的
剩余代码不做分析,大家可以自行去看源码
目前来说这样做的好处:
1)每一个handler只需要关注自己要处理的方法,如果你不关注channelActive方法时,你自定义的channelhandler就不需要重写channelActive方法
2)异常处理,如果 exceptionCaught方法每个handler都重写了,只需有一个类捕捉到然后做处理就可以了,不需要每个handler都处理一遍
3)灵活性。例如如下图所示:
如图所示在业务逻辑处理中,也许左侧第一个ChannelHandler根本不需要管理某个业务逻辑,但是从第二个ChannelHandler就需要关注处理某个业务需求了,那么就可以很灵活地从第二个ChannelHandler开始处理业务,不需要从channel中的第一个ChannelHandler开始处理,这样会使代码显得让人看不懂~
初步看懂的ChannelHandler,ChannelHandlerContext,ChannelPipeline之间的关系就是如上总结的
这里纠正一下上面源码剖析的一个错误结论:每个客户端的socketChannel对象都会创建一个自己的piepline,并且互相拥有对方的引用,下面源码证明:
每个客户端连接上来后,都会用NioSocktChannel包装原生的SocketChannel引用,下面源码论证:
详细的accept事件源码流程,可以参考第五卷
SimpleChannelInboundHandler
为什么要单独把这个拿出来说一下,是因为这里有坑,很多人会踩到
坑: 使用的channelRead0这个方法,结果服务器端就是不打印,服务器返回的结果,当时客户端是这样写的
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
public class BaseClientHandler extends SimpleChannelInboundHandler<ByteBuf>{
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
System.out.println("Client channelRead0 received:" + msg);
}
// @Override
// public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// System.out.println("Client channelRead received:" + msg);
//
// }
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
原因:SimpleChannelInboundHandler是继承于ChannelInboundHandlerAdapter,重写了channelRead方法
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
boolean release = true;
try {
if (acceptInboundMessage(msg)) {
@SuppressWarnings("unchecked")
I imsg = (I) msg;
channelRead0(ctx, imsg);
} else {
release = false;
ctx.fireChannelRead(msg);
}
} finally {
if (autoRelease && release) {
ReferenceCountUtil.release(msg);
}
}
}
SimpleChannelInboundHandler后面指定了处理类型,也就是源码中的"I",acceptInboundMessage方法判断msg是不是SimpleChannelInboundHandler中指定的类型,我们这边指定的是ByteBuf,感觉没啥问题啊,但是我们忽略了一个问题,我们客户端中有3个处理器,两个inbound类型的处理器,其中一个就是HelloWorldClientHandler,还有一个就是StringDecoder,上一个处理器已经把服务器端的信息转化成String,还用ByteBuf来接收,显然不能处理
这里如果想处理,把类型换成String即可,下面我们来看看书上是怎么说的
SimpleChannelInboundHandler的channelRead0还有一个好处就是你不用关心释放资源,因为源码中已经帮你释放了,所以如果你保存获取的信息的引用,是无效的~
关于入站和出站处理器是如何识别的问题
首先pipeline将所有入站和出站处理器串联在一起,并没有搞出两条链表进行区分,那么pipeline是如何识别入站和出站处理器的呢?
下面分析:
上面分析过active事件,这里是触发read事件的时候,会挨个调用每个handler的channelRead方法,但是别忘了这里有我们之前没讲的一个方法:
分析完毕
BootStrap
BootStrap就是常常听到的引导,比如SpringBoot中的启动类上的注解带有BootStrap,因此可以知道BootStrap是一个程序的启动入口,也就是引导的意思。在Netty中引导可以分为两种:服务端引导和客户端引导
- ServerBootStrap:作用于服务端,可以绑定到一个本地端口
- BootStrap:作用于客户端,可以连接一个远程主机和端口
也可以从对照下图
BootStrap里只有一个EventLoopGroup,而ServerBootStrap中有两个EventLoopGroup,这是为什么呢?
- 因为服务器需要对本地端口进行绑定,因此它会需要一个专用的ServerChannel来连接正在开放监听的端口(这是一个EventLoopGroup,里面只包含一个ServerChannel,因此相应的ServerChannel也就只使用到一个EventLoop)。
- 它还要进行客户端的连接处理,因此第二组包含的是已创建的用来处理客户端连接的Channel。
而客户端不需要本地端口绑定,因此只有连接的Channel,这样更进一步的应征了上一篇文章说得到的ChannelGroup,它可以将不同类型的Channel分为一组。
服务端的两个EventLoopGroup工作流程如下
总结: 在编写服务端的时候需要使用ServerBootStrap来绑定端口和配置一些其他信息,编写客户端是需要使用BootStrap指明发送链接的目的地和其他配置,一个Channel只能绑定一个EventLoop,一个EventLoop可以给多个Channel绑定,通过ChannelFuture来进行异步事件的通知,当事件被触发后,在ChannelPipeline中会有相应的ChannelHandler可以对当前数据进行处理。