网络传输中数据通常以一种格式:字节。这些字节要怎样传播主要取决于我们指定的网络传输服务,帮助我们抽象底层的数据传输机制。用户不需要关心实现细节,他们只需要确信他们的字节能被可靠地发送和接收。
Netty为它所有的传输服务实现提供了通用的API,使你能很容易的从阻塞传输服务转换到非阻塞传输服务。
案例学习:传输服务迁移
我们以一个简单的应用案例开始我们的传输服务学习。这个应用接收一个连接,然后写入”Hi!”并返回给客户端,最后关闭连接。
使用OIO(阻塞的传输服务)和NIO(异步的传输服务)
我们会仅仅使用JDK的API来实现这个应用的OIO和NIO版本。
下面的代码实现了阻塞的版本。
这段代码足够处理中等数量的并发请求。但是如果这个应该大受欢迎,你会发现如果有成千上万的并发请求,你的应用就表现的不太好了,然后你决定转换到非阻塞(异步)的网络编程实现。但是你马上就会发现非阻塞的API和阻塞的API完全不同,因此你需要重写你的应用。
下面给出的非阻塞的版本实现:
正如你所看到的,尽管这些代码是处理同样的事情,但是确实完全不同。如果实现一个非阻塞的IO需要完全的重写,考虑下实现这么复杂的代码需要多大的工作量。
下面我们看看如何通过Netty实现OIO和NIO。
通过Netty实现OIO和NIO
首先实现阻塞版本:
接下来我们会通过Netty实现非阻塞的版本
Netty实现非阻塞的版本
下面的代码几乎上上一个版本的代码相同,除了两行代码(line5,9)。
这就是从OIO转换到NIO所有要做的事情。
因此Netty为所有传输服务的实现都提供了同一套API,无论你选哪一种传输服务,你的代码只要做出很少的修改。
下面更深入的研究一下传输服务API
传输服务API
传输服务API的核心就是Channel
接口,它为所有的IO操作服务。它的结构如下图所示:
如果所示,ChannelPipeline
和ChannelConfig
中都指定了Channel
实例。ChannelConfig
存储了Channel的所有配置同时支持热部署(hot change)。因为一个特定的传输服务可能会有一个唯一的配置,它可能实现了ChannelConfig
的子类。
因为Channel都是独立的,声明Channel
作为java.lang.Comparable
的子接口是为了保证有序性。因此,AbstractChannel
中compareTo()
的实现会抛出异常,如果两个不同的Channel
实例返回同样hash code。
ChannelPipeline
持有所有的ChannelHandler
实例,这些实例会为输入输出的数据和事件服务。
ChannelHandler
的典型使用场景有:
- 转换数据的格式
- 为异常提供通知
- 为Channel的激活(active)和失活(inactive)提供通知
- 为Channel从一个EventLoop中注册或撤销(deregister)提供通知
- 为用户自定义事件提供通知
拦截过滤器
ChannelPipeline
实现了一个通用的设计模式-拦截过滤器。UNIX的管道是另一个常见的例子:命令被链接在一起,一个命令的输出呗链上的下一个命令过滤
如果有需要,你可以通过添加或移除ChannelHandler
实例来修改一个正在运行的ChannelPipeline
。Netty能构造高灵活性(highly flexible)的应用。比如,你能通过简单的增加一个合适的ChannelHandler
(SslHandler)来支持所需的STARTTLS协议。
除了刚刚介绍的ChannelPipeline
和ChannelConfig
,你还能使用其他Channel
接口提供的方法,最重要方法如下表所示:
方法名 | 描述 |
eventLoop | 返回这个Channel的EventLoop |
pipeline | 返回这个Channel的ChannelPipeline |
isActive | 返回Channel是否是active的。active的定义可能依赖于底层的传输服务。比如,Socket传输服务认为一旦与远程主机建立连接即active,然后Datagram传输服务认为只要连接打开就算active |
localAddress | 返回本地的SocketAddress |
remoteAddress | 返回远程的SocketAddress |
write | 写数据到远程端,这个数据会被送到ChannelPipeline并进入队列直到被flush才发送出去 |
flush | 将刚才写入的数据刷到底层的传出服务(如:Socket)中 |
writeAndFlush | 一个方便的方法,首先调用write()然后flush() |
后面我们会详细讨论这些特性的使用,但现在只需要知道Netty通过几个接口就可以提供丰富的功能。
考虑写数据并flush到远端这个常见的任务。下面的代码展示了writeAndFlush()
方法的使用:
Netty的Channel
实现是线程安全的,所以你可以在多线程的环境下通过一个Channel
实例的引用来写数据到远端。下面的例子展示了再多线程的环境下将数据有序的写到远端:
提供的传输服务
Netty提供了几个可用的传输服务。因为并不是所有的传输服务都支持每一个传输协议,你得选择一个适合你应用中传输协议的传输服务。下表列出了Netty提供的传输服务
名称 | 包 | 描述 |
NIO | io.netty.channel.socket.nio | 基于java.nio.channels(基于selector的途径)包 |
Epoll | io.netty.channel.epoll | 使用JNI的epoll()和非阻塞IO,这个传输服务支持的一些特性在Linux上才有效,如SO_REUSEPORT。且比NIO传输服务和完全非阻塞都要快 |
OIO | io.netty.channel.socket.oio | 基于java.net包,使用阻塞流 |
Local | io.netty.channel.local | 一本地传输服务能用来通过pipe在VM中通信 |
Embedded | io.netty.channel.embedded | 一个嵌入式(embedded)传输服务,允许在没有真正基于网络传输服务的情况下使用ChannelHandler,这对于你测试ChannelHandler的实现很有用 |
NIO—非阻塞IO
NIO为所有的IO操作提供了完全异步的实现。它利用了基于selector的API。
selector作为一个注册器来告知你Channel状态的改变,可能的状态改变有:
- 一个新的Channel被接收且已准备就绪
- 一个Channel的连接已经被建立
- 一个Channel中有准备读取的数据
- 一个Channel可用于写数据
在应用对状态的改变做出反应后,selector被重置然后重复前面的处理,在另一个线程中检查状态的改变并做出相应的相应。
下表显示了java.nio.channels.SelectionKey
中定义的位模式常量,这些位模式常量能组成应用关心的通知集合。
名称 | 描述 |
OP_ACCEPT | 请求告知新连接被接收,一个Channel实例被创建 |
OP_CONNECT | 请求告知一个连接被建立 |
OP_READ | 请求告知当数据准备好从Channel中读 |
OP_WRITE | 请求告知当可以向Channel中写数据。这种情况出现在socket的缓存被填满的情况(当数据传输速度高于远端机器处理速度会发送) |
这些NIO的内部细节被用户级的API隐藏,这一点在Netty的传输服务实现中很常见。
下图显示了状态变换的处理流程
Zero-cpoy : Zero-copy是一个特殊属性,当前只能在NIO和Epoll传输服务中可使用,它允许你可以快速高效地移动你的数据从文件系统到网络中而不需要从你的内存空间复制到你的用户空间,这对于你提高协议中的传输性能是一个非常重要的特性,例如FTP和HTTP协议,但是这种特性并不是被所有的操作系统所支持的,具体来说,如果数据被加密或者压缩过就不能正常使用了,只有一些简单的原生的文件内容可以被传输
Epoll—Linux的native非阻塞传输服务
Netty的NIO传输服务是基于Java提供的对非阻塞网络编程的通用抽象来实现的。尽管这足以保证Netty的非阻塞的API可以在任何的平台的可用性,但这依旧会有一些限制,因为JDK为了能在所有的系统上有同样的可用性做了一些折。
因为linux的网络的高性能促使了很多一些特性的产生,包括epoll,一个高扩展性的IO事件通知的新特性。
Netty为Linux提供的NIO的API是使用的epoll的,通过这个方法我们可以与使用的linux保持一致,而不需要浪费一些性能。思考一下如果你的系统是linux,你的应用可以利用这个特性,你会发现在高负载的情况下这比JDK的NIO的实现更加高效。如果用epoll替换NIO,只需要将NioEventLoopGroup
用EpollEventLoopGroup
替换,NioServerSocketChannel
用EpollServerSocketChannel
替换就可以了。
OIO—旧的阻塞IO模型
从Netty的OIO传输服务实现代表了一种折中:它是通过通用传输服务API来实现的,但因为是建立在java.net
包的基础上的,所以不是异步的。这适用于一些特定的场合。
举例来说,你也许需要一些普通的代码来实现阻塞调用例如JDBC,如果你将其转化成非阻塞的也许并不是那么的实用,短期内你可以直接使用Netty的OIO传输服务,如果有需要你可以在未来的时间内将其转化成其他的任意一种异步传输,我们还是先看看阻塞通信是如何工作的吧。
在java.net包下的API,经常有一个线程接收新的连接到serverSocket的请求,一个新的socket即将被创建来与远程服务端进行交互,然后需要一个新的线程分配来处理后继的数据交互,多开一个新的线程是有必要的,因为在一个具体的socket上的任何IO操作都可能随时被阻断,如果用一个线程处理多个sockets很容易导致一个阻塞的操作也会影响其他的操作。
正是因为如此,你可能会有疑问为什么可以使用与非阻塞一样的API来支持OIO呢?因为Netty使用了SO_TIMEOUT这个socket的参数标识,它规定了一个I/O操作完成的最长等待的毫秒数,如果在内部规定的时间内操作并没有完成,那么一个SocketTimeOutException将会被抛出,Netty将会捕获这个异常,然后继续处理循环,在下一个EcentLoop运行的时候,它会再次尝试,这是像Netty这样的异步框架能支持OIO的唯一方式,上图说明了这个逻辑
在JVM中通过本地传输服务通信
Netty为运行在同一个虚拟机上的服务端和客户端提供了本地传输的异步通信。同样,这个传输服务支持的API与其他所Netty的传输服务实现相同。
这种传输方式,与服务端channel相连的SocketAddress并没有绑定物理地址,而是,只要服务器一运行就在注册器上存储注册,只要channel一关闭,就从注册器上解除注册,因为这种传输服务并没有真实的网络传输产生,它不能与其他的传输服务相互操作,因此,在同一个JVM上的客户端想要连接到服务器端的话,必须使用这种传输方式,除了这种限制,它就与其他的传输服务一样了
嵌入式传输服务
Netty还提供了一种额外的传输方式允许你将一些ChannelHandler作为一种辅助工具类嵌入到其他的ChannelHandler中,在这种方式下,我们可以在不修改内部代码的基础上扩展ChannelHandler的功能
传输服务用例
接下来让我们考虑如何为一个特定的用例选择传输协议。正如之前所说的,不是所有的传输服务都支持所有核心传输协议。下表显示了这种支持情况:
传输服务 | TCP | UDP | SCTP | UDT |
NIO | X | X | X | X |
Epoll(linux) | X | X | — | — |
OIO | X | X | X | X |
在Linux中启用SCTP
SCTP需要内核支持同时需要用户来安装
比如,Ubuntu系统你使用如下命令:
# sudo apt-get install libsctp1
Fedora系统:
# sudo yum install kernel-modules-extra.x86_64 lksctp-tools.x86_64
尽管只有SCTP协议需要这些特定的配置,但是一些其他的传输服务需要它们自己要考虑的配置选项。而且,如果你想支持更高的并发连接数,服务器的配置可能需要有客户端不同。
下面是你可能会遇到的一些用例:
- 非阻塞代码—如果在你的代码中没有阻塞调用(或者你可以限制),使用NIO或epoll(Linux系统上)是一个好主意。尽管NIO/epoll是用来处理高并发连接,但在低并发连接的情况下也能工作良好,特别是在多个连接中共享线程的情况。
- 阻塞代码—如果你的代码库严重依赖于阻塞的I/O,那么你的应用应该有一个相应的设计,如果你想把你的阻塞操作直接转化成Netty的NIO传输的时候,在且你不是用重写原有代码去完成转化的功能,你可能会遇到一些问题,例如你可以考虑一种迁移场景,你的应用一开始使用OIO,然后迁移到NIO,你需要重新修改你的代码
- 在同一个JVM下通信一同一个JVM上通信且不需要通过网络暴露你的服务的场景下,使用本地传输会是一个很棒的决定,这样可以在使用的你代码的基础上,消除所有的网络传输操作上的开销,如果你在以后的时间里想将你的服务暴露出去,你可以将其转化成NIO或者OIO
- 测试你的ChannelHandler实现一如果你想要为你的channelHandler写一个测试单元的话,你可以考虑使用嵌入式的传输方式,这个方式可以在不需要创造很多mock对象的基础上很轻易地测试你的代码,你可以测试再所有的事件流上的常用的API,且保证你的channelHandler在真实的传输服务上运行正确