本章主要内容
- Java网络编程
- Netty简介
- Netty的核心组件
假设你正在为一个重要的大型公司开发一款全新的任务关键型的应用程序。在第一次会议上,你得知该系统必须要能够扩展到支撑150 000名并发用户,并且不能有任何的性能损失,这时所有的目光都投向了你。你会怎么说呢?
如果你可以自信地说:“当然,没问题。”那么大家都会向你脱帽致敬。但是,我们大多数人可能会采取一个更加谨慎的立场,例如:“听上去是可行的。”然后,一回到计算机旁,我们便开始搜索“high performance Java networking”(高性能Java网络编程)。
如果你现在搜索它,在第一页结果中,你将会看到下面的内容:
Netty: Home
netty.io/
Netty是一款异步的事件驱动的网络应用程序框架,支持快速地开发可维护的高性能的面向协议的服务器和客户端。
如果你和大多数人一样,通过这样的方式发现了Netty,那么你的下一步多半是:浏览该网站,下载源代码,仔细阅读Javadoc和一些相关的博客,然后写点儿代码试试。如果你已经有了扎实的网络编程经验,那么可能进展还不错,不然则可能是一头雾水。
这是为什么呢?因为像我们例子中那样的高性能系统不仅要求超一流的编程技巧,还需要几个复杂领域(网络编程、多线程处理和并发)的专业知识。Netty优雅地处理了这些领域的知识,使得即使是网络编程新手也能使用。但到目前为止,由于还缺乏一本全面的指南,使得对它的学习过程比实际需要的艰涩得多——因此便有了这本书。
我们编写这本书的主要目的是:使得Netty能够尽可能多地被更加广泛的开发者采用。这也包括那些拥有创新的内容或者服务,却没有时间或者兴趣成为网络编程专家的人。如果这适用于你,我们相信你将会非常惊讶自己这么快便可以开始创建你的第一款基于Netty的应用程序了。当然在另一个层面上讲,我们也需要支持那些正在寻找工具来创建他们自己的网络协议的高级从业人员。
Netty确实提供了极为丰富的网络编程工具集,我们将花大部分的时间来探究它的能力。但是,Netty终究是一个框架,它的架构方法和设计原则是:每个小点都和它的技术性内容一样重要,穷其精妙。因此,我们也将探讨很多其他方面的内容,例如:
- 关注点分离——业务和网络逻辑解耦;
- 模块化和可复用性;
- 可测试性作为首要的要求。
在这第1章中,我们将从一些与高性能网络编程相关的背景知识开始铺陈,特别是它在Java开发工具包(JDK)中的实现。有了这些背景知识后,我们将介绍Netty,它的核心概念以及构建块。在本章结束之后,你就能够编写你的第一款基于Netty的客户端和服务器应用程序了。
1.1 Java网络编程
早期的网络编程开发人员,需要花费大量的时间去学习复杂的C语言套接字库,去处理它们在不同的操作系统上出现的古怪问题。虽然最早的Java(1995—2002)引入了足够多的面向对象façade(门面)来隐藏一些棘手的细节问题,但是创建一个复杂的客户端/服务器协议仍然需要大量的样板代码(以及相当多的底层研究才能使它整个流畅地运行起来)。
那些最早期的Java API(java.net
)只支持由本地系统套接字库提供的所谓的阻塞函数。代码清单1-1展示了一个使用了这些函数调用的服务器代码的普通示例。
代码清单1-1 阻塞I/O示例
ServerSocket serverSocket = new ServerSocket(portNumber); ← -- 创建一个新的ServerSocket,用以监听指定端口上的连接请求Socket clientSocket = serverSocket.accept; ← -- ❶ 对accept方法的调用将被阻塞,直到一个连接建立BufferedReader in = new BufferedReader( new InputStreamReader(clientSocket.getInputStream));PrintWriter out = new PrintWriter(clientSocket.getOutputStream, true); ← -- ❷ 这些流对象都派生于该套接字的流对象String request, response;while ((request = in.readLine) != null) { ← -- ❸ 处理循环开始 if ("Done".equals(request)) { break; ← -- 如果客户端发送了“Done”,则退出处理循环 } response = processRequest(request); ← -- ❹ 请求被传递给服务器的处理方法 out.println(response); ← -- 服务器的响应被发送给了客户端} ← -- 继续执行处理循环
代码清单1-1实现了Socket
API的基本模式之一。以下是最重要的几点。
ServerSocket
上的accept
方法将会一直阻塞到一个连接建立❶,随后返回一个新的Socket
用于客户端和服务器之间的通信。该ServerSocket
将继续监听传入的连接。BufferedReader
和PrintWriter
都衍生自Socket
的输入输出流❷。前者从一个字符输入流中读取文本,后者打印对象的格式化的表示到文本输出流。readLine
方法将会阻塞,直到在❸处一个由换行符或者回车符结尾的字符串被读取。- 客户端的请求已经被处理❹。
这段代码片段将只能同时处理一个连接,要管理多个并发客户端,需要为每个新的客户端Socket
创建一个新的Thread
,如图1-1所示。
图1-1 使用阻塞I/O处理多个连接
让我们考虑一下这种方案的影响。第一,在任何时候都可能有大量的线程处于休眠状态,只是等待输入或者输出数据就绪,这可能算是一种资源浪费。第二,需要为每个线程的调用栈都分配内存,其默认值大小区间为64 KB到1 MB,具体取决于操作系统。第三,即使Java虚拟机(JVM)在物理上可以支持非常大数量的线程,但是远在到达该极限之前,上下文切换所带来的开销就会带来麻烦,例如,在达到10 000个连接的时候。
虽然这种并发方案对于支撑中小数量的客户端来说还算可以接受,但是为了支撑100 000或者更多的并发连接所需要的资源使得它很不理想。幸运的是,还有一种方案。
1.1.1 Java NIO
除了代码清单1-1中代码底层的阻塞系统调用之外,本地套接字库很早就提供了非阻塞调用,其为网络资源的利用率提供了相当多的控制:
- 可以使用
setsockopt
方法配置套接字,以便读/写调用在没有数据的时候立即返回,也就是说,如果是一个阻塞调用应该已经被阻塞了[1]; - 可以使用操作系统的事件通知API[2]注册一组非阻塞套接字,以确定它们中是否有任何的套接字已经有数据可供读写。
Java对于非阻塞I/O的支持是在2002年引入的,位于JDK 1.4的java.nio
包中。
新的还是非阻塞的
NIO最开始是新的输入/输出(New Input/Output)的英文缩写,但是,该Java API已经出现足够长的时间了,不再是“新的”了,因此,如今大多数的用户认为NIO代表非阻塞I/O(Non-blocking I/O),而阻塞I/O(blocking I/O)是旧的输入/输出(old input/output,OIO)。你也可能遇到它被称为普通I/O(plain I/O)的时候。
1.1.2 选择器
图1-2展示了一个非阻塞设计,其实际上消除了上一节中所描述的那些弊端。
图1-2 使用Selector
的非阻塞I/O
class java.nio.channels.Selector
是Java的非阻塞I/O实现的关键。它使用了事件通知API以确定在一组非阻塞套接字中有哪些已经就绪能够进行I/O相关的操作。因为可以在任何的时间检查任意的读操作或者写操作的完成状态,所以如图1-2所示,一个单一的线程便可以处理多个并发的连接。
总体来看,与阻塞I/O模型相比,这种模型提供了更好的资源管理:
- 使用较少的线程便可以处理许多连接,因此也减少了内存管理和上下文切换所带来开销;
- 当没有I/O操作需要处理的时候,线程也可以被用于其他任务。
尽管已经有许多直接使用Java NIO API的应用程序被构建了,但是要做到如此正确和安全并不容易。特别是,在高负载下可靠和高效地处理和调度I/O操作是一项繁琐而且容易出错的任务,最好留给高性能的网络编程专家——Netty。
1.2 Netty简介
不久以前,我们在本章一开始所呈现的场景——支持成千上万的并发客户端——还被认定为是不可能的。然而今天,作为系统用户,我们将这种能力视为理所当然;同时作为开发人员,我们期望将水平线提得更高[3]。因为我们知道,总会有更高的吞吐量和可扩展性的要求——在更低的成本的基础上进行交付。
不要低估了这最后一点的重要性。我们已经从漫长的痛苦经历中学到:直接使用底层的API暴露了复杂性,并且引入了对往往供不应求的技能的关键性依赖[4]。这也就是,面向对象的基本概念:用较简单的抽象隐藏底层实现的复杂性。
这一原则也催生了大量框架的开发,它们为常见的编程任务封装了解决方案,其中的许多都和分布式系统的开发密切相关。我们可以确定地说:所有专业的Java开发人员都至少对它们熟知一二。[5]对于我们许多人来说,它们已经变得不可或缺,因为它们既能满足我们的技术需求,又能满足我们的时间表。
在网络编程领域,Netty是Java的卓越框架。[6]它驾驭了Java高级API的能力,并将其隐藏在一个易于使用的API之后。Netty使你可以专注于自己真正感兴趣的——你的应用程序的独一无二的价值。
在我们开始首次深入地了解Netty之前,请仔细审视表1-1中所总结的关键特性。有些是技术性的,而其他的更多的则是关于架构或设计哲学的。在本书的学习过程中,我们将不止一次地重新审视它们。
表1-1 Netty的特性总结
分 类
Netty的特性
设计
统一的API,支持多种传输类型,阻塞的和非阻塞的
简单而强大的线程模型
真正的无连接数据报套接字支持
链接逻辑组件以支持复用
易于使用
详实的Javadoc和大量的示例集
不需要超过JDK 1.6+[7]的依赖。(一些可选的特性可能需要Java 1.7+和/或额外的依赖)
性能
拥有比Java的核心API更高的吞吐量以及更低的延迟
得益于池化和复用,拥有更低的资源消耗
最少的内存复制
健壮性
不会因为慢速、快速或者超载的连接而导致OutOfMemoryError
消除在高速网络中NIO应用程序常见的不公平读/写比率
安全性
完整的SSL/TLS以及StartTLS支持
可用于受限环境下,如Applet和OSGI
社区驱动
发布快速而且频繁
1.2.1 谁在使用Netty
Netty拥有一个充满活力并且不断壮大的用户社区,其中不乏大型公司,如Apple、Twitter、Facebook、Google、Square和Instagram,还有流行的开源项目,如Infinispan、HornetQ、Vert.x、Apache Cassandra和Elasticsearch[8],它们所有的核心代码都利用了Netty强大的网络抽象[9]。在初创企业中,Firebase和Urban Airship也在使用Netty,前者用来做HTTP长连接,而后者用来支持各种各样的推送通知。
每当你使用Twitter,你便是在使用Finagle[10],它们基于Netty的系统间通信框架。Facebook在Nifty中使用了Netty,它们的Apache Thrift服务。可伸缩性和性能对这两家公司来说至关重要,他们也经常为Netty贡献代码[11]。
反过来,Netty也已从这些项目中受益,通过实现FTP、SMTP、HTTP和WebSocket以及其他的基于二进制和基于文本的协议,Netty扩展了它的应用范围及灵活性。
1.2.2 异步和事件驱动
因为我们要大量地使用“异步”这个词,所以现在是一个澄清上下文的好时机。异步(也就是非同步)事件肯定大家都熟悉。考虑一下电子邮件:你可能会也可能不会收到你已经发出去的电子邮件对应的回复,或者你也可能会在正在发送一封电子邮件的时候收到一个意外的消息。异步事件也可以具有某种有序的关系。通常,你只有在已经问了一个问题之后才会得到一个和它对应的答案,而在你等待它的同时你也可以做点别的事情。
在日常的生活中,异步自然而然地就发生了,所以你可能没有对它考虑过多少。但是让一个计算机程序以相同的方式工作就会产生一些非常特殊的问题。本质上,一个既是异步的又是事件驱动的系统会表现出一种特殊的、对我们来说极具价值的行为:它可以以任意的顺序响应在任意的时间点产生的事件。
这种能力对于实现最高级别的可伸缩性至关重要,定义为:“一种系统、网络或者进程在需要处理的工作不断增长时,可以通过某种可行的方式或者扩大它的处理能力来适应这种增长的能力。”[12]
异步和可伸缩性之间的联系又是什么呢?
- 非阻塞网络调用使得我们可以不必等待一个操作的完成。完全异步的I/O正是基于这个特性构建的,并且更进一步:异步方法会立即返回,并且在它完成时,会直接或者在稍后的某个时间点通知用户。
- 选择器使得我们能够通过较少的线程便可监视许多连接上的事件。
将这些元素结合在一起,与使用阻塞I/O来处理大量事件相比,使用非阻塞I/O来处理更快速、更经济。从网络编程的角度来看,这是构建我们理想系统的关键,而且你会看到,这也是Netty的设计底蕴的关键。
在1.3节中,我们将首先看一看Netty的核心组件。现在,只需要将它们看作是域对象,而不是具体的Java类。随着时间的推移,我们将看到它们是如何协作,来为在网络上发生的事件提供通知,并使得它们可以被处理的。
1.3 Netty的核心组件
在本节中我将要讨论Netty的主要构件块:
Channel
;- 回调;
Future
;- 事件和
ChannelHandler
。
这些构建块代表了不同类型的构造:资源、逻辑以及通知。你的应用程序将使用它们来访问网络以及流经网络的数据。
对于每个组件来说,我们都将提供一个基本的定义,并且在适当的情况下,还会提供一个简单的示例代码来说明它的用法。
1.3.1 Channel
Channel是Java NIO的一个基本构造。
它代表一个到实体(如一个硬件设备、一个文件、一个网络套接字或者一个能够执行一个或者多个不同的I/O操作的程序组件)的开放连接,如读操作和写操作[13]。
目前,可以把Channel
看作是传入(入站)或者传出(出站)数据的载体。因此,它可以被打开或者被关闭,连接或者断开连接。
1.3.2 回调
一个回调其实就是一个方法,一个指向已经被提供给另外一个方法的方法的引用。这使得后者[14]可以在适当的时候调用前者。回调在广泛的编程场景中都有应用,而且也是在操作完成后通知相关方最常见的方式之一。
Netty在内部使用了回调来处理事件;当一个回调被触发时,相关的事件可以被一个interface-ChannelHandler
的实现处理。代码清单1-2展示了一个例子:当一个新的连接已经被建立时,ChannelHandler
的channelActive
回调方法将会被调用,并将打印出一条信息。
代码清单1-2 被回调触发的ChannelHandler
public class ConnectHandler extends ChannelInboundHandlerAdapter { @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { ← -- 当一个新的连接已经被建立时,channelActive(ChannelHandlerContext)将会被调用 System.out.println( "Client " + ctx.channel.remoteAddress + " connected"); }}
1.3.3 Future
Future
提供了另一种在操作完成时通知应用程序的方式。这个对象可以看作是一个异步操作的结果的占位符;它将在未来的某个时刻完成,并提供对其结果的访问。
JDK预置了interface java.util.concurrent.Future
,但是其所提供的实现,只允许手动检查对应的操作是否已经完成,或者一直阻塞直到它完成。这是非常繁琐的,所以Netty提供了它自己的实现——ChannelFuture
,用于在执行异步操作的时候使用。
ChannelFuture
提供了几种额外的方法,这些方法使得我们能够注册一个或者多个ChannelFutureListener
实例。监听器的回调方法operationComplete
,将会在对应的操作完成时被调用[15]。然后监听器可以判断该操作是成功地完成了还是出错了。如果是后者,我们可以检索产生的Throwable
。简而言之,由ChannelFutureListener
提供的通知机制消除了手动检查对应的操作是否完成的必要。
每个Netty的出站I/O操作都将返回一个ChannelFuture
;也就是说,它们都不会阻塞。正如我们前面所提到过的一样,Netty完全是异步和事件驱动的。
代码清单1-3展示了一个ChannelFuture
作为一个I/O操作的一部分返回的例子。这里,connect
方法将会直接返回,而不会阻塞,该调用将会在后台完成。这究竟什么时候会发生则取决于若干的因素,但这个关注点已经从代码中抽象出来了。因为线程不用阻塞以等待对应的操作完成,所以它可以同时做其他的工作,从而更加有效地利用资源。
代码清单1-3 异步地建立连接
Channel channel = ...;// Does not blockChannelFuture future = channel.connect( ← -- 异步地连接到远程节点 new InetSocketAddress("192.168.0.1", 25));
代码清单1-4显示了如何利用 ChannelFutureListener
。首先,要连接到远程节点上。然后,要注册一个新的ChannelFutureListener
到对connect
方法的调用所返回的ChannelFuture
上。当该监听器被通知连接已经建立的时候,要检查对应的状态❶。如果该操作是成功的,那么将数据写到该Channel
。否则,要从ChannelFuture
中检索对应的Throwable
。
代码清单1-4 回调实战
Channel channel = ...;// Does not blockChannelFuture future = channel.connect( ← -- 异步地连接到远程节点 new InetSocketAddress("192.168.0.1", 25));future.addListener(new ChannelFutureListener { ← -- 注册一个ChannelFutureListener,以便在操作完成时获得通知 @Override public void operationComplete(ChannelFuture future) { ← -- ❶ 检查操作的状态 if (future.isSuccess){ ByteBuf buffer = Unpooled.copiedBuffer( ← -- 如果操作是成功的,则创建一个ByteBuf以持有数据 "Hello",Charset.defaultCharset); ChannelFuture wf = future.channel .writeAndFlush(buffer); ← -- 将数据异步地发送到远程节点。返回一个ChannelFuture .... } else { Throwable cause = future.cause; ← -- 如果发生错误,则访问描述原因的Throwable cause.printStackTrace; } }});
需要注意的是,对错误的处理完全取决于你、目标,当然也包括目前任何对于特定类型的错误加以的限制。例如,如果连接失败,你可以尝试重新连接或者建立一个到另一个远程节点的连接。
如果你把ChannelFutureListener
看作是回调的一个更加精细的版本,那么你是对的。事实上,回调和Future
是相互补充的机制;它们相互结合,构成了Netty本身的关键构件块之一。
1.3.4 事件和ChannelHandler
Netty使用不同的事件来通知我们状态的改变或者是操作的状态。这使得我们能够基于已经发生的事件来触发适当的动作。这些动作可能是:
- 记录日志;
- 数据转换;
- 流控制;
- 应用程序逻辑。
Netty是一个网络编程框架,所以事件是按照它们与入站或出站数据流的相关性进行分类的。可能由入站数据或者相关的状态更改而触发的事件包括:
- 连接已被激活或者连接失活;
- 数据读取;
- 用户事件;
- 错误事件。
出站事件是未来将会触发的某个动作的操作结果,这些动作包括:
- 打开或者关闭到远程节点的连接;
- 将数据写到或者冲刷到套接字。
每个事件都可以被分发给ChannelHandler
类中的某个用户实现的方法。这是一个很好的将事件驱动范式直接转换为应用程序构件块的例子。图1-3展示了一个事件是如何被一个这样的ChannelHandler
链处理的。
图1-3 流经ChannelHandler链的入站事件和出站事件
Netty的ChannelHandler
为处理器提供了基本的抽象,如图1-3所示的那些。我们会在适当的时候对ChannelHandler
进行更多的说明,但是目前你可以认为每个Channel-Handler
的实例都类似于一种为了响应特定事件而被执行的回调。
Netty提供了大量预定义的可以开箱即用的ChannelHandler
实现,包括用于各种协议(如HTTP和SSL/TLS)的ChannelHandler
。在内部,ChannelHandler
自己也使用了事件和Future
,使得它们也成为了你的应用程序将使用的相同抽象的消费者。
1.3.5 把它们放在一起
在本章中,我们介绍了Netty实现高性能网络编程的方式,以及它的实现中的一些主要的组件。让我们大体回顾一下我们讨论过的内容吧。
1.Future、回调和ChannelHandler
Netty的异步编程模型是建立在Future
和回调的概念之上的, 而将事件派发到ChannelHandler
的方法则发生在更深的层次上。结合在一起,这些元素就提供了一个处理环境,使你的应用程序逻辑可以独立于任何网络操作相关的顾虑而独立地演变。这也是Netty的设计方式的一个关键目标。
拦截操作以及高速地转换入站数据和出站数据,都只需要你提供回调或者利用操作所返回的Future
。这使得链接操作变得既简单又高效,并且促进了可重用的通用代码的编写。
2.选择器、事件和EventLoop
Netty通过触发事件将Selector
从应用程序中抽象出来,消除了所有本来将需要手动编写的派发代码。在内部,将会为每个Channel
分配一个EventLoop
,用以处理所有事件,包括:
- 注册感兴趣的事件;
- 将事件派发给
ChannelHandler
; - 安排进一步的动作。
EventLoop
本身只由一个线程驱动,其处理了一个Channel
的所有I/O事件,并且在该EventLoop
的整个生命周期内都不会改变。这个简单而强大的设计消除了你可能有的在ChannelHandler
实现中需要进行同步的任何顾虑,因此,你可以专注于提供正确的逻辑,用来在有感兴趣的数据要处理的时候执行。如同我们在详细探讨Netty的线程模型时将会看到的,该API是简单而紧凑的。
1.4 小结
在这一章中,我们介绍了Netty框架的背景知识,包括Java网络编程API的演变过程,阻塞和非阻塞网络操作之间的区别,以及异步I/O在高容量、高性能的网络编程中的优势。
然后,我们概述了Netty的特性、设计和优点,其中包括Netty异步模型的底层机制,包括回调、Future
以及它们的结合使用。我们还谈到了事件是如何产生的以及如何拦截和处理它们。
在本书接下来的部分,我们将更加深入地探讨如何利用这些丰富的工具集来满足自己的应用程序的特定需求。
在下一章中,我们将要深入地探讨Netty的API以及编程模型的基础知识,而你则将编写你的第一款客户端和服务器应用程序。
[1] W. Richard Stevens的Advanced Programming in the UNIX Environment (Addison-Wesley, 1992)第364页“4.3BSD returned EWOULDBLOCK if an operation on a non-blocking descriptor could not complete without blocking”。
[2] 也称为I/O多路复用,该接口从最初的select
和poll
调用到更加高性能的实现,已经演变了很多年。参见Sangjin Han的文章《Scalable Event Multiplexing: epoll vs. kqueue》(www.eecs.berkeley.edu/~ sangjin/2012/12/21/epoll-vs-kqueue.html)。
[3] 这里指支撑更多的并发的客户端。——译者注
[4] 这里指熟悉这些底层的API的人员少。——译者注
[5] Spring框架大概是最出名的,并且实际上是一个完整的应用程序框架的生态系统,处理了对象的创建、批量处理、数据库编程等。
[6] Netty在2011年荣获了Duke’s Choice Award的殊荣,参见www.java.net/dukeschoice/2011。
[7] 最新的版本编译需要JDK 1.8+,参见https://github.com/netty/netty/pull/6392。——译者注
[8] 还包括炙手可热的大数据处理引擎Spark。——译者注
[9] 完整的已知采用者列表参见http://netty.io/wiki/adopters.html。
[10] 关于Finagle的更多信息参见https://twitter.github.io/finagle/。
[11] 第15章和第16章的案例研究描述了这里提到的公司中的一些是如何使用Netty来解决现实世界的问题的。
[12] André B. Bondi的Proceedings of the second international workshop on Software and performance— WOSP’00 (2000)第195页,“Characteristics of scalability and their impact on performance”。
[13] Java平台,标准版第8版API规范,java.nio.channels,Channel:http://docs.oracle.com/javase/8/docs/ api/java/nio/channels/package-summary.html。
[14] 指接受回调的方法。——译者注
[15] 如果在ChannelFutureListener
添加到ChannelFuture
的时候,ChannelFuture
已经完成,那么该ChannelFutureListener
将会被直接地通知。——译者注