ByteBuf
ByteBuf是一个节点容器,里面数据包括三部分:
1、已经丢弃的数据,这部分数据是无效的
2、可读字节,这部分数据是ByteBuf的主体
3、可写字节
这三段数据被两个指针给划分出来,读指针、写指针。
总结
1、 ByteBuf 本质上就是,它引用了一段内存,这段内存可以是堆内也可以是堆外的,然后用引用计数来控制这段内存是否需要被释放,使用读写指针来控制对 ByteBuf 的读写,可以理解为是外观模式的一种使用
2、基于读写指针和容量、最大可扩容容量,衍生出一系列的读写方法,要注意 read/write 与 get/set 的区别
3、多个 ByteBuf 可以引用同一段内存,通过引用计数来控制内存的释放,遵循谁 retain() 谁 release() 的原则
通信协议编解码
1、客户端把一个Java对象按照通信协议转换成二进制数据包
2、把二进制数据包发送到服务端,数据的传输油TCP/IP协议负责
3、服务端收到二进制数据包后,按照通信协议,包装成Java对象。
通信协议的设计
1、魔数,作用:能够在第一时间识别出这个数据包是不是遵循自定义协议的,也就是无效数据包,为了安全考虑可以直接关闭连接以节省资源。
2、版本号,
3、序列化算法
4、指令
5、数据长度
6、数据
总结
1、通信协议是为了服务端与客户端交互,双方协商出来的满足一定规则的二进制格式
2、介绍了一种通用的通信协议的设计,包括魔数、版本号、序列化算法标识、指令、数据长度、数据几个字段,该协议能够满足绝大多数的通信场景。
客户端登陆
1、客户端会构建一个登录请求对象,然后通过编码把请求对象编码为 ByteBuf,写到服务端。
2、服务端接受到 ByteBuf 之后,首先通过解码把 ByteBuf 解码为登录请求响应,然后进行校验。
3、服务端校验通过之后,构造一个登录响应对象,依然经过编码,然后再写回到客户端。
4、客户端接收到服务端的之后,解码 ByteBuf,拿到登录响应响应,判断是否登陆成功。
客户端和服务端收发信息
判断客户端是否登陆成功
1、通过 channel.attr(xxx).set(xx) 的方式,我们可以在登陆成功后,给Channel绑定一个登陆成功的标志位,判断登陆的时候取出这个标志位就可以了
pipeline与channelHandler
它们通过责任链设计模式来组织代码逻辑,并且支持逻辑的动态添加和删除。
ChannelHandler 有两大子接口:
第一个子接口是 ChannelInboundHandler,从字面意思也可以猜到,他是处理读数据的逻辑
第二个子接口 ChannelOutBoundHandler 是处理写数据的逻辑
这两个子接口分别有对应的默认实现,ChannelInboundHandlerAdapter,和 ChanneloutBoundHandlerAdapter,它们分别实现了两大接口的所有功能,默认情况下会把读写事件传播到下一个 handler。
总结
1、通过我们前面编写客户端服务端处理逻辑,引出了 pipeline 和 channelHandler 的概念。
2、channelHandler 分为 inBound 和 outBound 两种类型的接口,分别是处理数据读与数据写的逻辑,可与 tcp 协议栈联系起来。
3、两种类型的 handler 均有相应的默认实现,默认会把事件传递到下一个,这里的传递事件其实说白了就是把本 handler 的处理结果传递到下一个 handler 继续处理。
4、inBoundHandler 的执行顺序与我们实际的添加顺序相同,而 outBoundHandler 则相反。
拆包粘包理论与解决方案
TCP是个“流”协议,所谓流,就是没有界限的一串数据。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包的问题。
解决方法
1、解决思路是在封装自己的包协议:包=包内容长度(4byte)+包内容
2、对于粘包问题先读出包头即包体长度n,然后再读取长度为n的包内容,这样数据包之间的边界就清楚了。
3、对于断包问题先读出包头即包体长度n,由于此次读取的缓存区长度小于n,这时候就需要先缓存这部分的内容,等待下次read事件来时拼接起来形成完整的数据包。
总结
1、拆包器的作用就是根据我们的自定义协议,把数据拼装成一个个符合我们自定义数据包大小的 ByteBuf,然后送到我们的自定义协议解码器去解码。
2、Netty 自带的拆包器包括基于固定长度的拆包器,基于换行符和自定义分隔符的拆包器,还有另外一种最重要的基于长度域的拆包器。通常 Netty 自带的拆包器已完全满足我们的需求,无需重复造轮子。
3、基于 Netty 自带的拆包器,我们可以在拆包之前判断当前连上来的客户端是否是支持自定义协议的客户端,如果不支持,可尽早关闭,节省资源。
Netty 自带的拆包器
固定长度的拆包器 FixedLengthFrameDecoder
如果你的应用层协议非常简单,每个数据包的长度都是固定的,比如 100,那么只需要把这个拆包器加到 pipeline 中,Netty 会把一个个长度为 100 的数据包 (ByteBuf) 传递到下一个 channelHandler。行拆包器 LineBasedFrameDecoder
从字面意思来看,发送端发送数据包的时候,每个数据包之间以换行符作为分隔,接收端通过 LineBasedFrameDecoder 将粘过的 ByteBuf 拆分成一个个完整的应用层数据包。分隔符拆包器 DelimiterBasedFrameDecoder
DelimiterBasedFrameDecoder 是行拆包器的通用版本,只不过我们可以自定义分隔符。基于长度域拆包器 LengthFieldBasedFrameDecoder
最后一种拆包器是最通用的一种拆包器,只要你的自定义协议中包含长度域字段,均可以使用这个拆包器来实现应用层拆包。由于上面三种拆包器比较简单,读者可以自行写出 demo,接下来,我们就结合我们小册的自定义协议,来学习一下如何使用基于长度域的拆包器来拆解我们的数据包。
channelHandler 生命周期
ChannelHandler 回调方法的执行顺序为
handlerAdded() -> channelRegistered() -> channelActive() -> channelRead() -> channelReadComplete()
我们来逐个解释一下每个回调方法的含义
1、handlerAdded() :指的是当检测到新连接之后,调用 ch.pipeline().addLast(new LifeCyCleTestHandler()); 之后的回调,表示在当前的 channel 中,已经成功添加了一个 handler 处理器。
2、channelRegistered():这个回调方法,表示当前的 channel 的所有的逻辑处理已经和某个 NIO 线程建立了绑定关系,类似我们在Netty 是什么?这小节中 BIO 编程中,accept 到新的连接,然后创建一个线程来处理这条连接的读写,只不过 Netty 里面是使用了线程池的方式,只需要从线程池里面去抓一个线程绑定在这个 channel 上即可,这里的 NIO 线程通常指的是 NioEventLoop,不理解没关系,后面我们还会讲到。
3、channelActive():当 channel 的所有的业务逻辑链准备完毕(也就是说 channel 的 pipeline 中已经添加完所有的 handler)以及绑定好一个 NIO 线程之后,这条连接算是真正激活了,接下来就会回调到此方法。
4、channelRead():客户端向服务端发来数据,每次都会回调此方法,表示有数据可读。
5、channelReadComplete():服务端每次读完一次完整的数据之后,回调该方法,表示数据读取完毕。
单聊
1、A 要和 B 聊天,首先 A 和 B 需要与服务器建立连接,然后进行一次登录流程,服务端保存用户标识和 TCP 连接的映射关系。
2、A 发消息给 B,首先需要将带有 B 标识的消息数据包发送到服务器,然后服务器从消息数据包中拿到 B 的标识,找到对应的 B 的连接,将消息发送给 B。
总结:
1、我们定义一个会话类 Session 用户维持用户的登录信息,用户登录的时候绑定 Session 与 channel,用户登出或者断线的时候解绑 Session 与 channel。
2、服务端处理消息的时候,通过消息接收方的标识,拿到消息接收方的 channel,调用 writeAndFlush() 将消息发送给消息接收方。
群聊
1、A,B,C 依然会经历登录流程,服务端保存用户标识对应的 TCP 连接
2、A 发起群聊的时候,将 A,B,C 的标识发送至服务端,服务端拿到之后建立一个群聊 ID,然后把这个 ID 与 A,B,C 的标识绑定
3、群聊里面任意一方在群里聊天的时候,将群聊 ID 发送至服务端,服务端拿到群聊 ID 之后,取出对应的用户标识,遍历用户标识对应的 TCP 连接,就可以将消息发送至每一个群聊成员
总结
1、通过标识拿到channel
2、通过 ChannelGroup,我们可以很方便地对一组 channel 进行批量操作
群聊的成员管理
性能优化
心跳与空闲检测
连接假死的现象是:在某一端(服务端或者客户端)看来,底层的 TCP 连接已经断开了,但是应用程序并没有捕获到,因此会认为这条连接仍然是存在的,从 TCP 层面来说,只有收到四次握手数据包或者一个 RST 数据包,连接的状态才表示已断开。
假死导致两个问题
1、对于服务端,每条连接都会耗费cpu和内存资源,大量假死的连接会耗光服务器的资源
2、对于客户端,假死会造成发送数据超时,影响用户体验
通常,连接假死由以下几个原因造成的
1、应用程序出现线程堵塞,无法进行数据的读写。
2、客户端或者服务端网络相关的设备出现故障,比如网卡,机房故障。
3、公网丢包。公网环境相对内网而言,非常容易出现丢包,网络抖动等现象,如果在一段时间内用户接入的网络连续出现丢包现象,那么对客户端来说数据一直发送不出去,而服务端也是一直收不到客户端来的数据,连接就一直耗着
服务端空闲检测
1、如果能一直收到客户端发来的数据,那么可以说明这条连接还是活的,因此,服务端对于连接假死的应对策略就是空闲检测。
2、简化一下,我们的服务端只需要检测一段时间内,是否收到过客户端发来的数据即可,Netty 自带的 IdleStateHandler 就可以实现这个功能。
IdleStateHandler 的构造函数有四个参数,其中第一个表示读空闲时间,指的是在这段时间内如果没有数据读到,就表示连接假死;第二个是写空闲时间,指的是 在这段时间如果没有写数据,就表示连接假死;第三个参数是读写空闲时间,表示在这段时间内如果没有产生数据读或者写,就表示连接假死。写空闲和读写空闲为0,表示我们不关心者两类条件;最后一个参数表示时间单位。在我们的例子中,表示的是:如果 15 秒内没有读到数据,就表示连接假死。
在一段时间之内没有读到客户端的数据,是否一定能判断连接假死呢?并不能为了防止服务端误判,我们还需要在客户端做点什么。
客户端定时心跳
服务端在一段时间内没有收到客户端的数据有两种情况
1、连接假死
2、非假死确实没数据发
所以我们要排除第二种情况就能保证连接自然就是假死的,定期发送心跳到服务端
实现了每隔 5 秒,向服务端发送一个心跳数据包,这个时间段通常要比服务端的空闲检测时间的一半要短一些,我们这里直接定义为空闲检测时间的三分之一,主要是为了排除公网偶发的秒级抖动。
为了排除是否是因为服务端在非假死状态下确实没有发送数据,服务端也要定期发送心跳给客户端。
总结
1、要处理假死问题首先我们要实现客户端与服务端定期发送心跳,在这里,其实服务端只需要对客户端的定时心跳包进行回复。
2、客户端与服务端如果都需要检测假死,那么直接在 pipeline 的最前方插入一个自定义 IdleStateHandler,在 channelIdle() 方法里面自定义连接假死之后的逻辑。
3、通常空闲检测时间要比发送心跳的时间的两倍要长一些,这也是为了排除偶发的公网抖动,防止误判。
Netty 的零拷贝实现
Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。堆内存多了一次内存拷贝,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。ByteBuffer 由 ChannelConfig 分配,而 ChannelConfig 创建 ByteBufAllocator180默认使用 Direct BufferCompositeByteBuf 类可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。addComponents 方法将 header 与 body 合并为一个逻辑上的 ByteBuf,这 两 个 ByteBuf 在 CompositeByteBuf 内 部 都 是 单 独 存 在 的 ,
CompositeByteBuf 只是逻辑上是一个整体
通过 FileRegion 包装的FileChannel.tranferTo方法 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
通过 wrap 方法, 我们可以将 byte[] 数组、ByteBuf、ByteBuffer 等包装成一个 Netty ByteBuf 对象, 进而避免了拷贝操作。
Selector BUG:若 Selector 的轮询结果为空,也没有 wakeup 或新消息处理,则发生空轮询,CPU 使用率 100%,
Netty 的解决办法:对 Selector 的 select 操作周期进行统计,每完成一次空的select 操作进行一次计数,若在某个周期内连续发生 N 次空轮询,则触发了 epoll死循环 bug。重建 Selector,判断是否是其他线程发起的重建请求,若不是则将原 SocketChannel 从旧的 Selector 上去除注册,重新注册到新的 Selector181上,并将原来的 Selector 关闭。
客户端(handler 响应处理器)
- 创建群
- 群消息
- 心跳定时器(ChannelHandlerContext -> EventExecutor -> scheduleAtFixedRate )
- 加入群
- 群成员列表
- 登录
- 退出登录
- 发消息
- 退出群
服务端(handler Request)
- 权限认证
- 创建群
- 群发消息
- 心跳(ChannelHandlerContext -> EventExecutor -> scheduleAtFixedRate )
- IMHandler(压缩 handler - 合并平行 handler)
- 加入群
- 群成员列表
- 登录
- 退出登录
- 发消息
- 退出群
公共
- 空闲检测(IMIdleStateHandler 如果 15 秒内没有读到数据,就表示连接假死,回调 channelIdle,关闭channel, ctx.channel().close())
- 编解码
Bootstrap
Bootstrap的使用很像Builder模式,Bootstrap就是Builder,EventLoopGroup、Channel和Handler等是各种Part。稍有不同的是,准备好各种Part后,并不是直接build出一个Product来,而是直接通过connect()方法使用这个Product
ChannelPipeline
ChannelPipeline实际上应该叫做ChannelHandlerPipeline,可以把ChannelPipeline看成是一个ChandlerHandler的链表,当需要对Channel进行某种处理的时候,Pipeline负责依次调用每一个Handler进行处理。每个Channel都有一个属于自己的Pipeline,调用Channel#pipeline()方法可以获得Channel的Pipeline,调用Pipeline#channel()方法可以获得Pipeline的Channel。
事件的传播
AbstractChannel直接调用了Pipeline的write()方法,因为write是个outbound事件,所以DefaultChannelPipeline直接找到tail部分的context,调用其write()方法:
context的write()方法沿着context链往前找,直至找到一个outbound类型的context为止,然后调用其invokeWrite()方法:
ByteBuf和设计模式
ByteBufAllocator - 抽象工厂模式
在Netty的世界里,ByteBuf实例通常应该由ByteBufAllocator来创建。CompositeByteBuf - 组合模式
CompositeByteBuf可以让我们把多个ByteBuf当成一个大Buf来处理,ByteBufAllocator提供了compositeBuffer()工厂方法来创建CompositeByteBuf。CompositeByteBuf的实现使用了组合模式
- ByteBufInputStream - 适配器模式
ByteBufInputStream使用适配器模式,使我们可以把ByteBuf当做Java的InputStream来使用。同理,ByteBufOutputStream允许我们把ByteBuf当做OutputStream来使用。
- ReadOnlyByteBuf - 装饰器模式
ReadOnlyByteBuf用适配器模式把一个ByteBuf变为只读,ReadOnlyByteBuf通过调用Unpooled.unmodifiableBuffer(ByteBuf)方法获得:
- ByteBuf - 工厂方法模式
前面也提到过了,我们很少需要直接通过构造函数来创建ByteBuf实例,而是通过Allocator来创建。从装饰器模式可以看出另外一种获得ByteBuf的方式是调用ByteBuf的工厂方法,比如:
ByteBuf#duplicate()
ByteBuf#slice()
PooledByteBuf内存池原理分析
Arena -》 Chunk -》 Page
Netty的PooledArena是由多个Chunk组成的大块内存区域,而每个chunk则由一个或多个page组成,对内存的组织和管理也就集中在如何管理和组织Chunk和Page了
PoolChunk
chunk主要用来组织和管理多个page的内存分配和释放,Chunk的Page被构成一颗二叉树,假设一个Chunk由16个page组成。
PoolSubpage
对于小于一个Page的内存,Netty在page中完成分配,每个Page会被切分成大小相等的多个储存块,储存块的大小由第一次申请的内存块大小决定。
内存回收策略
无论chunk还是page,都是通过状态位来标识内存是否可用,不同之处chunk通过在二叉树对节点进行标识实现,Page是通过维护块的使用状态来实现
Reactor模型
1、Reactor模型是什么?
2、多线程IO的痛点
服务器用一个while循环,不断监听端口是否有新的套接字连接,如果有,那么就调用一个处理函数处理
无法并发,效率太低,如果当前的请求没有处理完,那么后面的请求只能被阻塞,服务器的吞吐量太低
缺点在于资源要求太高,系统中创建线程是需要比较高的系统资源的,如果连接数太高,系统无法承受,而且,线程的反复创建-销毁也需要代价。
改进方法是:
采用基于事件驱动的设计,当有事件触发时,才会调用处理器进行数据处理。使用Reactor模式,对线程的数量进行控制,一个线程处理大量的事件。
3、单线程Reactor是什么?
4、单线程模式的缺点
5、多线程的Reactor是什么?
6、基于线程池的改进
7、Reactor的优点和缺点
优点
1)响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
2)编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
3)可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;
4)可复用性,reactor框架本身与具体事件处理逻辑无关,具有很高的复用性;
缺点
1)相比传统的简单模型,Reactor增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
2)Reactor模式需要底层的Synchronous Event Demultiplexer支持,比如Java中的Selector支持,操作系统的select系统调用支持,如果要自己实现Synchronous Event Demultiplexer可能不会有那么高效。
3) Reactor模式在IO读写数据时还是在同一个线程中实现的,即使使用多个Reactor机制的情况下,那些共享一个Reactor的Channel如果出现一个长时间的数据读写,会影响这个Reactor中其他Channel的相应时间,比如在大文件传输时,IO操作就会影响其他Client的相应时间,因而对这种操作,使用传统的Thread-Per-Connection或许是一个更好的选择,或则此时使用改进版的Reactor模式如Proactor模式。