1 Java网络编程
早期的Java API只支持由本地系统套接字提供的所谓的阻塞函数,下面将使用这些函数编写服务端的代码。
1.1 服务端
package com.socket.test;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
@Slf4j
public class SocketServer {
public static void main(String[] args) {
ServerSocket serverSocket = null;
BufferedReader input = null;
PrintWriter out = null;
Socket socket = null;
try {
// 创建一个新的ServerSocket,用以监听指定端口上的链接请求
serverSocket = new ServerSocket(4700);
// 对accept()方法的调用将被阻塞,直到一个链接的建立
socket = serverSocket.accept();
// 这些流对象都派生于该套接字的流对象,input代表从socket中读取客户端的信息,out代表向客户端发送通信
input = new BufferedReader(new InputStreamReader(socket.getInputStream()));
out = new PrintWriter(socket.getOutputStream(), true);
String request;
String response;
while ((request = input.readLine()) != null) {
// 如果客户端发送了OK,则退出循环
if ("OK".equals(request)) {
break;
}
log.info("收到客户端请求 {}", request);
// 这里是服务端的业务处理流程,可以是一个方法
response = request + "处理后的结果";
out.println(response);
}
} catch (IOException e) {
log.error("Init server error ", e);
} finally {
if (serverSocket != null) {
try {
serverSocket.close();
} catch (IOException e) {
log.error("Close ServerSocket fail ", e);
}
}
if (input != null) {
try {
input.close();
} catch (IOException e) {
log.error("Close input stream fail ", e);
}
}
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
log.error("Close socket fail ", e);
}
}
if (out != null) {
out.close();
}
}
}
}
如代码所示,该服务端中同一时刻只能处理一个连接,要管理多个并发客户端,需要为每个新的客户端socket创建一个新的Thread。如下图所示:
以上方案的弊端:
- 在任何时候都可能有大量的线程处于休眠状态,只是等待输入或者输出数据就绪,这可能算是一种资源浪费。
- 需要为每个线程的调用栈都分配内存,其默认值大小区间为64KB~1MB,具体取决于操作系统。
- 即使JVM在物理上可以支持大量的线程,但远在达到该极限前,上下文切换所带来的开销就会带来麻烦。
综上,这种并发方案支撑中小数据量的客户端来说还算能接受,但支撑1W~10W或者更多并发连接时所需要的资源使得它很不理想。
2 Java NIO
本地套接字也提供了非阻塞调用,其为网络资源的利用率提供了很多的控制。
- 可以使用setsockopt()方法配置套接字,以便读/写调用在没有数据的时候立即返回。
- 可以使用操作系统的事件通知API注册一组非阻塞套接字,以确定它们中是否有任何的套接字已经有数据的可供读写。
Java对于非阻塞I/O的支持是在2002年引入的,位于JDK 1.4的java.nio包中。
下图展示的非阻塞设计,实际上消除了上一节中所描述的哪些弊端。
Class java.nio.channels.Selector 是Java的非阻塞I/O实现的关键。它使用了事件通知API以确定在一组非阻塞套接字中有哪些已经就绪能够就行I/O相关的操作。因为可以在任何的事件检查任意的读操作或者写操作的完成状态,做到一个单一的线程便可以处理多个并发的链接。
与阻塞I/O模型相比,这种模型提供了更好的资源管理:
- 使用较少的线程便可以处理更多的链接,减少了内存管理和上下文切换所带来的开销。
- 当没有I/O操作需要处理的时候,线程也可以被用于其他任务。
在高负载下可靠和高效地处理和调度I/O操作是一项繁琐而且容易出错的任务,做好使用基于NIO封装得较好的Netty进行编程。
3 Netty
3.1 netty特性
分类 | Netty特性 |
---|---|
设计 |
|
易于使用 |
|
性能 |
|
健壮性 |
|
安全性 |
|
社区驱动 |
|
3.2 异步和事件驱动
netty实现的关键:异步与事件驱动。
- 非租塞网络调用使我们可以不必等到一个操作的完成。完全异步的I/O正是基于这个特性构建的,并且更进一步;异步方案会立即返回,并在它完成时,会直接或者在稍后的某个时间点通知用户。
- 选择器使我们能够通过较少的线程便可监视许多连接上的事件。
3.3 netty的核心组件
netty的主要构建块:
- Channel
- 回调
- Future
- 事件和ChannelHandler
这些构建块代表了不同类型的构造:资源、逻辑以及通知。你的应用程序将使用它们来访问网络以及流经网络的数据。
3.3.1 Channel
Channel是Java NIO的一个基本构造。
它代表一个到实体(如硬件设备、文件、网络套接字或者能够执行一个或者多个不同的I/O操作的程序组件)的开放连接,如读操作和写操作。
当前,可以把Channel看作是传入(入站)或者传出(出站)数据的载体。因此它可以被打开或被关闭,连接或者断开连接。
3.3.2 回调
一个回调其实就是一个方法,一个指向已经被提供给另一个方法的方法的引用。这使得后者可以在适当的时候调用前者。回调在广泛的编程场景中都有应用,而且也是在操作完成后通知相关方最常见的方式之一。
Netty在内部使用回调来处理事件:当一个回调被触发时,相关的事件可以被一个接口ChannelHandler的实现处理。
如下代码展示了:当一个新的链接已经创建时,ChannelHandler的channelActive()回调方法将被调用,并打印出一条信息。
public class ConnectHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
// 当一个新的链接已经建立时,channelActive(ChannelHandlerContext)将会被调用
System.out.println("Client " + ctx.channel().remoteAddress() + " connected");
}
}
3.3.3 Future
Future提供了另一种在操作完成时通知应用程序的方式。这个对象可以看作是一个异步操作的结果的占位符;它将在未来的某个时刻完成,并提供对其结果的访问。
JDK预置了 interface java.util.concurrent.Future,但是其所提供的实现,只允许手动检查对应的操作是否已经完成,或者一直阻塞直到它完成。整个过程非常繁琐,所以Netty只提供了它自己的实现——ChannelFuture,用于在执行异步操作的时候使用。
ChannelFuture 提供了几种额外方法,这些方法使我们能够注册一个或者多个ChannelFutureListener实例。监听器可以判断回调方法 operationComplete(),将会在对应的操作完成时被调用。然后监听器可以判断该操作是否成功。如果失败,可检索产生的Throwable。总之,由ChannelFutureListener提供的通知机制笑出了手动检查对应的操作是否完成的必要。
每个Netty的出站I/O操作都将返回一个ChannelFuture;也就是说,它们都不会阻塞,故Netty完全是异步和事件驱动的。
如下代码展示了一个ChannelFuture作为一个I/O操作的一部分返回的例子。这里的connect()方法将会直接返回,而不会阻塞,该调用将会在后台完成。
Channel channel = new NioSocketChannel();
// 异步连接到远程节点,不会被block
ChannelFuture connect = channel.connect(new InetSocketAddress("127.0.0.1", 25));
如下代码显示如何利用 channelFutureListener。
Channel channel = new NioSocketChannel();
// 异步连接到远程节点,不会被block
ChannelFuture future = channel.connect(new InetSocketAddress("127.0.0.1", 25));
// 注册一个ChannelFutureListener,以便在操作完成时获得通知
future.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture channelFuture) throws Exception {
// 检查操作状态,如果操作成功,数据将写到Channel中。
if (future.isSuccess()) {
// 如果操作是陈工的,则创建一个ByteBuf 以持有数据
ByteBuf byteBuf = Unpooled.copiedBuffer("你好", Charset.defaultCharset());
// 将数据异步地发送到远程节点,返回一个channelFuture
ChannelFuture writeAndFlush = future.channel().writeAndFlush(byteBuf);
} else {
// 如果错误,则打印具体原因。
Throwable cause = future.cause();
log.error("Run future fail ", cause);
}
}
});
回调和Future是相互补充的机制,它们相互结合,构成了Netty关键的构件块。
3.3.4 事件和ChannelHandler
netty使用不同的事件来通知我们状态的改变或者操作的状态。这使得我们能基于已经发生的事件触发适当的操作。这些操作可以是:
- 记录日志
- 数据转换
- 流控制
- 应用程序逻辑
Netty是一个网络编程框架,所以事件是按照它们与入站或者出站数据流的相关性进行分类的。可能由入站数据或者相应的状态更改而触发的事件包括:
- 连接已被激活或者连接失活
- 数据读取
- 用户事件
- 错误事件
出站时间是未来将会被处罚的每个动作的操作结果,这些动作包括:
- 打开或者关闭到远程节点的链接
- 将数据写到或者冲刷到套接字
每个时间都可以被分发给ChanelHandler类中的某个用户实现的方法。这是一个很好将事件驱动范式直接转换为应用程序构建块的例子。如下所示:
Netty的ChannelHandler为处理器提供了基本的抽象,如上图所示,每个ChannelHandler的实例都类似于一种为了响应特定事件而被执行的回调。
Netty提供了大量预定义的开箱即用的ChannelHandler实现,包括各种协议入HTTP和SSL/TLS的handler实现。
3.4 总结
3.4.1 Future、回调和ChannelHandler
Netty的异步编程模型是建立在Future和回调的概念上的,而将事件派发到ChannelHandler的方法则发生在更深的层次。结合在一起,这些元素就提供了一个处理环境,使你的应用程序逻辑可以独立于任何网络操作相关的顾虑而独立演变。这也是Netty设计的关键目标之一。
拦截操作以及高速地转换入站数据和出站数据,都只需要你提供回调或者利用操作所返回的Future。这使得链接操作变得即简单又高效,并且促进了可重用的通用代码编写。
3.4.2 选择器、事件和EventLoop
Netty通过触发事件将Selector从应用程序中抽象出来,消除了所有本来将需要手动编写的派发代码。在内部,会为每个Channel分配一个EventLoop用作处理所有事件,包括:
- 注册感兴趣的事件
- 将事件派发给ChannelHandler
- 安排进一步的动作
EventLoop本身只由一个线程驱动,其处理了一个Channel的所有I/O事件,并且在该EventLoop的整个生命周期内都不会改变。这个简单而强大的设计消除了所有在ChannelHandler需要进行同步的任何顾虑。因此,程序员可以专注于正确的逻辑,用来在有感兴趣的数据要处理的时候执行。