凌月风的个人博客

记录精彩人生

Open Source, Open Mind,
Open Sight, Open Future!
  menu

Java笔记系列——06-网络通信(2)

0 浏览

Netty简介

  • Netty其实就是一个高性能NIO框架,所以它是基于NIO基础上的封装,本质上是提供高性能网络IO通信的功能。Netty提供了三种Reactor模型的支持,我们可以通过Netty封装好的API来快速完成不同Reactor模型的开发
  • Netty特点
    • 提供了高效的I/O模型、线程模型和时间处理机制
    • 提供了非常简单易用的API,相比NIO来说,针对基础的Channel、Selector、Sockets、Buffers等API提供了更高层次的封装,屏蔽了NIO的复杂性
    • 对数据协议和序列化提供了很好的支持
    • 稳定性,Netty修复了 Java原生NIO较多的问题,比如select空转导致的CPU消耗100%、TCP断线重连、keep-alive检测等问题。
    • 可扩展性在同类型的框架中都是做的非常好的,比如一个是可定制化的线程模型,用户可以在启动参数中选择Reactor模型、 可扩展的事件驱动模型,将业务和框架的关注点分离。
    • 性能层面的优化,作为网络通信框架,需要处理大量的网络请求,必然就面临网络对象需要创建和销毁的问题,这种对JVM的垃圾回收来说不是很友好,为了降低JVM垃圾回收的压力,引入了两种优化机制
      • 对象池复用
      • 零拷贝技术(相当于浅克隆)
  • Netty提供的功能image-20220621222222861

工作机制

  • Netty的整体设计就是多线程Reactor模型,分离请求监听和请求处理,通过多线程分别执行具体的handler。 image-20220621222352507

  • 网络通信层

    • 网络通信层的职责是执行网络的IO操作,它支持多种网络通信协议和I/O模型的链接操作。当网络数据读取到内核缓冲区后,会触发读写事件,这些事件在分发给时间调度器来进行处理。在Netty中,网络通信的核心组件如下

      • Bootstrap:客户端启动api。用来链接远程netty server,只绑定一个EventLoopGroup
      • ServerBootStrap:服务端监听api。用来监听指定端口,会绑定两个EventLoopGroupbootstrap组件可以非常方便快捷的启动Netty应用程序
      • ChannelChannel是网络通信的载体。Netty自己实现的Channel是以Java NIO channel为基础,提供了更高层次的抽象,同时也屏蔽了底层Socket的复杂性,为Channel提供了更加强大的功能
    • 随着连接和数据的变化,Channel也会存在多种状态,比如连接建立、连接注册、连接读写、连接销毁。随着状态的变化,Channel也会处于不同的生命周期,每种状态会绑定一个相应的事件回调。以下是常见的时间回调方法。

      • channelRegistered,Channel创建后被注册到EventLoop
        channelUnregistered,Channel创建后未注册或者从EventLoop取消注册
        channelActive,Channel处于就绪状态,可以被读写
        channelInactive,Channel处于非就绪状态
        channelRead,Channel可以从源端读取数据
        channelReadComplete,Channel读取数据完成
    • BootstrapServerBootStrap分别负责客户端和服务端的启动,Channel是网络通信的载体,它提供了与底层Socket交互的能力。而当Channel生命周期中的事件变化,就需要触发进一步处理,这个处理是由Netty的事件调度器来完成。

  • 事件调度器

    • 事件调度器是通过Reactor线程模型对各类事件进行聚合处理,通过Selector主循环线程集成多种事件(I/O时间、信号时间),当这些事件被触发后,具体针对该事件的处理需要给到服务编排层中相关的Handler来处理。
    • 事件调度器核心组件
      • EventLoopGroup:本质上是一个线程池,主要负责接收I/O请求,并分配线程执行处理请求。
      • EventLoop:相当于线程池中的线程,EventLoop处理Channel生命周期内所有的I/O事件,比如accept、connect、read、write等。一个EventLoopGroup可以包含多个EventLoop
    • 每新建一个ChannelEventLoopGroup会选择一个EventLoop进行绑定,该Channel在生命周期内可以对EventLoop进行多次绑定和解绑。 image-20220621223159059
    • EventLoopGroup是Netty的核心处理引擎,可以简单的把EventLoopGroup当成是Netty中Reactor多线程模型的具体实现,我们可以通过配置不同的EventLoopGroup使得Netty支持多种不同的Reactor模型。
      • 单线程模型:EventLoopGroup只包含一个EventLoopBossWorker使用同一个EventLoopGroup
      • 多线程模型:EventLoopGroup包含多个EventLoopBossWorker使用同一个EventLoopGroup
      • 主从多线程模型:EventLoopGroup包含多个EventLoopBossMain ReactorWorkerSub Reactor模型。他们分别使用不同的EventLoopGroupMain Reactor负责新的网络连接Channel的创建(也就是连接的事件),Main Reactor收到客户端的连接后,交给Sub Reactor来处理。
  • 服务编排

    • 服务编排层的职责是负责组装各类的服务,简单来说,就是I/O事件触发后,需要有一个Handler来处理,所以服务编排层可以通过一个Handler处理链来实现网络事件的动态编排和有序的传播
    • 它包含三个组件
      • ChannelPipeline:它采用了双向链表将多个Channelhandler链接在一起,当I/O事件触发时,ChannelPipeline会依次调用组装好的多个ChannelHandler,实现对Channel的数据处理。ChannelPipeline是线程安全的,因为每个新的Channel都会绑定一个新的ChannelPipeline。一个ChannelPipeline关联一个EventLoop,而一个EventLoop只会绑定一个线程 。ChannelPipeline中包含入站ChannelInBoundHandler和出站ChannelOutboundHandler,前者是接收数据,后者是写出数据,相当于InputStreamOutputStream
      • ChannelHandler:针对IO数据的处理器,数据接收后,通过指定的Handler进行处理
      • ChannelHandlerContextChannelHandlerContext用来保存ChannelHandler的上下文信息,也就是说,当事件被触发后,多个handler之间的数据,是通过ChannelHandlerContext来进行传递的。
    • 每个ChannelHandler都对应一个自己的ChannelHandlerContext,它保留了ChannelHandler所需要的上下文信息,多个ChannelHandler之间的数据传递,是通过ChannelHandlerContext来实现的 image-20220621224651670
  • 工作机制总结

    • 服务单启动初始化Boss和Worker线程组,Boss线程组负责监听网络连接事件,当有新的连接建立时,Boss线程会把该连接Channel注册绑定到Worker线程
    • Worker线程组会分配一个EventLoop负责处理该Channel的读写事件,每个EventLoop相当于一个线程。通过Selector进行事件循环监听。
    • 当客户端发起I/O事件时,服务端的EventLoop将就绪的Channel分发给Pipeline,进行数据的处理数据传输到ChannelPipeline后,从第一个ChannelInBoundHandler进行处理,按照pipeline链逐个进行传递
    • 服务端处理完成后要把数据写回到客户端,这个写回的数据会在ChannelOutboundHandler组成的链中传播,最后到达客户端 image-20220621225026097

数据容器

  • ByteBuf是Netty的Server与Client之间通信的数据传输载体 ,等同于Java NIO中的ByteBuffer,但是ByteBufNIO中的ByteBuffer的功能做了增强

  • ByteBuf其实是一个字节容器,该容器中包含如下部分

    • 已经丢弃的字节,这部分数据是无效的
    • 可读字节,这部分数据是ByteBuf的主体数据,从ByteBuf里面读取的数据都来自这部分;
    • 可写字节,所有写到ByteBuf的数据都会存储到这一段
    • 可扩容字节,表示ByteBuf最多还能扩容多少容量 image-20220621225639377
  • ByteBuf中,有两个指针

    • readerIndex: 读指针,每读取一个字节,readerIndex自增加1。ByteBuf里面总共有witeIndexreaderIndex个字节可读,
    • writeIndex: 写指针,每写入一个字节,writeIndex自增加1,直到增加到capacity后,可以触发扩容后继续写入。当readerIndexwriteIndex相等的时候,ByteBuf不可读
  • ByteBuf中有一个maxCapacity最大容量,默认的值是 Integer.MAX_VALUE ,当ByteBuf写入数据时,如果容量不足时,会触发扩容,直到capacity扩容到maxCapacity

    • ByteBuf可以自动扩容,默认长度是256,如果内容长度超过阈值时,会自动触发扩容

    • 假设ByteBuf初始容量是10

      • 如果写入后数据大小未超过512个字节,则选择下一个16的整数倍进行库容。 比如写入数据后大小为12,则扩容后的capacity是16。
      • 如果写入后数据大小超过512个字节,则选择下一个2^n。 比如写入后大小是512字节,则扩容后的capacity是2^10=1024 。(因为2^9=512,长度已经不够了)
    • 扩容不能超过max capacity,否则会报错。

  • ByteBuf的创建可以基于堆内存,也可以基于直接内存。直接内存的好处是读写性能会高一些,如果数据存放在堆中,此时需要把Java堆空间的数据发送到远程服务器,首先需要把堆内部的数据拷贝到直接内存(堆外内存),然后再发送。如果是把数据直接存储到堆外内存中,发送的时候就少了一个复制步骤

    • 基于堆内存创建:ByteBuf buffer=ByteBufAllocator.DEFAULT.heapBuffer(10);
    • 基于直接内存创建(堆外内存):ByteBufAllocator.DEFAULT.directBuffer(10);

粘包与拆包

  • TCP传输协议是基于数据流传输的,而基于流化的数据是没有界限的,当客户端向服务端发送数据时,可能会把一个完整的数据报文拆分成多个小报文进行发送,也可能将多个报文合并成一个大报文进行发送。 因此出现粘包与拆包问题
    • 服务端恰巧读到了两个完整的数据包 A 和 B,没有出现拆包/粘包问题;
    • 服务端接收到 A 和 B 粘在一起的数据包,服务端需要解析出 A 和 B;
    • 服务端收到完整的 A 和 B 的一部分数据包 B-1,服务端需要解析出完整的 A,并等待读取完整的 B数据包;
    • 服务端接收到 A 的一部分数据包 A-1,此时需要等待接收到完整的 A 数据包;
    • 数据包 A 较大,服务端需要多次才可以接收完数据包 A。 image-20220621231141625
  • 由于存在拆包/粘包问题,接收方很难界定数据包的边界在哪里,所以可能会读取到不完整的数据导致数据解析出现问题,为了解决该问题,一般我们会在应用层定义通信协议。通信双方约定一个通信报文协议,发送报文时根据协议进行编码,收到报文之后,按照约定的协议进行解码,从而避免出现粘包和拆包问题。

编解码器

  • 要解决粘包与拆包问题,需要制定一个通信报文协议,通常有几类方案

    • 消息长度固定

      • 每个数据报文都需要一个固定的长度,当接收方累计读取到固定长度的报文后,就认为已经获得了一个完整的消息,当发送方的数据小于固定长度时,则需要空位补齐
      • 这种方式很简单,但是缺点也很明显,对于没有固定长度的消息,不清楚如何设置长度,而且如果长度设置过大会造成字节浪费,长度太小又会影响消息传输,所以一般情况下不会采用这种方式。
    • 特定分隔符

      • 既然没办法通过固定长度来分割消息,可以在消息报文中增加一个分割符,然后接收方根据特定的分隔符来进行消息拆分。
      • 对于特定分隔符的使用场景中,需要注意分隔符和消息体中的字符不要存在冲突,否则会出现消息拆分错误的问题
    • 消息长度加消息内容加分隔符

      • 这种方式在项目中是非常常见的协议,比如Redis的协议:首先通过消息头中的总长度来判断当前一个完整消息所携带的参数个数。然后在消息体中,再通过消息内容长度以及消息体作为一个组合,最后通过\r\n进行分割。服务端收到这个消息后,就可以按照该规则进行解析得到一个完整的命令进行执行
      • Zookeeper中使用了Jute协议,这是Zookeeper自定义的消息协议
  • 在Netty中,默认提供了一些常用的编解码器用来解决拆包粘包的问题。

    • FixedLengthFrameDecoder:固定长度解码器

      • 原理很简单,就是通过构造方法设置一个固定消息大小frameLength,无论接收方一次收到多大的数据,都会严格按照frameLength进行解码。
      • 如果读取到长度为frameLength的消息,那么解码器会认为已经获取到了一个完整的消息,如果消息长度小于frameLength,那么该解码器会一直等待后续数据包的达到,直到获得指定长度后返回。
    • DelimiterBasedFrameDecoder:特殊分隔符解码器 。核心参数

      • delimiters:特殊分隔符,参数类型是ByteBufByteBuf可以传递一个数组,意味着我们可以同时指定多个分隔符,但最终会选择长度最短的分隔符进行拆分。

        比如接收方收到的消息体为hello\nworld\r\n,此时指定多个分隔符 \n 和 \r\n ,那么最终会选择最短的分隔符解码,得到如下数据hello | world |

      • maxLength:报文的最大长度限制,如果超过maxLength还没检测到指定分隔符,将会抛出异常TooLongFrameException

      • failFast:表示容错机制,布尔类型,它与maxLength配合使用。如果failFast=true,当超过maxLength后会立刻抛出TooLongFrameException,不再进行解码;如果failFast=false,那么会等到解码出一个完整的消息后才会抛出TooLongFrameException

      • stripDelimiter:判断解码后的消息是否去除分隔符,布尔类型。

        如果stripDelimiter=false,而制定的特定分隔符是 \n ,那么数据解码的方式如下。
        hello\nworld\r\n
        stripDelimiter=false时,解码后得到hello\n | world\r\n

    • LengthFieldBasedFrameDecoder:长度域解码器 。它是解决拆包粘包最常用的解码器,基本上能覆盖大部分基于长度拆包的场景。核心参数

      • engthFieldOffset:长度字段的偏移量,也就是存放长度数据的起始位置
      • lengthFieldLength:长度字段锁占用的字节数
      • lengthAdjustment:在一些较为复杂的协议设计中,长度域不仅仅包含消息的长度,还包含其他数据比如版本号、数据类型、数据状态等,这个时候我们可以使用lengthAdjustment进行修正,它的值=包体的长度值-长度域的值
      • initialBytesToStrip:解码后需要跳过的初始字节数,也就是消息内容字段的起始位置
      • lengthFieldEndOffset:长度字段结束的偏移量
image/svg+xml