Approaching science《the secret of netty》
IO 相关概念、五种 IO 模型、BIO NIO AIO 特点及区别、NIO 设计原理及核心组件、netty 简介及应用场景、netty 线程模型(Reactor 线程模型)、netty 设计原理及核心组件、netty 常用技巧实现(心跳机制、长连接、tcp 粘包/拆包、大文件传输、SSL/TLS 加密传输等)、相关代码示例。
版本
- jdk:17
- spring boot:3.2.2
- netty:4.1.108.Final
1 IO 四个概念
四个概念是指 同步、异步、阻塞、非阻塞(衍生出 同步阻塞、同步非阻塞、异步阻塞、异步非阻塞)。
1.1 区别
首先要明确,同步/异步是通信机制,即被调用者返回结果时通知调用者的一种通知机制;阻塞/非阻塞是线程状态,即被调用者返回结果前调用者的线程状态。其次可以换个角度理解,同步/异步描述的是被调用者,阻塞/非阻塞描述的是调用者。然后同步/异步 与 阻塞/非阻塞是相互联系的。最后看下文。
1.2 联系
- 同步:A 调用 B,B 在接收到 A 的请求后会立即处理,立即返回,此为同步。A 的本次调用会得到结果。
- 异步:A 调用 B,B 在接收到 A 的请求后不会立即处理,但一定会去处理,且在处理完后会通知 A,此为异步。A 的本地调用不会得到结果,但后续会得到 B 的通知。
- 阻塞:A 调用 B,A 在 B 返回结果前一直等待,此为阻塞。
- 非阻塞:A 调用 B,A 在 B 返回结果前不会等待,而是去做其它事情,此为非阻塞。
- 同步阻塞:A 调用 B,B 在接收到 A 的请求后立即处理、立即返回,且此时 A 一直等待返回结果,此为同步阻塞。
- 同步非阻塞:A 调用 B,B 在接收到 A 的请求后立即处理、立即返回,且此时 A 去做其它事情,并时不时来看看 B 做完了没,此为同步非阻塞。
- 异步阻塞:A 调用 B,B 在接收到 A 的请求后不会立即处理,但一定会去处理,在处理完后会通知 A,且此时 A 一直等待返回结果,此为异步阻塞。
- 异步非阻塞:A 调用 B,B 在接收到 A 的请求后不会立即处理,但一定会去处理,在处理完后会通知 A,且此时 A 去做其它事情,直到收到 B 的通知,此为异步非阻塞。
2 IO 五种模型
五种模型是指五种 IO 模型,即 阻塞 IO 模型、非阻塞 IO 模型、IO 多路复用模型、信号驱动 IO 模型、异步 IO 模型。只有异步 IO 模型是真正异步的。
网络 IO 的本质是 socket 的读取,socket 在 linux 系统中被抽象为流,所以 IO 可以理解为是对流的操作。
对于一个 IO 请求而言,它会经历两个阶段:
- 1、等待数据准备就绪;
- 2、内核将准备就绪的数据拷贝到用户线程。
对于 socket 流而言,也会经历两个阶段:
- 1、等待网络上的数据分组到达,然后将其拷贝到内核缓冲区;
- 2、将内核缓冲区的数据拷贝到用户进程缓冲区。
2.1 阻塞 IO 模型
最传统的 IO 模型,即在读写数据过程中会发生阻塞现象。
当用户线程发出 IO 请求之后,内核回去检查数据是否准备就绪,若没有则等待就绪,此时,用户线程会阻塞,并交出 CPU;当数据准备就绪之后,内核会将数据拷贝到用户线程,并将结果返回给用户线程,然后用户线程解除阻塞状态。该模型是同步阻塞的。
2.2 非阻塞 IO 模型
当用户线程发起一个 IO 请求之后,并不需要阻塞等待,而是马上得到一个结果,若结果为 error 则说明数据未准备就绪,于是再次发起 IO 请求;一旦内核中的数据准备好了,且再次收到了用户线程的 IO 请求,则内核会将数据拷贝到用户线程,然后返回。
因此在非阻塞 IO 模型中,用户线程会不断的询问内核数据是否准备就绪(类似于 where true),也就是说该模型中用户线程不会交出 CPU,而是会一直占用 CPU。故该模型也会带来一个问题,那就是用户线程一直占用 CPU 会导致 CPU 占用率过高,浪费资源,所以一般情况下很少使用。该模型是同步非阻塞的。
2.3 IO 多路复用模型
IO 多路复用模型,也称事件驱动模型,其在非阻塞 IO 模型的基础上解决了用户线程不断轮询内核以至于长时间占用 CPU 的问题。其解决方案是引入了新的 select 系统调用。
多路复用在操作系统层面的设计思想是通过 select 来监控多个 fd(文件描述符),从而避免为每一个 fd 创建一个监控线程,来达到减少线程资源占用的开销。一旦 select 检测到某个 fd 携带事件(如读写),则将 fd 的就绪状态返回给用户线程,用户线程在根据其具体的事件调用对应的 IO 请求。
换言之,多路复用模型中,会用一个用户线程不断轮询多个 socket 的状态(即 select),只有当某个 socket 有真正的读写事件到达时,才会通知用户线程调用具体的 IO 读写操作。
其缺点是,因为其是通过不断轮询 socket 的状态来对到达的事件逐一响应,故当某个事件响应体过大就会导致后面的事件迟迟得不到处理,并且会影响新的事件轮询。
在多路复用模型中,当用户发起 IO 请求后,即某个 socket 有具体事件到达时,会被立即处理,所以其是同步;当没有读写事件时,用户线程是非阻塞的。所以该模型是同步非阻塞的。
多路复用模型引入了新的系统调用 select,该功能在不通操作系统上有不同的实现,linux 为 epoll,macos 为 kqueue、windows 为 iocp 和 wepoll(即 windows 版的 epoll)。感兴趣的同学可自行百度。
2.4 信号驱动 IO 模型
当用户线程发起一个 IO 请求后,会给对应的 socket 注册一个信号函数,然后用户线程不阻塞继续运行;党内和数据准备就绪后会发送一个信号给用户线程,用户线程接到信号后,便在信号函数中调用 IO 读写操作来进行实际的 IO 请求操作。
2.5 异步 IO 模型
异步 IO 模型是比较理想的模型。当用户线程发起一个 IO 请求之后,就可以立即去做其它事情了。从内核的角度来看,当它收到一个异步 IO 请求后,会立即返回,说明 IO 请求已经成功发起了,因此不会对用户线程产生任何阻塞。接着,内核会等待数据准备就绪,当数据准备就绪后,会将数据拷贝到用户线程,做完这一切后,内核会给用户线程发送一个信号,告诉它 IO 操作完成了。也就是说在该模型中,用户线程只需要发起 IO 操作,完全不用理会实际的 IO 操作是如何完成的,直到等待内核的信号,就可以使用数据了。
在该模型中,IO 操作的两个阶段(等待数据就绪,拷贝数据到用户线程)都是内核完成的,所以其是真正意义上的异步。对于其它模型而言,第二阶段(拷贝数据到用户线程)会引起用户线程的阻塞,所以不是异步。
注意,异步 IO 是需要操作系统底层支持的。在 jdk7 中国提供了 Asynchronous 来实现异步 IO。
3 IO 三个实现
三种实现在本文特指 java 语言实现的 BIO、NIO、AIO。这三种实现可以理解为是 java 语言对操作系统各 IO 模型的封装,使得开发人员在使用具体的 IO 时不需要关心操作系统层面的知识,只需要了解 java 的 api 即可。
3.1 简介
-
BIO
BIO 是传统 io,即 java.io 包下的代码实现,其是同步阻塞的,采用 BIO 通信机制。即当用户线程发起一个 IO 请求后,系统会立即处理、立即返回,且在 IO 操作完成之前用户线程会一直等待。因此每个请求都需要一个线程来单独处理。
采用 BIO 通信机制的服务端通常有一个 acceptor 线程负责监听客户端的连接,当接收到客户端的连接后会为每个连接创建一个新的线程进行处理,处理完之后,将结果通过输出流返回给客户端,最后线程销毁。这就是典型的一请求一应答模式。
-
NIO
NIO 是 jdk 1.4 引入的 java.nio 包下的代码实现,其是同步非阻塞的,是 BIO 的升级版本,和 BIO 有着同样的作用,它们之前最重要的区别就是数据打包和传输的方式不一样。BIO 以流的方式处理数据,而 NIO 以块的方式处理数据。NIO 是多路复用 IO 模型的实现。
面向流的 IO 系统一次处理一个字节的数据,即一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易,但面向流的 IO 通常比较慢。
面向块的 IO 系统以块的形式处理数据,每个操作都在一步中产生或消费一个数据块。其在速度上要比流快很多,但比面向流的 IO 缺少一些优雅性和简单性。
NIO 即 New IO,与原来的 I/O 有着同样的作用和目的,它们之间最重要的区别就是数据打包和传输的方式。原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。
面向流的 I/O 系统一次一个字节地处理数据,一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易,连接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是面向流的 I/O 通常非常慢。
面向块的 I/O 系统以块的形式处理数据。每个操作都在一步中产生或者消费一个数据块。按块处理数据要比按流处理数据快得多,但面向块的 I/O 缺少一些面向流的 I/O 的优雅性和简单性。 -
AIO
AIO 是 jdk 1.7 之后引入的包,即 Asynchronous IO,是异步非阻塞的,是 NIO 的升级版本,通过回调的方式来实现高效的 IO 操作。AIO 是 异步 IO 模型的实现。
3.2 适用场景
- BIO
BIO 适用于连接数目较小且固定的架构。这种方式对服务器资源要求比较高,并发局限于应用中,jdk1.4 以前的唯一选择,程序简单直接,容易理解。 - NIO
NIO 适用于连接数目较多且连接比较短(轻操作)的架构。比如聊天服务器,并发局限于应用中,编程较复杂,jdk1.4 开始支持。 - AIO
AIO 适用于连接数目较多且连接较长(重操作)的架构。比如音视频服务器,充分调用操作系统参与并发操作,编程较复杂,jdk1.7 开始支持。
4 NIO 的秘密
4.1 设计原理
NIO 是基于通道、面向缓冲区的,且其依赖 socket 可实现非阻塞模式。NIO 在设计上有三大核心组件,即 Selector(选择器)、Channel(通道)、Buffer(缓冲区)。上图简要描述了三大组件间的关系。
4.1.1 Buffer
缓冲区,其本质上是一块可以写入/读取数据的内存(分为堆内存和堆外内存)。主要与通道进行交互,即数据从缓冲区写入通道,从通道读取到缓冲区。其是 IO 数据的载体,即作为数据在 IO 设备 与 NIO 程序之间传输的中介。举个栗子,数据在服务端与客户端传输时,会先从服务端/客户端的 IO 设备写入缓冲区,接着通过通道传输到 NIO 程序,然后通过网络传输到客户端/服务端的通道,最后再将通道中的缓冲区所承载的数据读取到 NIO 程序。再举个栗子,缓冲区是火车皮,通道是火车,先将货物从工厂装入火车皮,接着再由火车将其运到港口,再由货轮(网络传输介质)将其送到目的地港口,然后由火车将其送往内陆目的地,最后将车皮中的货物卸下。生动而形象!
缓冲区在 java 中被抽象成 java.nio.Buffer 及其子类,并提供了相关 api 来操作对应内存块里的数据。使用时需要先分配缓冲区大小(固定不可变),然后才能写入或读取数据。
4.1.2 Channel
通道,表示一个可以从 NIO 程序连接到 IO 设备(如磁盘文件、socket 等)的通道,负责在 IO 设备与 NIO 程序之间传输数据(以缓冲区的形式)。通道本身不能访问数据,只能与缓冲区交互。通道类似于传统的流,但有不同于流,二者区别如下:
- 通道是全双工的,即双向的,即可以同时进行读写;流是单向的,只能读或者些。
- 通道支持异步读写。
- 通道以缓冲区为载体进行数据传输。
通道在系统层面对应系统的文件描述符(file descriptor)。在 linux 系统中,一切即可为文件(socket 连接也被视为文件),每当打开或创建一个文件时,都会创建一个文件描述符,文件描述符是内核为了高效管理已被打开的文件所创建的索引,且所有执行 IO 操作的系统调用都会通过文件描述符进行。所以每建立一个连接(打开一个通道),都会创建一个文件描述符。
通道在 java 中被抽象成 java.nio.channels.Channel 及其子类,并提供了相关 api 来操作通道。
4.1.3 Selector
选择器,亦称为多路复用器,其可以检测出一个或多个通道上那些有事件到达(如读取、连接、接收等),并可以获取到有事件到达的通道,交由 NIO 程序去处理。在设计上,用一个线程通过一个选择器去监控多个通道,当某个通道有事件到达时,再交由用户线程去处理对应的 IO 操作,这样就避免了每一个连接创建线程,在节省资源的同时又提高了效率。
选择器的使用需要对应系统的支持,在不同平台上有不同的实现。如 linux 的 epoll,macos 的 kqueue、windows 的 select 和 wepoll(即 windows 版的 epoll)等,这些都是操作系统所实现的多路复用器。
选择器在 java 中被抽象成 java.nio.channels.Selector 及其子类,并提供相关 api 来操作选择器。
4.2 核心组件
如下图所示,nio 核心组件为 Selector、Channel、Buffer,其中 Selector 与 Channel 之间通过 SelectionKey 关联,具体的 IO 操作需要底层操作系统支持,于是就有了 FileDescriptor 和 NativeDispatcher。
4.2.1 Buffer 系列
Buffer 类是 java nio 提供的缓冲区的抽象类,同时提供了常用数据类型(如基本数据类型、集合类型等)的实现类,且这些实现类又分为堆缓冲区和直接缓冲区。
堆缓冲区即在 jvm 堆内存分配的缓冲区(也称非直接内存),堆外内存即直接在系统内存上分配的缓冲区(也称直接内存),二者区别如下:
- 作用链:
- 堆缓冲区:IO 设备 -> 直接内存 -> 非直接内存 -> 直接内存 -> IO 设备。
- 直接缓冲区:IO 设备 -> 直接内存 -> IO 设备。
- IO 效率:直接缓冲区 IO 效率要高于堆缓冲区,因为其少了一个数据拷贝。
- 分配耗能:直接缓冲区分配时要比堆缓冲区更加耗费性能。
因此,当所涉及的数据较大且生命周期很长时,可以考虑使用直接缓冲区;当需要频繁 IO 操作时(如网络并发场景),则可以考虑使用堆缓冲区。总之,如果不能带来明显的性能提升,推荐使用堆缓冲区(嗯?你问我为什么?问就是 jdk 源码中默认使用堆缓冲区(存在即合理))。
Buffer 类在设计上通过维护一个 hb 数组来存放数据(hb 数据具体由其子类声明),还维护了 mark(标记)、position(位置)、limit(限制)、capacity(容量)等属性。
- mark:本质是一个索引,通过 mark() 方法设置当前位置(position)为标记点,后续可以通过 reset() 方法将 position 重置到 mark 标记点。标记点不能大于 position。
- position:本质是一个索引,表示下一次要读取或写入的数组的索引。position 不能大于 limit。
- limit:本质是一个索引,表示当前缓冲区可以操作的数据的大小。写入模式下,limit 等于缓冲区容量;读取模式下,limit 等于已写入的数据量。limit 值不能大于缓冲区容量。
- capacity:缓冲区容量,创建缓冲区时需设置,且后续不可修改大小。
- 四者遵守不变式:mark <= position <= limit <= capacity
Buffer 在使用时一般有如下步骤:
- 使用 allocate() 方法分配一个 buffer 对象,且需指定缓冲区大小。
- 使用 put() 方法将数据存入缓冲区。
- 使用 flip() 方法将缓冲区从写入模式切换到读取模式。
- 使用 get() 方法将数据取出缓冲区。
Buffer 常用方法(以 ByteBuffer 实现类为例):
- ByteBuffer allocate(int capacity):创建一个指定容量的缓冲区。
- put(byte b):将指定字节放入缓冲区当前位置(position)。注:若无特别指定,put() 默认都放入当前位置。
- put(byte[] src):将指定数组 src放入缓冲区。
- put(int index, byte[] src):将指定数组放入缓冲区的指定位置(index)。不会改变 position。
- put(int index, byte[] src, int offset, int length):将指定数组(src)从指定位置(offset)开始的指定长度(length)的数据放入缓冲区指定位置(index)。
- get():读取缓冲区当前位置(position)的数据,然后递增 position。
- get(int index):读取缓冲区指定位置的数据,并递增 position。
- get(byte[] dst):读取缓冲区从当前位置开始的 dst 数组长度的数据到 dst 中。
- get(int index, byte[] dst, int offset, int length):读取缓冲区从指定位置(index)开始的长度为 length 的数据到 dst 数组的 offset 位置处。
- flip():切换缓冲区模式。即从 写入/读取 切换到 读取/写入。切换时会将 limit 设置到 position,将 position 值为 0,若设置了 mark 则丢弃。
- mark():设置标记,标记点为当前位置。
- reset():将当前位置重置到标记点。
- clear():清除缓冲区。
4.2.2 FileDescriptor 系列
文件描述符是 linux 内核为了高效管理被打开的文件而创建的索引,它是一个非负整数,用来表示每一个被进程打开的文件或建立的 socket 连接等,且,所有 IO 操作的系统调用都是通过文件描述符完成的。程序刚启动的时候,默认有三个文件描述符,分别是:0(标准输入)、1(标准输出)、2(标准错误)。
4.2.3 Channel 系列
关于 Channel 通道的作用已经在 4.1.2 中阐述过了,这里再说明下其几个重要实现(后三个为网络通道):
- FileChannel:文件通道,用来操作本地文件的通道。
- SocketChannel:socket 客户端通道,通过 TCP 处理网络 IO 数据的通道(用户服务端)。
- ServerSocketChannel:socket 服务端通道,通过 TCP 处理网络 IO 数据的通道(用于服务端,处理 TCP 连接)。
- DatagramChannel:通过 UDP 处理网络 IO 数据的通道。
它们都有一个核心属性 FileDescriptor(文件描述符),前面提到过,每当打开或创建一个文件时,都会创建一个文件描述符,文件描述符是内核为了高效管理已被打开的文件所创建的索引,且所有执行 IO 操作的系统调用都会通过文件描述符进行。所以每个 channel 对象都会持有一个FileDescriptor 实例。
核心方法如下:
- read(ByteBuffer dst):将通道中的字节数据读取到指定缓冲区中。
- read(ByteBuffer[] dsts):将通道中的字节数据读取到指定缓冲区数组中。
- write(ByteBuffer src):将指定缓冲区中的数据写入通道。
- write(ByteBuffer[] srcs):将指定缓冲区数组中的数据写入通道。
- transferFrom(ReadableByteChannel src, long position, long count):将指定通道(src)中的字节数据复制到当前通道中。该方法仅存于文件通道中。
- transferTo(long position, long count, WritableByteChannel target):将当前通道中的字节数据复制到指定通道中(target)。该方法仅存于文件通道中。
- register(Selector sel, int ops):将当前通道注册到指定选择器(sel)上,并指定该通道要关注的事件。该方法仅存于网络通道中。
- bind(SocketAddress local):给当前通道绑定本地地址。该方法仅存于网络通道中。
- configureBlocking():配置通道是否阻塞。
- connect(SocketAddress remote):客户端连接远程服务端。该方法仅存于 SocketChannel、DatagramChannel 中。
通道的获取通常有以下几种方式:
- 通过支持通道操作的类的 getChannel() 方法获取,如 FileInputStream、FileOutputStream、RandomAccessFile、Socket、ServerSocket、DatagramSocket 类等。
- 通过静态方法,如文件类 Files 的 newByteChannel() 方法,或网络通道的 open() 方法。
4.2.4 SelectionKey 系列
SelectionKey 表示 SelectableChannel 在 Selector 注册的令牌。即 SelectionKey 可以理解为是一种标识、令牌、token 等。SelectableChannel 是支持注册到 Selector 中的通道,因此实现了该接口的通道都支持注册功能,如网络通道(SocketChannel、ServerSocketChannel、DataframChannel)等。换言之,当打开了一个通道,被注册到 Selector 中时,该通道对象会被包装成 SelectionKey 维护在 Selector 中。至于为什么要注册,当然是因为 Selector 时选择器(多路复用器),要监控管控是否有事件到达。
SelectionKey 只有一个 SelectionKeyImpl 实现类,该类持有了通道属性(SelChIml channel)和选择器属性(SelectorImpl selector)。以此将选择器和通道对应起来。
4.2.5 Selector 系列
Selector,选择器,即多路复用器。其可以同时监控多个可选择通道 SelectableChannel(网络通道皆扩展自该类),当某些通道有具体的事件到达时再交由用户线程去处理具体的 IO 操作。这样就避免了为了每一个通道创建一个线程,节省了系统资源开销。
Selector 类在实现上依赖于不同操作系统对多路复用的具体实现,常见的操作系统对多路复用的实现有:poll、select、epoll、kqueue、wepoll 等(关于这些操作系统的底层实现,感兴趣的同学可以自行百度)。java nio 对这些实现进行了包装,如下(注:以下 Selector 的具体实现类,开发时只有在对应平台才能看到对应的实现类):
- PollSelectorImpl:对应 linux 平台对多路复用的 poll 实现。
- EPollSelectorImpl:对应 linux 平台对多路复用的 epoll 实现。
- KQueueSelectorImplr:对应 macos 平台对多路复用的 kqueue 实现。
- WindowsSelectorImpl:对应 windows 平台对多路复用的 select 实现。
- WEPollSelectorImpl:对应 windows 平台对多路复用的 wepoll 实现。
在将通道注册到选择器上时,需要指定该通道所要关注的事件,公有以下四种事件:
- OP_READ:读事件,即只关注读取事件,值为 1。
- OP_WRITE:写事件,即只关注写入事件,值为 4。
- OP_CONNECT:连接事件,即只关注客户端连接事件,值为 8。
- OP_ACCEPT:接收事件,即只关注接收客户端连接的事件,值为 16。
// 当该通道需要关注多个事件时,可用 位或 运算符连接
SelectionKey.OP_READ | SelectionKey.OP_WRITE
Selector 核心属性如下:
// 注册到该选择器上的通道(前面说明过 SelectionKey 是注册令牌 对通道进行了包装 为了便于理解故在此称其为通道)
private final Set<SelectionKey> keys;
// 有事件到达的通道
private final Set<SelectionKey> selectedKeys;
// 供外部访问的通道
private final Set<SelectionKey> publicKeys; // 已注册的通道 不能修改 对应 keys
private final Set<SelectionKey> publicSelectedKeys; // 有事件到达的通道 可移除但不能添加 对应 selectedKeys
// 已取消的通道(已移除)
private final Deque<SelectionKeyImpl> cancelledKeys = new ArrayDeque<>();
protected SelectorImpl(SelectorProvider sp) {
super(sp);
keys = ConcurrentHashMap.newKeySet();
selectedKeys = new HashSet<>();
publicKeys = Collections.unmodifiableSet(keys); // 基于 keys 初始化 publickeys 且不可修改
publicSelectedKeys = Util.ungrowableSet(selectedKeys); // 基于 selectedKeys 初始化 publicSelectedKeys 且只能添加不能移除
}
Selector 核心方法如下:
- keys():获取所有注册在选择器上的通道。
- selectedKeys():获取注册在通道上且真正有事件到达的通道。
- select():检测有事件到达的通道,其返回有事件到达的通道的数量。
- close():关闭选择器。若该选择器已关闭则立即返回,若未关闭则标记该选择器已关闭,然后调用 implCloseSelector() 执行具体的关闭操作。
- implCloseSelector():关闭选择器。具体操作为逐个移除注册到该选择器上的通道,并关闭通道。
- register(AbstractSelectableChannel ch, int ops, Object att):注册通道。参数一为要注册的通道,参数二为该要注册的通道所关注的事件。实际上 SelectableChannel 也定义了 register() 方法,且在内部利用传入的 Selector 参数调用了 Selector 定义的 register() 方法。所以通道注册时由 channl.register() 触发,具体注册操作由 selector.register() 完成。
4.3 工作流程
因为 nio 适用于连接数目较多连接较短(轻操作)的架构,如聊天服务器,所以下文以一个简易的聊天服务器为例介绍各组件的使用。注:该流程对应下一节(4.4 代码示例)。
服务端:
- 1、通过 ServerSocketChannel.open() 创建一个服务端的通道 serverChannel,此时会为该通道初始化一个文件描述符。
- 2、通过 serverChannel.configureBlocking(false) 将其设置为非阻塞。
- 3、通过 serverChannel.bind(new InetSocketAddress(host, port)) 给服务端绑定一个地址,这个地址可以是 ipv4、ipv6、通用、新通用。
- 4、通过 Selector.open() 创建一个选择器实例。
- 5、通过 serverChannel.register(selector, SelectionKey.OP_ACCEPT) 将该通道注册到选择器上,并指定该通道只关注 OP_ACCEPT 事件,即只关注来自客户端的连接事件。
- 6、通过 while(true) 不断轮询调用 selector.select() 方法,以此来监控有事件到达的通道。
- 7、若有事件到达,则通过 selector.selectedKeys() 获取有事件到达的通道,然后判断其事件类型。
- 8、若为 OP_ACCPET 事件,则与客户端建立连接,并未该连接创建 SocketChannel 通道,然后将该通道注册到 selector 上,且指定关注事件为 OP_READ,接收来自客户端的消息。
- 9、若为 OP_READ 事件,则读取来自客户端的消息。注:客户端断开连接也会触发 OP_READ 事件,故可在此处添加客户端下线逻辑。
- 10、当这一批事件处理完之后,继续执行 第六步。
客户端:
- 1、通过 SocketChannel.open(new InetSocketAddress(host, port)) 来创建通道 channel 并与服务端建立连接,其中 host、port 为服务端地址(即 serverChanel.bind(new InetSocketAddress(host, port)) 中的地址)。此时会为该通道初始化一个文件描述符。注:open(new InetSocketAddress(host, port)) 方法在内部会先创建通道,然后调用 connect() 方法与服务端连接,因此也可以先调用 open() 方法获取通道,然调用 connect() 与服务端连接。
- 2、通过 channel.configureBlocking(false) 将其设置为非阻塞。
- 3、通过 Selector.opne() 创建一个选择器实例。
- 4、通过 channl.register(selector, SelectionKey.OP_READ) 将该通道注册到选择器上,并指定该通道只关注 OP_READ 事件。
- 5、后续操作和服务端中的第六步一样,只不过对于客户端中的事件来说,只有 OP_READ、OP_WRITE,而没有 OP_CONNECT、OP_ACCEPT 事件,因为客户端只关注来自服务端的消息和向服务端发送消息。
通过以上示例可以发现,当一个客户端想要与服务端建立连接时,会先在客户端创建一个通道,然后请求连接,服务端与客户端通过三次握手连接成功之后,会在服务端为该连接创建一个通道,然后双方就可以通信了。(可对照 4.1 设计原理 小节 的示意图进行理解)。
4.4 代码示例
4.4.1 服务端核心代码:
public class NIOServer {
private static final Log logger = LogFactory.getLog(NIOServer.class);
private static volatile NIOServer INSTANCE;
private final int port;
private Selector selector;
private ServerSocketChannel serverSocketChannel;
private NIOServer(int port) {
this.port = port;
}
public static NIOServer getInstance(int port) {
if (INSTANCE == null) {
synchronized (NIOServer.class) {
if (INSTANCE == null) {
INSTANCE = new NIOServer(port);
}
}
}
return INSTANCE;
}
// 启动服务端
public void start() {
this.init();
int events;
SelectionKey selectionKey;
Iterator<SelectionKey> iterator;
while (true) {
try {
events = this.selector.select();
if (events <= 0) {
continue;
}
iterator = this.selector.selectedKeys().iterator();
while (iterator.hasNext()) {
selectionKey = iterator.next();
if (!selectionKey.isValid()) {
continue;
}
iterator.remove();
try {
if (selectionKey.isAcceptable()) {
logger.info("accept select key " + selectionKey);
accept();
}
if (selectionKey.isReadable()) {
logger.info("read select key " + selectionKey);
read(selectionKey);
}
} catch (IOException e) {
selectionKey.cancel();
if (selectionKey.channel() != null) {
selectionKey.channel().close();
}
}
}
} catch (IOException e) {
logger.error("Failed to run with nio server");
e.printStackTrace();
}
}
}
// 初始化服务端
private void init() {
try {
this.serverSocketChannel = ServerSocketChannel.open();
this.serverSocketChannel.configureBlocking(false);
this.serverSocketChannel.bind(new InetSocketAddress(this.port));
this.selector = Selector.open();
this.serverSocketChannel.register(this.selector, SelectionKey.OP_ACCEPT);
logger.info("nio server is initialized");
} catch (IOException e) {
logger.error("Failed to init nio server");
e.printStackTrace();
System.exit(1);
}
}
// 处理 accept 事件
private void accept() throws IOException {
logger.info("process accept event");
SocketChannel socketChannel = this.serverSocketChannel.accept();
socketChannel.configureBlocking(false);
socketChannel.register(this.selector, SelectionKey.OP_READ);
logger.info(socketChannel.getRemoteAddress() + " 上线");
}
// 处理读事件(包括客户端下线)
private void read(SelectionKey selectionKey) throws IOException {
logger.info("process read event");
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int length;
try {
length = socketChannel.read(buffer);
} catch (IOException e) {
logger.info(socketChannel.getRemoteAddress() + " 下线");
selectionKey.cancel();
socketChannel.close();
return;
}
if (length <= 0) {
return;
}
buffer.flip();
byte[] message = buffer.array();
buffer.clear();
logger.info(new String(message, 0, length));
this.sendMessageToAll(socketChannel, message);
}
// 将消息发送到其它客户端
private void sendMessageToAll(SocketChannel currentChannel, byte[] message) {
Channel channel;
SocketChannel socketChannel;
ByteBuffer buffer;
for (SelectionKey key : this.selector.keys()) {
try {
channel = key.channel();
if (!(channel instanceof SocketChannel) || channel == currentChannel) {
continue;
}
socketChannel = ((SocketChannel) channel);
buffer = ByteBuffer.wrap(message);
socketChannel.write(buffer);
logger.info("target client " + socketChannel.getRemoteAddress());
} catch (IOException e) {
logger.error("Failed to send message to all", e);
e.printStackTrace();
}
}
}
}
4.4.2 服务端启动代码:
@Component // spring boot 启动完成后会触发 ApplicationStartedEvent 事件 进而启动 nio 服务端
public class NIOServerStartup implements ApplicationListener<ApplicationStartedEvent> {
private static final Log logger = LogFactory.getLog(NIOServerStartup.class);
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
logger.info("start initializing and starting nio server");
NIOServer server = NIOServer.getInstance(8081);
server.start();
}
}
4.4.3 客户端核心代码:
public class NIOClient {
private static final Log logger = LogFactory.getLog(NIOClient.class);
private final String clientName;
private final String host;
private final int port;
private Selector selector;
private SocketChannel socketChannel;
public NIOClient(String clientName, String host, int port) {
this.clientName = clientName;
this.host = host;
this.port = port;
}
// 连接服务端
public void connect() {
try {
this.socketChannel = SocketChannel.open(new InetSocketAddress(host, port));
this.socketChannel.configureBlocking(false);
this.selector = Selector.open();
this.socketChannel.register(this.selector, SelectionKey.OP_READ);
} catch (IOException e) {
logger.error("Failed to connect nio server");
e.printStackTrace();
}
}
// 发送消息
public void write() {
try {
ByteBuffer buffer = ByteBuffer.allocate(1024);
Scanner scanner = new Scanner(System.in);
String message;
while (true) {
System.out.print("在此输入骚话 没有就去百度:");
message = scanner.nextLine();
if (!StringUtils.hasText(message)) {
continue;
}
message = this.clientName + ":" + message;
buffer.put(message.getBytes(StandardCharsets.UTF_8));
buffer.flip();
this.socketChannel.write(buffer);
buffer.clear();
}
} catch (IOException e) {
logger.error("Failed to write to nio server");
e.printStackTrace();
}
}
// 读取消息
public void read() {
int events;
SelectionKey selectionKey;
Iterator<SelectionKey> iterator;
while (true) {
try {
events = this.selector.select();
if (events <= 0) {
continue;
}
iterator = this.selector.selectedKeys().iterator();
while (iterator.hasNext()) {
selectionKey = iterator.next();
if (!selectionKey.isValid()) {
continue;
}
iterator.remove();
if (selectionKey.isReadable()) {
this.read(selectionKey);
}
}
} catch (IOException e) {
logger.error("Failed to read message from server", e);
e.printStackTrace();
}
}
}
// 处理读事件
private void read(SelectionKey selectionKey) throws IOException {
logger.info("process read event");
SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int length;
byte[] message;
while ((length = socketChannel.read(buffer)) > 0) {
buffer.flip();
message = buffer.array();
buffer.clear();
System.out.println(new String(message, 0, length));
}
}
}
4.4.4 客户端实例
// 客户端一 发送消息
public class ZedClient {
public static void main(String[] args) {
new Thread(() -> {
NIOClient client = new NIOClient("影流之主", "127.0.0.1", 8081);
client.connect();
client.write();
}).start();
}
}
// 客户端二 接收消息
public class FizzClient {
public static void main(String[] args) {
new Thread(() -> {
NIOClient client = new NIOClient("潮汐海灵", "127.0.0.1", 8081);
client.connect();
client.read();
}).start();
}
}
// 客户端三 接收消息
public class AhriClient {
public static void main(String[] args) {
new Thread(() -> {
NIOClient client = new NIOClient("九尾妖狐", "127.0.0.1", 8081);
client.connect();
client.read();
}).start();
}
}
5 netty 简介&特点&应用场景
5.1 简介
netty 是一个基于 java nio 框架实现的高性能、异步事件驱动的网络应用程序框架。其设计目的是提供简单易用、易扩展、高性能、高可靠、低延迟的网络编程框架。最初由 JBoss 公司开发,现由社区维护。
其非常适合构建各种网络应用,包括但不限于网络服务器、游戏服务器、代理、聊天应用、实时通信和分布式系统等。如 ElastisSearch、kafka、Dubbo 等框架内部都采用了 netty。总之,用过的都说好。
5.2 特点
- 高并发:netty 使用异步的、非阻塞的 IO 模型,通过事件驱动的方式处理网络操作,能够高效的处理并发连接和并发请求。
- 高性能:netty 采用了一系列高性能技术,如零拷贝、内存池和可定制线程模型等,使其在性能方面表现出色。
- 易扩展:netty 的架构和组件设计具有高度灵活性和可扩展性。用户可以根据具体需求活使用场景进行定制和扩展。
- 多协议支持:netty 支持大部分常用网络协议,如 http、websocket、tcp、udp 等,同时支持自定义协议。且具备灵活的编解码器和处理器,简化了协议的实现和交互。
- 安全性:netty 提供了一系列安全性功能,如加密、身份验证、SSL/TLS 等。
- 简单易用:netty 提供了简洁的 API 和丰富的文档,使得开发人员可快速上手。同时其强大活跃的生态支持着 netty 的脱变。
5.3 netty 应用场景
- 实时通信系统:netty 的高性能低延迟特点,可用于需要实时交互的场景,如聊天服务器、游戏服务器、实时监控系统等。
- 分布式系统:netty 可作为分布式系统的底层通信框架,即集群节点间通信,如 kafka、ElasticSearch、Dubbo 等类似框架。
- 远程服务调用(RPC):即 RPC,嗯,和分布式系统是一个意思。
- 数据传输系统:netty 的零拷贝特性和高效的内存管理机制使其适用于大规模数据传输,如文件传输、流媒体传输等。
- 长连接系统:netty 的高性能和低资源消耗特点使其适用于长连接应用,如 IM、实时监控系统、推送服务、游戏服务器等。嗯,说来说去好像都一个意思。
6 netty 线程模型
在高性能的 IO 设计中有两个非常著名的线程模型,即 Reactor 和 Proactor。Reactor 是同步非阻塞的,Proactor 是异步的。二者区别:
- 相同点:
- 同为事件驱动
- 同属 IO 多路复用模式
- 不同点:
- reactor 为同步,proactor 为异步。
- reactor 感知的是已就绪的可读写事件,proactor 感知的是已完成的读写事件。即在 reactor 中,事件到达后,系统通知用户进程,用户进程来处理事件;而在 proactor 中,事件到达后,系统处理事件,处理完后通知用户进程。
Reactor 线程模型根据 reactor 数和线程数的不同有三种经典实现,分别是:单 reactor 单线程、单 reactor 多线程、主从 reactor 多线程。netty 则是基于主从 reactor 多线程模型,并做了一定的改进。
6.1 传统 IO 线程模型
如上图所示,为传统 IO 线程模型。其特点是每个客户端连接都需要单独的线程支持。
- 优点:
- 适用于连接数目较小且固定的架构,且对系统资源要求较高,并发局限于应用中。
- 缺点:
- 当并发数很大时,需要创建大量线程,占用更多系统资源。
- 当连接建立后,若该连接上短时间内没有 IO 事件,则该线程会一直阻塞,故会造成系统资源的浪费。
6.2 Reactor 线程模型
Reactor 是异步非阻塞基于事件驱动的网络编程设计模式,适用于高并发场景。其通过合理的线程管理和事件分发机制,实现高并发处理。其核心组件如下:
- Reactor:响应器,负责监听和分发事件。将建听到的事件分发给处理器 Handler。事件包括连接事件、读事件和写事件。
- Acceptor:接收器,用来处理连接事件,即客户端的请求连接事件。
- Handler:处理器,用来处理读写事件,其一般处理流程为:read -> 业务处理 -> send。
Reactor 模型一般工作流程如下:
- 应用程序给 Reactor 注册其感兴趣事件和相关处理器。
- Reactor 启动,开始监听事件。
- 分发事件:若事件为连接事件,则交由 Acceptor 处理器处理,Acceptor 会为每个连接创建 Channel,且会将其注册到 Select;若事件为读写事件,则交由 Hanlder 处理器。
- 处理读写事件:Handler 从 Channel 读取到数据后,进行业务处理,然后将结果写进 Channel。
Reactor 模型根据其 reactor 数和线程数不同有三种经典实现,分别是:单 Reactor 单线程模型、单 Reactor 多线程模型 和 主从 Reactor 多线程模型。
6.2.1 单 Reactor 单线程模型
如上图所示,为 单 Reactor 单线程模型。该模型中,从 Reactor 对象通过 select 监控事件,再到事件分发、处理都是在当前 Reactor 线程中完成的,即整个流程有且仅有一个线程。故,其适用于一些小容量、低并发的应用场景。缺点是肉眼可见的明显。
6.2.2 单 Reactor 多线程模型
如上图所示,为 单 Reactor 多线程模型,相比于 单 Reactor 单线程模型,其在读写事件处理中的业务处理环节增加了多线程方案,即当 Handler 从 Channel 中读取到数据后会将其交给 Worker 线程池中线程,由 Worker 线程池中线程完成具体的业务处理,然后将结果返回给 Handler,Handler 再将结果写入 Channel。
相较于 单 Reactor 单线程模型,该模型性能得到一定提升,但任然存在问题:
- Reactor 任然是单线程,即事件监听、连接注册和事件分发任然在一个线程中进行,在高并发场景下任然存在性能问题;且单线程并不具备高可用性。
- 多线程数据共享和访问会较复杂,即处理结果由主线程 Reactor 发送时就会涉及到数据共享的互斥和保护机制。
6.2.3 主从 Reactor 多线程模型
如上图所示,为 主从 Reactor 多线程模型。与 单 Reactor 多线程模型不同的是,在该模型中,将 Reactor 设计为主从两部分,且都为多线程,同时保留了专门处理业务的 Worker 线程池。即连接事件监听、分发和注册等流程将由 主 Reactor 线程池负责;读写事件监听、分发等流程将由 从 Reactor 线程池负责;业务处理依旧交给 Worker 线程池负责。
该模型工作流程大致如下:
- 1、服务端启动时,会从主 Reactor 线程池中随机抽取一个幸运儿,作为 Acceptor 线程,用于绑定监听端口、接受客户端连接。
- 2、当 Acceptor 线程接收到客户端的连接请求后为妻创建 SocketChannel,然后将其随机注册主 Reactor 线程池中的某个 Reactor 线程中的 select 上,接着在这个线程内完成介入认证、IP 黑白名单过滤、握手等操作。
- 3、主 Reactor 线程池的 Reactor 线程完成某个连接的所有操作后意味着客户端已和服务端完成连接建立,此时其会将对应的 SocketChannel 从其所在的 select(多路复用器)上摘除,然后随机注册到从 Reactor 线程池中的某个线程的 select 上,从 Reactor 线程池中的对应线程将负责该连接的 IO 读写事件处理。
- 4、当从 Reactor 线程监听到某个 channel 有读写事件到达后,会将通道内的数据读取出,然后将其交给 Worker 线程池进行具体的业务处理。
- 5、Worker 线程完成业务处理后,将结果交给从 Reactor 线程,其负责将结果写入对应 channel。
此处应该再画个工作流程图,嗯,有时间再画!
故,主从 Reactor 多线程模型,通过线程分层来提高工作专注度,使其各司其职,再引入线程池来提高并发度,再加上其本身基于 IO 多路复用机制,以此使得其具备高性能、高并发和低延迟等特点,成为网络编程界的翘楚。
6.3 netty 线程模型模型
如上图所示,为 netty 线程模型(服务端)。其基于 Reactor 线程模型第三种实现 主从 Reactor 多线程改进。其 EventLoopGroup 意为事件循环组,本质为线程池,其持有 EventLoop 数组,每个 EventLoop 持有一个 Executor 对象,用来执行具体任务。客户端只有 WorkerGroup。下文会详细讲解相关核心组件。
netty 服务端一般会创建两个 EvenLoopGroup,即上图中的 BossGroup 和 WorkerGroup(此处命名随意),其中 BossGroup 相当于主 Reactor 线程池,主要负责接收客户端的连接;WorkerGroup 相当于从 Reactor 线程池,主要负责读写事件的处理。与主从 Reactor 多线程模型相比,netty 的实现看似少了 Worker-thread-pool 即负责处理业务的工作线程池,实际上 netty 会将业务处理以任务的方式提交到 WorkerGroup 的 tasQueue 队列中,由 WorkerGroup 线程池执行,即 WorkerGroup 同时担负了 从 Reactor 和 Worker-thread-pool 的责任。同时,netty 通过支持设置 EventLoopGroup 线程池中线程的数量来同时支持 Reactor 线程模型的三种经典实现。
netty 线程模型的工作流程大致如下:
- 1、服务端启动时,首先先创建 NioServerSocketChannel(内置了 java nio ServerSocketChannel 对象,即对其进行了扩展),然后将其随机注册到 BossGroup 中的某个 EventLoop 的 select 上,然后启动该 EventLoop。
- 2、EventLoop 会循环执行 step-0 -> step-1 -> step-2 流程,即监听 NioServerSocketChannel 通道是否有 accept 事件,若有则执行 processSelectedKeys 处理 accept 事件,为客户端连接创建 NioSocketChannel(内置了 java nio SocketChannel 对象)对象,在此过程中可能会产生任务并将其提交到 BossGroup 对应的 tasQueue 中,接着执行 runAllTasks 执行相关任务,最后将客户端的连接通道 NioSocketChannel 注册到 WorkerGroup 中的某个 EventLoop 的 select 上。
- 3、WorkerGroup 中的 EventLoop 也会循环执行 step-0 -> step-1 -> step-2,即监听其所有的 select 上注册的通道是否有读写事件到达,若有责执行 processSelectedKeys 处理读写事件,在此过程中可能会产生任务(如业务处理,用户也可主动向其提交任务),然后执行 runAllsTasks 执行任务。
- 4、netty 定义了 ChannelHandler 用来处理事件,并将其以双向链表的形式维护在 ChannelPipeline 中,即每个事件处理时都会经过该链表中的 handler 的处理,ChannelHandler 是 netty 提供的业务处理的核心接口,所有业务相关的处理都应该在该接口中实现。
7 netty 核心组件
如上图所示,为 netty 核心组件关系图。其中包括 ServerBootstrap、Bootstrap、EventLoopGroup、EventLoop、Channel、ChannelPipeline、ChannelHandler、ChannelHandlerContext、ChannelFuture、Codec 和 ByteBuf 等核心组件,同时,由于 netty 底层依赖于 java nio,所以也包括了 Selector 和 java.nio.channels.Channel 等组件。其核心组件各功能如下:
- 网络通信层:
- ServerBootstrap & Bootstrap:引导类或启动类,用于配制 netty 并启动,如配置线程模型、通道类型、处理器、绑定端口号、连接服务端等。其中 ServerBootstrap 用于服务端,Bootstrap 用于客户端。
- Channel:通道,表示一个可以从应用程序连接到 IO 设备(如磁盘文件、socket 等)的通道,负责在应用程序和 IO 设备之间传输数据。netty 中的 Channel 和 java nio 中的 Channel 有所区别,netty 的 Channel 内置了 java Channel,并提供了更多的功能。
- 事件调度层:
- EventLoopGroup:事件循环组,本质上是一个线程池,用来处理 IO 事件或任务。其持有一个 EventLoop 数组。
- EventLoop:事件循环,相当于线程池中的线程。持有一个 Executor 对象,以单线程的方式循环处理 IO 事件、任务等;持有一个 java.nio.channels.Selector 对象,用来监听注册再其上的 java.nio.channels.Channel;持有一个 taskQueue 对象,用来存储一些临时的非 IO 任务,如 registe、bind和业务任务。
- 服务编排层:
- ChannelHandler:通道处理器,是 netty 开发中与业务紧密相关的核心接口。主要用于数据入站和出站的解码、编码、处理;响应各种事件,如连接、数据接收、数据转换和异常等。
- ChannelPipeline:通道管道,一个 Channel 持有一个通道管道对象。其持有一个由 ChannelHandlerContext(ChannelHandlerContext 持有一个 ChannelHandler 对象) 组成的双向链表,提供以链式方式处理事件、数据的功能。因为一个事件或数据可能会被处理多次,在不同的阶段可能需要触发不同的操作,所以事件或数据会被以链式处理的方式处理,即当事件或数据被当前 ChannelHandler 处理后会交给下一个处理器处理。
- ChannelHandlerContext:通道处理器上下文,持有一个 ChannelHandler 对象,同时维护了当前处理器所在的上下文环境,如所属的 ChannelPipeline、当前所处理的 Channel 等信息,且可以将事件或数据从当前 handler 节点传递到下一个节点,也可以传递到指定节点。
- 辅助组件:
- ChannelFuture:通道监听器。netty 中的 IO 操作都是异步的,或者说 netty 从设计上就以异步为出发点,所以并不能立刻得到某个操作的结果。该监听器的作用就是当某个操作执行结束(成功或失败)后,以事件监听的方式返回执行结果。
- Codec:编码解码器,用于处理数据的编码和解码,即将字节数据转换为应用程序可识别的格式,或将应用程序数据转换为字节数据。
- ByteBuf:netty 框架提供实现的字节缓冲区,其本质上是一块可以写入/读取数据的内存(分为堆内存和堆外内存)。主要与通道进行交互,即数据从缓冲区写入通道,从通道读取到缓冲区。其是 IO 数据的载体,即作为数据在 IO 设备 与 NIO 程序之间传输的中介。java.nio.ByteBuffer 是 java nio 实现的字节缓冲区,二者有所不同。
- 依赖组件:
- java.nio.channels.Selector:java nio 包提供的多路复用器接口,详见 4.1.3 和 4.2.5 章节。
- java.nio.channels.Channel:java nio 包提供的通道接口,详见 4.1.2 和 4.2. 3 章节。
7.1 ServerBootstrap & BootStrap
引导类或启动类,用于配制 netty 并启动,如配置线程模型、通道类型、处理器、绑定端口号、连接服务端等。其中 ServerBootstrap 用于服务端,Bootstrap 用于客户端。二者都继承自 AbstractBootstrap 抽象类。
核心属性如下:
- AbstractBootstrap:
- EventLoopGroup group:在服务端用来处理 IO 连接事件,在客户端用来处理 IO 读写事件。
- ChannelHandler handler:EventLoopGroup 所处理事件的处理器。注:在服务端时为 BossGroup 所处理事件的处理器。
- Map<ChannelOption<?>, Object> options:EventLoopGroup 配置。注:在服务端时为 BossGroup 配置。
- ChannelFactory<? extends Channel> channelFactory:用于创建 Channel 示例的工厂。其创建的 Channel 的最终类型由 channel() 方法决定,一般服务端指定为 NioServerSocketChannel,客户端指定为 NioSocketChannel。
- SocketAddress localAddress:本地 socket 地址。
- ServerBootstrap:
- EventLoopGroup childGroup:用来处理 IO 读写事件。
- ChannelHandler childHandler:childGroup(即 WorkerGroup)所处理事件的处理器。
- Map<ChannelOption<?>, Object> childOptions:childGroup(即 WorkerGroup)的配置。
- Bootstrap:
- SocketAddress remoteAddress:socket 远程服务器地址。
核心方法如下:
-
AbstractBootstrap:
// 设置属性 group public B group(EventLoopGroup group) {...} // 设置当前应用程序的通道类型(内部会将其转换为 ChannelFactory 工厂类示例) public B channel(Class<? extends C> channelClass) {...} // 设置 group 的配置 public <T> B option(ChannelOption<T> option, T value) {...} // 设置 group 所对应事件处理器 public B handler(ChannelHandler handler) {...} // 为当前 socket 应用程序绑定(分配)一个本地端口号(服务端掉用) public ChannelFuture bind(int inetPort) {...}
-
ServerBootstrap:
// 设置服务端的 BossGroup 和 WorkerGroup(即 parentGroup 和 childGroup) public ServerBootstrap group(EventLoopGroup parentGroup, EventLoopGroup childGroup) {...} // 设置 childGroup 的配置 public <T> ServerBootstrap childOption(ChannelOption<T> childOption, T value) {...} // 设置 childGroup 所对应事件处理器 public ServerBootstrap childHandler(ChannelHandler childHandler) {...}
-
Bootstrap:
// 连接服务端 入参为服务端 ip 和 port(即服务端通过 bind() 方法指定的端口号) public ChannelFuture connect(String inetHost, int inetPort) {...}
ChannelOption 各属性含义:(以下只介绍了部分属性,其它还请自行度娘)
- 1、SO_BROADCAST:socket 参数,是否允许发送广播数据报。只有数据报套接字支持广播,并且还必须是在支持广播消息的网络上(如以太网、令牌环网等)。netty 默认关闭。
- 2、SO_KEEPALIVE:socket 参数,是否发送心跳探测报。开启后,默认情况下若两小时内没有数据通信,则 tcp 会自动发送一个活动探测数据报,以检查连接是否正常。
- 3、SO_SNDBUF:socket 参数,发送缓冲区大小。
- 4、SO_RCVBUF:socket 参数,接收缓冲区大小。
- 5、SO_REUSEADDR:socket 参数,是否允许重复使用本地地址和端口。即开启后,socket 可以其它进程共同使用某个地址或端口,较常用。
- 6、SO_LINGER:netty 对 socket 参数 SO_LINGER 的简单封装,表示关闭 socket 的延迟。默认值为 -1,值 < 0 时,在调用 socket.close() 方法后会立即返回,但操作系统会将缓冲区未发送的数据全部发送到对端。值为 0 时表示 socket.close() 方法立即返回,操作系统放弃未发送数据,并直接向对端发送 RST 包,对端收到复位错误。值 > 0 表示 socket 延迟关闭,即表示调用 socket.close() 方法的线程会被阻塞,直到延迟时间超时或待发送数据发送完毕;若超时,则对端收到复位错误。
- 7、SO_BACKLOG:socket 参数,表示服务端接收客户端连接的队列长度,若队列已满,则客户端连接将被拒绝。默认值:win 为 200,其它为 128。
- 8、SO_TIMEOUT:表示 http 连接成功后,等待读写数据的最大时间,单位为毫秒,若设置为 0 则表示永不超时。
- 9、TCP_NODELAY:TCP 参数,表示是否立即发送数据。该设置控制 Nagle 算法的启用,该算法将小碎片的数据连接成更大的报文发送,以此来减少报文发送的数量。若需要发送小数据或保证数据的实时性则需要开启该设置。默认值为 true(操作系统默认 false)。
- 10、IP_TOS:IP 参数,用于设置 IP 头部的 Type-of-Service 字段,该字段描述 IP 包的优先级和 QoS 选项。
7.2 EventLoopGroup
事件循环组,本质上是一个线程池,对外提供管理 EventLoop、获取线程、注册 channel 等能力。其持有一个 EventLoop 数组,该数组即可理解为池中的线程。
EventLooPGroup 创建时 EventLoop 数组大小默认为 cpu 和数的两倍。一般情况下服务端的 ServerBootstrap 需要两个 EventLoopGroup,客户端的 Bootstrap 只需一个。
核心属性如下:
- EventExecutor[] children:即 EventLoop 数组(ExentExecutor 为 EventLoop 父接口)。
核心方法如下:
EventLoop next(); // 从池中获取 EventLoop
ChannelFuture register(Channel channel); // 将指定 Channel 随机注册到池中某个 EventLoop 的 select 上
Future<?> shutdownGracefully(); // 优雅地停止
7.3 EventLoop
事件循环,相当于线程池中的线程。持有一个 Executor 对象和一个 Thread 对象,以单线程的方式循环处理 IO 事件、任务等;持有一个 java.nio.channels.Selector 对象,用来监听注册再其上的 java.nio.channels.Channel;持有一个 taskQueue 对象,用来存储一些临时的非 IO 任务,如 registe、bind和业务任务。
一个 EventLoop 在它的生命周期内只能与一个 Thread 绑定。EventLoop 将接收 Channel 在其持有的 Selector 对象上的注册,且可以注册多个,即相当于注册再其上的 Channel 所到达的所有事件都将由当前 EventLoop 处理。
核心属性如下:
- java.nio.channels.Selector selector:多路复用器,负责监听在其上注册的 java.nio.channels.Channel 是否有事件到达。
- Queue< Runnable> tastQueue:任务队列,用来存储一些临时的非 IO 任务,如 registe、bind和用户主动提交的业务任务。这些任务将由当前 EventLoop 统一执行。
核心方法如下:
EventLoopGroup parent(); // 获取其所在的 EventLoopGroup
ChannelFuture register(Channel channel); // 将指定 Channel 随机注册到当前 EventLoop 所持有的 select 上
void execute(Runnable command); // 向任务队列中提交任务
ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit); // 向任务队列中提交定时任务
7.4 Channel
通道,表示一个可以从应用程序连接到 IO 设备(如磁盘文件、socket 等)的通道,负责在应用程序和 IO 设备之间传输数据。netty 中的 Channel 和 java nio 中的 Channel 有所区别,netty 的 Channel 内置了 java Channel,并提供了更多的功能。
核心属性如下:
- SelectableChannel ch:其持有的 java nio 中对应的 channel 对象。服务端此对象为 java.nio.channels.ServerSocketChannel,客户端此对象为 SocketChannel。
- DefaultChannelPipeline pipeline:通道管道对象,用来处理当前通道所到达的事件。
- EventLoop eventLoop:当前通道所属的多路复用器(select)(EventLoop 持有了 select 对象)。
核心方法如下:(太多了 少写几个)
ChannelFuture bind(SocketAddress localAddress); // 绑定 socket 地址
ChannelFuture connect(SocketAddress remoteAddress); // 连接远程服务端
ChannelFuture writeAndFlush(Object msg); // 向通道写入数据
ChannelFuture close(); // 关闭通道
Channel 的状态:
- channelUnRegistered:通道已创建,但未被注册到 EventLoop 的 select 上。
- ChannelRegistered:通道已被注册到 EventLoop 的 select 上。
- channelInactive:通道不活跃,即通道未与对端连接。
- channelActive:通道活跃,即通道已与对端连接,此时可正常收发数据。
- 即通道会经历 已创建 -> 已注册 -> 未连接 -> 已连接 四个状态,已连接即可用状态。
Channel 的特点:
- 分层的:Channel 允许有一个父级,具体取决于创建方式。如 NioServerSocketChannel 接收客户端连接后将为该连接创建一个 NioSocketChannel,此时该 NioSocketChannel 的父级为 NioServerSocketChannel。层次结构的语义取决于 Channel 所属的 transport 实现。
- 异步的:即 channel 的所有 IO 操作都是异步的,需要用 ChannelFuture 来接收异步操作结果。
- 向下访问特定于传输的操作:通过将 Channel 向下转型来调用相关操作。嗯… 有待深入了解。
- 释放资源:Channel 完成后,需要通过 close() 方式释放资源。
Channel 的主要实现或扩展:
- ServerChannel & SocketChannel:socket(tcp/ip 协议) 服务端与客户端 channel,NioServerSocketChannel 和 NioSocketChannel 是其主要实现。
- Http2StreamChannel:支持 http/2 协议的 channel。
- ServerSctpChannel & SctpChannel:支持 sctp/ip 协议的服务端与客户端 channel。
- DatagramChannel:支持 udp/ip 协议的 channel。
- DuplexChannel:拥有两个端点并能在每个端点独立关闭的全双工 channel。
7.5 ChannelHandler
通道处理器,是 netty 开发中与业务紧密相关的核心接口。主要用于数据入站和出站的解码、编码、处理;响应各种事件,如连接、数据接收、数据转换和异常等。
ChannelHandler 有两个核心扩展接口,分别是 ChannelInboundHandler 和 ChannelOutboundHandler,它们分别声明了数据入站和出站时的处理方法;同时环提供了两个适配器 ChannelInboundHandlerAdapter 和 ChannelOutboundHandlerAdapter,二者分别实现了 ChannelInboundHandler 和 ChannelOutboundHandler 接口。在开发 netty 应用时则需要实现这两个接口或者扩展这两个适配器类。
其核心方法如下:
-
ChannelInboundHandler:
// 通道注册后调用 注册成功后会通过 ctx.fireChannelRegistered() 触发该方法的回调 void channelRegistered(ChannelHandlerContext ctx) throws Exception; // 通道创建后调用(即已创建未注册) 创建后通过 ctx.fireChannelUnregistered() 触发该方法的回调 void channelUnregistered(ChannelHandlerContext ctx) throws Exception; // 通道成功连接后调用 连接后通过 ctx.fireChannelActive() 触发该方法的回调 void channelActive(ChannelHandlerContext ctx) throws Exception; // 通道断开连接或不可用时调用 断开连接或不可用时会通过 ctx.fireChannelInactive() 触发该方法的回调 void channelInactive(ChannelHandlerContext ctx) throws Exception; // 缓冲区数据被读取后调用 读取后会通过 ctx.fireChannelRead() 触发该方法的回调 void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception; // 读取完成后调用 读取完成后会通过 ctx.fireChannelReadComplete() 触发该方法的回调 void channelReadComplete(ChannelHandlerContext ctx) throws Exception; // 用户事件触发后调用 触发后会通过 ctx.fireUserEventTriggered() 触发该方法的回调 void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception; // 通道的可写状态发生改变时调用 改变后会通过 ctx.fireChannelWritabilityChanged() 触发该方法的回调 void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception; // 发生异常时调用 发生异常时通过 ctx.fireExceptionCaught() 触发该方法的回调 void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception;
-
ChannelOutboundHandler:
// 通道绑定地址后调用 绑定后通过 ctx.bind() 触发该方法的回调 void bind(ChannelHandlerContext ctx, SocketAddress localAddress, ChannelPromise promise) throws Exception; // 通道连接对端后调用 连接后通过 ctx.connect() 触发该方法的回调 void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) throws Exception; // 通道断开连接后调用 断开连接后通过 ctx.distconnect() 触发该方法的回调 void disconnect(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception; // 通道关闭后调用 关闭后通过 ctx.close() 触发该方法的回调 void close(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception; // 通道取消当前注册后调用 取消注册后通过 ctx.deregister() 触发该方法的回调 void deregister(ChannelHandlerContext ctx, ChannelPromise promise) throws Exception; void read(ChannelHandlerContext ctx) throws Exception; // 读取后调用 通过 ctx.read() 触发该方法的回调 // 写入后调用 通过 ctx.write() 触发该方法的回调 void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception; void flush(ChannelHandlerContext ctx) throws Exception; // 执行 flush 后调用 通过 ctx.flush() 触发该方法的回调
7.6 ChannelHandlerContext
通道处理器上下文,持有一个 ChannelHandler 对象,同时维护了当前处理器所在的上下文环境,如所属的 ChannelPipeline、当前所处理的 Channel 等信息,定义了多种触发事件方法,可以将事件或数据从当前 handler 节点传递到下一个节点或指定节点。
ChannelHandlerContext 继承了 ChannelInboundInvoker 和 ChannelOutboundInvoker 接口,其各种触发方法继承自这两个接口。其核心方法如下:
// 以下方法来自于 ChannelInboundInvoker 接口
// 通道注册后 触发 ChannelInboundHandler.channelRegistered() 的回调
ChannelInboundInvoker fireChannelRegistered();
// 通道已创建未注册时 触发 ChannelInboundHandler.channelUnregistered() 的回调
ChannelInboundInvoker fireChannelUnregistered();
// 通道连接或可用后 触发 ChannelInboundHandler.channelActive() 的调用
ChannelInboundInvoker fireChannelActive();
// 通道断开连接或不可用后 触发 ChannelInboundHandler.channelInactive() 的调用
ChannelInboundInvoker fireChannelInactive();
// 发生异常时 触发 ChannelInboundHandler.exceptionCaught() 的调用
ChannelInboundInvoker fireExceptionCaught(Throwable cause);
// 用户事件触发后 触发 ChannelInboundHandler.userEventTriggered() 的调用
ChannelInboundInvoker fireUserEventTriggered(Object event);
// 读取后 触发 ChannelInboundHandler.channelRead() 的调用
ChannelInboundInvoker fireChannelRead(Object msg);
// 读取完成后 触发 ChannelInboundHandler.channelReadComplete() 的调用
ChannelInboundInvoker fireChannelReadComplete();
// 通道可写状态发生改变时 触发 ChannelInboundHandler.channelWritabilityChanged() 的调用
ChannelInboundInvoker fireChannelWritabilityChanged();
// 以下方法来自于 ChannelOutboundInvoker 接口
ChannelFuture bind(SocketAddress localAddress, ChannelPromise promise); // 通道绑定地址后 触发 ChannelOutboundHandler.bind() 的调用
// 通道连接后 触发 ChannelOutboundHandler.connect() 的调用
ChannelFuture connect(SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise);
ChannelFuture disconnect(ChannelPromise promise); // 通道断开连接后 触发 ChannelOutboundHandler.disconnect() 的调用
ChannelFuture close(ChannelPromise promise); // 通道关闭后 触发 ChannelOutboundHandler.close() 的调用
ChannelFuture deregister(ChannelPromise promise); // 通道取消注册后 触发 ChannelOutboundHandler.deregister() 的调用
ChannelOutboundInvoker read(); // 读取后 触发 ChannelOutboundHandler.read() 的调用
ChannelFuture write(Object msg, ChannelPromise promise); // 写入后 触发 ChannelOutboundHandler.write() 的调用
ChannelOutboundInvoker flush(); // 执行 flush() 后触发 ChannelOutboundHandler.flush() 的调用
7.7 ChannelPipeline
通道管道,一个 Channel 持有一个通道管道对象。其持有一个由 ChannelHandlerContext(ChannelHandlerContext 持有一个 ChannelHandler 对象) 组成的双向链表,提供以链式方式处理事件、数据的功能。因为一个事件或数据可能会被处理多次,在不同的阶段可能需要触发不同的操作,所以事件或数据会被以链式处理的方式处理,即当事件或数据被当前 ChannelHandler 处理后会交给下一个处理器处理。
处理器 ChannelHandler 实际上分为两种,即入站处理器和出站处理器,入站处理器执行时将按照其假如链表的先后顺序执行,出站处理器的执行顺序与其加入链表的顺序相反。如有入站处理器 A、B、C,加入链表顺序为 A -> B -> C,则它们被调用的顺序也为 A -> B -> C;出站处理器则与其相反。
其核心属性如下:
- HeadContext head:链表头节点,HeadContext 为内部类,且为 ChannelHandlerContext 子类。
- TailContext tail:链表尾节点,TailContext 为内部类,且为 ChannelHandlerContext 子类。
其核心方法如下:
ChannelPipeline addFirst(String name, ChannelHandler handler); // 在链表头部添加一个处理器
ChannelPipeline addFirst(EventExecutorGroup group, String name, ChannelHandler handler); // 在链表头部添加一个处理器并指定调用该处理的组
ChannelPipeline addLast(String name, ChannelHandler handler); // 在链表尾部添加一个处理器
ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler); // 在链表尾部添加一个处理器并指定调用该处理的组
ChannelPipeline addBefore(String baseName, String name, ChannelHandler handler); // 在指定处理器(baseName)前添加一个处理器
ChannelPipeline addAfter(String baseName, String name, ChannelHandler handler); // 在指定处理器(baseName)后添加一个处理器
ChannelHandler remove(String name); // 根据名称移除处理器
ChannelPipeline remove(ChannelHandler handler); // 移除处理器
ChannelHandler replace(String oldName, String newName, ChannelHandler newHandler); // 替换处理器
7.8 ChannelFuture
通道监听器。netty 中的 IO 操作都是异步的,或者说 netty 从设计上就以异步为出发点,所以并不能立刻得到某个操作的结果。该监听器的作用就是当某个操作执行结束(成功或失败)后,以事件监听的方式返回执行结果。
其核心方法为 addListener(),即添加监听器。添加的监听器在监听到执行结束后(成功或失败)返回执行结果。
7.9 Codec
编码解码器,用于处理数据的编码和解码,即将原始字节数据与自定义数据对象进行互转。数据在网络中都是以字节的形式传输,当数据到达服务端或客户端后则需要将其解码成自定义数据对象,同理,数据从服务端或客户端传输到网络前则需要将其编码成字节数据。
编解码器由两部分组成,即 Decoder(解码器)和 Encoder(编码器),解码器负责入站数据,编码器负责出站数据。
netty 提供了多种数据转换的基类供开发者扩展,基类如下:
- 编码器:
- MessageToByteEncoder:消息转换成字节数据。
- MessageToMessageEncoder:消息转换成消息。
- 解码器:
- ByteToMessageDecoder:字节数据转换成消息。
- MessageToMessageDecoder:消息转换成消息。
- ReplayingDecoder:特殊的 ByteToMessageDecoder,不再需要调用readableBytes()方法,其通过自定义的ReplayingDecoderBuffer来实现解码。
- 编解码器:
- ByteToMessageCodec:字节数据与消息数据互转。
- MessageToMessageCodec:消息与消息互转。
- 其它编解码器:
- CombinedChannelDuplexHandler:一种特殊的处理器,但可以通过其来实现 Codec 的功能。Codec 可能对重用性有影响,此时则可使用 CombinedChannelDuplexHandler 来分别扩展编码器和解码器,从而避免直接扩展编解码器抽象类。
7.10 ByteBuf
netty 框架提供实现的字节缓冲区,其本质上是一块可以写入/读取数据的内存(分为堆内存和堆外内存)。主要与通道进行交互,即数据从缓冲区写入通道,从通道读取到缓冲区。其是 IO 数据的载体,即作为数据在 IO 设备 与 NIO 程序之间传输的中介。java.nio.ByteBuffer 是 java nio 实现的字节缓冲区,二者有所不同。
ByteBuffer 只有一个指针用于处理读写等操作,每次读写时需要额外调用 flip() 和 clear() 方法,否则将出错(有关 ByteBuffer 请移步 4.2.1 章节);同时,ByteBuffer 初始化后容量固定(ByteBuf 初始化后容量可扩展)。故 netty 设计了自己的缓冲区数据结构 ByteBuf。
ByteBuf 只用两个指针 writeIndex 和 readIndex 来处理读写等操作。初始化时二者都为 0,随着数据的写入 writeIndex 增加,读取时 readIndex 增加,但不会超过 writeIndex。读取之后 0 ~ readIndex 这部分数据被视为 discard,调用 discardReadBytes() 方法即可释放这部分空间。discardReadBytes() 方法类似于 ByteBuffer 的 compact() 方法,移除无用数据,实现对缓冲区的重复使用。同时,ByteBuf 还提供了查找、复制、与 ByteBuffer 相互转换等功能。
在其它设计上,如堆缓冲区、直接缓冲区等和 ByteBuffer 保持一致。
8 代码示例
这里以一个简单的聊天系统为原型展示 netty 的简单使用。
8.1 服务端核心代码
public class NettyServer {
private static final Log logger = LogFactory.getLog(NettyServer.class);
private static volatile NettyServer INSTANCE;
private final int port;
private NettyServer(int port) {
this.port = port;
}
public static NettyServer getInstance(int port) {
if (INSTANCE == null) {
synchronized (NettyServer.class) {
if (INSTANCE == null) {
INSTANCE = new NettyServer(port);
}
}
}
return INSTANCE;
}
public void start() {
ServerBootstrap bootstrap = new ServerBootstrap();
EventLoopGroup selectGroup = new NioEventLoopGroup(); // 此时会创建 NioEventLoop 同时初始化 Selector
EventLoopGroup readWriteGroup = new NioEventLoopGroup();
bootstrap.group(selectGroup, readWriteGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline()
.addLast("encoder", new StringEncoder())
.addLast("decoder", new StringDecoder())
.addLast(new NettyServerChannelHandler());
}
});
ChannelFuture future;
try {
// 绑定地址(初始化 ServerSocketChannel、启动 select 轮询、注册 ServerSocketChannel)
future = bootstrap.bind(this.port).sync();
future.channel().closeFuture().sync();
} catch (Exception e) {
logger.error("Failed to start netty server!", e);
} finally {
selectGroup.shutdownGracefully();
readWriteGroup.shutdownGracefully();
}
}
}
8.2 服务端处理器
public class NettyServerChannelHandler extends ChannelInboundHandlerAdapter {
// ChannelGroup 是 netty 提供的通道组组件 并提供了批量发送消息等功能
private static final ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);
@Override
public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
Channel channel = ctx.channel();
channels.add(channel);
channels.writeAndFlush("客户端: " + channel.remoteAddress() + " 加入了群聊");
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) throws Exception {
channels.writeAndFlush("客户端: " + ctx.channel().remoteAddress() + " 退出了群聊");
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
channels.writeAndFlush("客户端: " + ctx.channel().remoteAddress() + " 上线");
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
channels.writeAndFlush("客户端: " + ctx.channel().remoteAddress() + " 下线");
}
@Override // 将接收到的消息转发到除当前客户端的其它客户端
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
for (Channel channel : channels) {
if (channel.id().equals(ctx.channel().id())) {
continue;
}
channel.writeAndFlush(msg);
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
ctx.close();
}
8.3 服务端启动
@Component // 这里以 spring boot 为背景 spring boot 启动完成后触发 ApplicationStartedEvent 事件
public class NettyServerStartup implements ApplicationListener<ApplicationStartedEvent> {
private static final Log logger = LogFactory.getLog(NettyServerStartup.class);
@Override
public void onApplicationEvent(ApplicationStartedEvent event) {
logger.info("start initializing and starting netty server");
NettyServer instance = NettyServer.getInstance(8082);
instance.start();
}
}
8.4 客户端核心代码
public class NettyClient {
private static final Log logger = LogFactory.getLog(NettyClient.class);
private final String clientName;
private final String host;
private final int port;
private Channel channel;
public NettyClient(String clientName, String host, int port) {
this.clientName = clientName;
this.host = host;
this.port = port;
}
public void connect() {
Bootstrap bootstrap = new Bootstrap();
NioEventLoopGroup readWriteGroup = new NioEventLoopGroup();
bootstrap.group(readWriteGroup)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel socketChannel) throws Exception {
socketChannel.pipeline()
.addLast("encoder", new StringEncoder())
.addLast("decoder", new StringDecoder())
.addLast(new NettyClientChannelHandler());
}
});
ChannelFuture future;
try { // 连接服务端
future = bootstrap.connect(this.host, this.port).sync();
this.channel = future.channel();
this.write();
this.channel.closeFuture().sync();
} catch (Exception e) {
logger.error("Failed to connect to netty server!", e);
} finally {
readWriteGroup.shutdownGracefully(); // 卧槽
}
}
public void write() {
Scanner scanner = new Scanner(System.in);
String message;
while (scanner.hasNextLine()) {
message = scanner.nextLine();
if (!StringUtils.hasText(message)) {
continue;
}
message = this.clientName + ": " + message + "\n";
this.channel.writeAndFlush(message);
}
}
}
8.5 客户端处理器
public class NettyClientChannelHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
System.out.println(" momo " + msg);
}
}
8.6 客户端实例
public class ZedClient {
public static void main(String[] args) throws InterruptedException {
NettyClient client = new NettyClient("影流之主", "127.0.0.1", 8082);
client.connect();
}
}
public class FizzClient {
public static void main(String[] args) {
NettyClient client = new NettyClient("潮汐海灵", "127.0.0.1", 8082);
client.connect();
}
}
9 使用技巧
怎么说呢,叫使用技巧好像不太河狸,但又想不到一个合适的名字,故,暂且就先这样喊它吧。
9.1 发消息
netty 提供了四种发消息的方式,分别由 Channel 和 ChannelHandlerContext 接口提供。
- Channel:
- write(Object msg):该方法调用后会先将消息缓存到 Channel 的发送缓冲区,待下一次调用 flush() 时将消息发出。
- writeAndFlush(Object msg):等同于连续调用 write() 和 flush()。
- ChannelHandlerContext:
- write(Object msg):该方法调用后会先将消息缓存到 ChannelHandlerContext 的发送缓冲区,待下一次调用 flush() 时将消息发出。
- writeAndFlush(Object msg):等同于连续调用 write() 和 flush()。
需要注意的是,写操作可能会失败或被延迟,因此在发送消息时要进行异常处理或设置超时时间,另外,也可以使用 ChannelFuture 来监听操作结果。
9.2 心跳机制
netty 提供了以下几种方式来实现心跳机制:
-
IdleStateHandler:netty 内置的空闲状态检测处理器,其持有三个时间时间属性,分别表示:
- readerIdleTimeNano:表示多长时间没有读,就发送一个心跳检测包检测连接是否正常。
- writerIdleTimeNano:表示多长时间没有写,就发送一个心跳检测包检测连接是否正常。
- allIdelTimeNano:表示多长时间没有读写,就发送一个心跳检测包检测连接是否正常。
以上三种时间若超时则会触发 IdleStateEvent 事件,当用户事件触发后会调用处理器的 userEventTriggered() 方法,且 handlerRemoved() 的时候有时无法感知到连接断开,所以还是需要心跳包来检测连接是否正常。
public class IdleHandler extends ChannelDuplexHandler { @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent event) { if (event.state() == IdleState.READER_IDLE) { ctx.close(); } else if (event.state() == IdleState.WRITER_IDLE) { ctx.writeAndFlush(new PingMessage()); } } } } .addLast(new IdleStateHandler(60, 30, 0)) .addLast(new IdleHandler());
-
自定义心跳检测机制:可以通过自定义实现 ChannelHandler 来实现心跳检测,如通过定时任务或线程发送心跳包,或对对端进行连接状态检测等方式。(若是定时任务则可通过 ctx.channel().eventLoop().schedule() 将其提交到 EventLoop 的任务队列中进行执行)。
需要注意的是,为避免心跳机制导致网络负载过大,应根据具体业务场景选择合适的心跳机制和频率。
9.3 长连接
netty 中可以通过以下几种方式来实现长连接:
- 心跳机制:使用心跳机制在服务端和客户端之间定时 ping pong,以保持连接处于活动状态。若 ping 完之后对端没有 pong(此处可涉及多种机制,如连续 多少次 ping 完后对端没有 pong等等)则视连接已失效,即可重新建立连接。
- 断线重连机制:在网络不稳定的情况下,程序可能因此不能正常工作。故可以定期检查连接状态,在连接断开后重新建立连接。可以通过 ChannelFuture 和 ChannelFutureListener 来实现断线重连机制。
- 基于 http/1.1 协议的长连接:http/1.1 协议支持长连接,可在一个 tcp 连接上多次发送请求和响应。netty 中可以通过 HttpClientCodec 和 HttpObjectAggregator 处理器实现基于 http/1.1 协议的长连接。
- 基于 web socket 协议的长连接:web socket 协议支持长连接,可在一个 tcp 连接上双向实时通信。netty 中可以通过 WebSocketServerProtocolHandler 和 WebSocketClientProtocolHandler 处理器来实现基于 web socket 协议的长连接。
9.4 内存管理机制
netty 的内存管理机制主要是通过 ByteBuf 实现的,即通过自定义实现的缓冲区实现。与 java.nio,ByteBuffer 有着相似的功能,但比其要更方便和先进。
ByteBuf 的内存管理主要分为两种方式:
- 堆内存:以普通字节数组为基础,由 jvm 在 jvm 堆上进行分配和回收。这种方式适用于小数据,如文本、xml 等。
- 直接内存:直接使用操作系统内存,即由操作系统进行分配和回收。这种方式适用于大数据,如音视频、大图片等。
netty 将缓冲区分为三种类型:堆缓冲区、直接缓冲区、复合缓冲区,其会根据不同的使用场景和内存需求来决定使用那种缓冲区,从而提高内存利用率。
9.5 tcp 拆包/粘包处理
- 拆包:是指发送方发送一条完整的数据放入缓冲区后,接收方每次只读取到这条完整数据的一部分。
- 粘包:是指发送方发送的多条完整数据放入缓冲区后,接收方每次可能会读取到多条完整数据。
会产生拆包/粘包问题是因为接收方不知道消息之间的界限,不知道每次读取多少字节的数据才是一条完整的消息。可以使用 netty 内置的解码器来解决该问题。
- LineBasedFrameDecoder:换行分隔符解码器。即发送数据时以换行符为每个数据包之间的界限,读取时遍历 ByteBuf 以换行符为界限读取数据。
- DelimiterBasedFrameDecoder:自定义分隔符解码器。即一种特殊的 LineBasedFrameDecoder,可以自定义消息界限分隔符。
- FixedLengthFrameDecoder:固定长度解码器。即其会对消息按指定长度进行拆包。
9.6 大文件传输
netty 内置了 ChunkedWriteHandler 处理器来支持大文件传输。ChunkedWriteHandler 实际上是个编码器,其可以将大文件切分成多个小数据块(Chunk),然后把它们以 ChunkData 的形式写入通道,这样就可以避免一次性将整个大文件读入内存,以降低内存占用率。其使用方法如下:
// 发送发
public void write(File file) throws Exception {
RandomAccessFile accessFile = new RandomAccessFile(file, "r");
DefaultFileRegion fileRegion = new DefaultFileRegion(accessFile.getChannel(), 0, accessFile.length());
DefaultFullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/");
HttpUtil.setContentLength(request, accessFile.length());
this.channel.write(request);
this.channel.writeAndFlush(new HttpChunkedInput(new ChunkedFile(accessFile, 0, file.length(), 8192)));
}
// 读取方
public class ChunkedHandler extends SimpleChannelInboundHandler<Object> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof HttpRequest) {
// 处理 http 请求
} else if (msg instanceof HttpContent content) {
// 处理 http content
if (content instanceof LastHttpContent) {
// 处理完整个 http 请求
} else if (content instanceof HttpChunkedInput chunkedInput) {
HttpContent chunk;
do {
chunk = chunkedInput.readChunk(ctx.alloc());
// 处理单个 chunk 数据
} while (chunk != null);
}
}
}
}
传输大文件时需要注意一下几点:
-
使用 ChunkedFile 时需要指定 Chunk 的大小,需根据实际情况选择合适大小,一般来说不建议超过 8KB。
-
为避免大文件传输对网络造成影响,可在服务端和客户端的 ChannelPipeline 中加入 WriteBufferWaterMark,限制写入缓冲区的大小。
socketChannel.config().setWriteBufferWaterMark(new WriteBufferWaterMark(8 * 1014, 32 * 1024));
9.7 SSL/TLS 加密传输
netty 内置了 SslHandler 来支持 SSL/TLS 加密传输。通常情况下,需要将 SslHandler 处理器添加在 ChannelPipeline 处理器链的最后一个位置。
SSLContext sslContext = SSLContext.getInstance("TLS");
KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(new FileInputStream("server.jks"), "password".toCharArray());
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
keyManagerFactory.init(keyStore, "password".toCharArray());
TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(keyStore);
sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
SSLEngine sslEngine = sslContext.createSSLEngine();
sslEngine.setUseClientMode(false);
// 根据构建好的 SSLEngine 创建 SslHandler 并将 handler 添加在 ChannelPipeline 中
socketChannel.pipeline().addLast("ssl", new SslHandler(sslEngine));
9.8 高可用和负载均衡
netty 本身并没有提供高可用与负载均衡相关的设计,但可以借助第三方组件来实现,如 Nginx、Zookeeper 等。
- 高可用:可将 netty 服务端部署多个节点,当其中某个节点宕机或相关服务器出现问题时,负载均衡器会会在将请求转发到可用的节点或服务器。
- 负载均衡:负载均衡是指将请求合理的分配到多个服务节点的过程。常见的负载均衡算法有轮询、随机、权重等,常用的负载均衡器有 Nginx、HAProxy 等。同时,可以借助 Zookeeper、Consul 等分布式组件来实现服务的发现、注册。
游客是你,风景是我。《稀客》-杨千嬅.mp3。