本章主要内容
- Transports
- NIO, OIO, Local, Embedded
- 示例
- API
一、Transport
为了学习传输是怎么工作的,我们这里从一个简单的应用开始,这个应用逻辑是接收客户端连接然后发送"Hi"给客户端。发送完成之后就断开连接。这是个简单的例子,每一步的详细实现这里不会深入讨论。
1.1、使用JDK的IO和NIO
首先我们先不使用Netty的API,只使用JDK的。下面先使用JDK阻塞IO实现这个应用。
package com.nan.netty.transport;
import java.io.IOException;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.Charset;
public class PlainOioServer {
public void serve(int port) throws IOException {
//绑定端口
final ServerSocket socket = new ServerSocket(port);
try {
while (true) {
//接收新连接
final Socket clientSocket = socket.accept();
System.out.println("Accepted connection from " + clientSocket);
//开启新线程处理连接
new Thread(() -> {
OutputStream out;
try {
out = clientSocket.getOutputStream();
//向客户端发送数据
out.write("Hi!\r\n".getBytes(Charset.forName("UTF-8")));
out.flush();
//发送完成后关闭连接
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
try {
clientSocket.close();
} catch (IOException ex) {
}
}
}).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 主执行方法
*/
public static void main(String[] args) throws IOException {
new PlainOioServer().serve(9999);
}
}
然后下面我们再实现一个客户端,逻辑很简单,就是连接服务端,并读取服务端发来的数据。
package com.nan.netty.transport;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;
public class PlainOioClient {
public void client(int port) throws IOException {
//连接到服务端
final Socket socket = new Socket("localhost", port);
//读取服务端返回的数据
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String line = reader.readLine();
reader.close();
socket.close();
System.out.println("Received from server: " + line);
}
/**
* 主执行方法
*/
public static void main(String[] args) throws IOException {
new PlainOioClient().client(9999);
}
}
先启动服务端,再启动客户端,可以看到客户端正确得到了服务端发送的数据。不过,随着用户量的提升,你会角色上面的代码扩展性太差。为了提高性能,你可能会打算使用异步API去实现上面的应用,但是你会发现API完全不一样。所以你不得不大量重构以前的代码。下面我们使用NIO实现一下。
package com.nan.netty.transport;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class PlainNioServer {
public void serve(int port) throws IOException {
System.out.println("Listening for connections on port " + port);
ServerSocketChannel serverChannel;
Selector selector;
serverChannel = ServerSocketChannel.open();
ServerSocket ss = serverChannel.socket();
InetSocketAddress address = new InetSocketAddress(port);
//绑定端口
ss.bind(address);
serverChannel.configureBlocking(false);
//打开选择器
selector = Selector.open();
//注册选择器用来接收新连接
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
final ByteBuffer msg = ByteBuffer.wrap("Hi!\r\n".getBytes());
while (true) {
try {
//阻塞代码只到触发了事件
selector.select();
} catch (IOException ex) {
ex.printStackTrace();
break;
}
//获取触发事件所有的SelectionKey
Set<SelectionKey> readyKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = readyKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
try {
//检查事件是否有效
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel)
key.channel();
SocketChannel client = server.accept();
System.out.println("Accepted connection from " + client);
client.configureBlocking(false);
//有效的客户端连接注册读写事件
client.register(selector, SelectionKey.OP_WRITE | SelectionKey.OP_READ, msg.duplicate());
}
//检查连接写事件是否准备好
if (key.isWritable()) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer) key.attachment();
while (buffer.hasRemaining()) {
//向客户端写数据,只到写完为止
if (client.write(buffer) == 0) {
break;
}
}
//关闭连接
client.close();
}
} catch (IOException ex) {
key.cancel();
try {
key.channel().close();
} catch (IOException cex) {
}
}
}
}
}
/**
* 主执行方法
*/
public static void main(String[] args) throws IOException {
new PlainNioServer().serve(9999);
}
}
可以看到,虽然大家都是Java,可这API也差别太大了。而且上面只是很简单的应用,如果是很复杂的应用,重构难度更难以想象啊。
现在我们再用Netty实现上面的应用。
1.2、使用Netty的IO和NIO
首先还是实现阻塞IO版本的应用,比过这一次是使用Netty框架,代码如下。
package com.nan.netty.transport;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.oio.OioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.oio.OioServerSocketChannel;
import java.net.InetSocketAddress;
import java.nio.charset.Charset;
class NettyOioServer {
public void serve(int port) throws Exception {
final ByteBuf buf = Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("Hi!\r\n", Charset.forName("UTF-8")));
EventLoopGroup group = new OioEventLoopGroup();
try {
//创建服务端启动器
ServerBootstrap b = new ServerBootstrap();
//使用OioEventLoopGroup,阻塞IO Old IO
b.group(group)
.channel(OioServerSocketChannel.class)
.localAddress(new InetSocketAddress(port))
//新连接回调ChannelInitializer
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(
//添加ChannelHandler处理事件
new ChannelInboundHandlerAdapter() {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
//向客户端发送数据,发送完成之后关闭连接
ctx.writeAndFlush(buf.duplicate()).addListener(ChannelFutureListener.CLOSE);
}
});
}
});
//绑定服务器端口地址
ChannelFuture f = b.bind().sync();
f.channel().closeFuture().sync();
} finally {
//释放资源
group.shutdownGracefully().sync();
}
}
/**
* 主执行方法
*/
public static void main(String[] args) throws Exception {
new NettyOioServer().serve(9999);
}
}
你可能会注意到,与JDK的API相比,Netty的代码非常紧凑。不过这也只是小优点之一。
现在让我们改成非阻塞的应用。
1.3、异步实现
上一小节使用额是Netty的阻塞IO实现的,下面的代码使用非阻塞IO实现。
package com.nan.netty.transport;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import java.net.InetSocketAddress;
import java.nio.charset.Charset;
class NettyNioServer {
public void serve(int port) throws Exception {
final ByteBuf buf = Unpooled.unreleasableBuffer(Unpooled.copiedBuffer("Hi!\r\n", Charset.forName("UTF-8")));
EventLoopGroup group = new NioEventLoopGroup();
try {
//创建服务端启动器
ServerBootstrap b = new ServerBootstrap();
//使用OioEventLoopGroup,阻塞IO Old IO
b.group(group)
.channel(NioServerSocketChannel.class)
.localAddress(new InetSocketAddress(port))
//新连接回调ChannelInitializer
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(
//添加ChannelHandler处理事件
new ChannelInboundHandlerAdapter() {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
//向客户端发送数据,发送完成之后关闭连接
ctx.writeAndFlush(buf.duplicate()).addListener(ChannelFutureListener.CLOSE);
}
});
}
});
//绑定服务器端口地址
ChannelFuture f = b.bind().sync();
f.channel().closeFuture().sync();
} finally {
//释放资源
group.shutdownGracefully().sync();
}
}
/**
* 主执行方法
*/
public static void main(String[] args) throws Exception {
new NettyNioServer().serve(9999);
}
}
乍一看,好像就是改了个类名称。仔细一看,其实就改了2个地方,一个是EventLoopGroup使用了Nio的实现,第二个就是Channel使用的NioServerSocketChannel。这样就从BIO模式换成了NIO,So easy。
因为Netty传输的实现都是相同的API,所以具体实现用什么影响都不是太大。通过Channel、ChannelPipeline、ChannelHandler
这些接口暴露出来统一的API。
现在我们来更深入的学习Netty的传输API。
二、传输API
发送数据操作的传输API的核心是Channel接口。
看看Channel接口的设计层次。
从上图可以看出,Channel会分配ChannelPipeline和ChannelConfig给它。ChannelConfig存储着整个Channel的配置信息并且允许更新他们。一般传输都是有自己的特殊配置并且只有传输没有其他实现。为了达到目的可能就是一个ChannelConfig的子类型。
- 转换数据格式
- 通知异常
- 通知Channel可用或不可用状态
- 通知Channel从EventLoop注册或注销
- 通知用户自定义事件
你可以在任何时候修改ChannelPipeline,也就是说你可以需要的时候向ChannelPipeline中添加或删除ChannelHandler。所以用Netty可以编写高度灵活的应用。
除了访问指定的ChannelPipeline和ChannelConfig,你也可以直接操作Channel。Channel提供了很多方法,下表列出来的是比较重要的。
ChannelPipeline持有所有ChannelHandler的实例,这些实例用在通过Channel进出的数据上。ChannelHandler的实习允许你处理数据或传输数据。ChannelHandler也是Netty核心之一,后面有一章会专门讲到它的。
目前我们已经知道ChannelHandler可以做以下任务:
方法名 | 描述 |
eventLoop() | 返回指定给Channel的EventLoop |
pipeline() | 返回指定给Channel的ChannelPipeline |
isActive() | channel是否激活状态,意思是连接是否正常 |
localAddress() | 返回绑定本地的SocketAddress |
remoteAddress() | 返回连接的远程的SocketAddress |
write() | 向对端些数据,数据传输经过ChannelPipeline |
等下你会学到怎么使用这些功能。要记得开发的时候面向接口,使用接口提供的统一的API,这样你的应用的灵活度就会很高,当尝试不同的实现方式,代码就不用大量重构了。
当你发送数据的时候会调用Channel.write()方法,下面给个伪代码例子。
Channel channel = ...
//创建数据ByteBuf
ByteBuf buf = Unpooled.copiedBuffer("your data", CharsetUtil.UTF_8);
//写数据
ChannelFuture cf = channel.write(buf);
//为了操作完成后获取通知,添加ChannelFutureListener
cf.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) {
//写操作成功完成
if (future.isSuccess()) {
System.out.println(“Write successful“);
} else {
//写操作未成功完成
System.err.println(“Write error“);
future.cause().printStacktrace();
}
}
});
请牢记,Channel是线程安全的,也就是说在不同的线程使用它是安全的。它的所有方法都可以用在多线程环境中。因此持有它的引用并在需要的时候发送数据给对端是安全的,即使是多线程的环境。下面的伪代码展示了在多线程环境中使用它。
final Channel channel = ...
//创建数据ByteBuf
final ByteBuf buf = Unpooled.copiedBuffer("your data", CharsetUtil.UTF_8);
//创建写数据Runnable
Runnable writer = () -> channel.write(buf.duplicate());
//创建一个线程池
Executor executor = Executors.newChachedThreadPool();
//在一个线程执行写数据操作
executor.execute(writer);
//在另一个线程执行写数据操作
executor.execute(writer);
同样,write()方法保证写数据顺序是和你传数据顺序一致。其他方法可以参考Netty javadocs。
了解这些接口不仅重要,而且有助于了解传输实现的不同。这些知识已经准备好了。下一章我们就来看看Netty提供了哪些传输实现以及他们的行为。
三、所有Transport
Netty已经提供了很多可以使用的Transport实现。这些实现没有支持所有的协议,也就是说使用什么Transport得看你应用使用了什么协议。这一章我们将要学习哪个Transport实现了哪个协议。
下表展示了Netty默认提供了的Transport。
名称 | 包 | 描述 |
NIO | io.netty.channel.socket.oio | 基于java.nio.channels,使用选择器 |
OIO | io.netty.channel.socket.oio | 以java.net package为基础,使用阻塞流 |
Local | io.netty.channel.local | 用于同一个JVM上的应用通讯 |
Embedded | io.netty.channel.embedded | 嵌入式传输实现,主要是用来测试ChannelHandler的,不需要真实网络环境 |
现在,我们来深入了解最重要的传输实现NIO。
3.1、NIO-非阻塞IO
NIO是使用最多的传输方式。它基于Java1.4 NIO的选择器提供了所有IO操作的异步实现。
使用Netty的NIO,我们可以通过注册监听器获取Channel状态改变的通知。主要有以下几种:
- 接收一个新的Channel并且Channel准备OK
- Channel连接成功
- Channel收到数据并准备好读操作
- Channel可以发送数据
这里也可以只去监听一个事件类型而忽略其他类型。
下表列出来了可监听的事件类型,这些都是定义在SelectionKey类中的。
名称 | 描述 |
OP_ACCEPT | 接收一个新连接并且创建一个Channel后获得通知 |
OP_CONNECT | 连接完成获得通知 |
OP_READ | 可以从Channel读数据获得通知 |
OP_WRITE | 可以向Channel写数据获得通知。大部分情况下这是 没问题的,但如果操作系统的缓冲区被填满了就不能 再写数据了。这种情况一般是你写的太快而对端不能 及时处理时发生。 |
Netty的NIO也是使用这种模型收发数据,但是它暴露给的用户是自己的API,基本上隐藏了内部的实现。就像前面说的,Netty提供给了用户统一的API并且隐藏了实现细节。下图展示了选择器的大致流程。
#1:一个新Channel注册到选择器上
#2:选择器处理所有状态变更
#3:已经注册的所有Channel
#4:Selector.select()会阻塞,只到有状态变化或者超时
#5:有Channel状态变化
#6:处理状态变化
#7:选择器操作线程执行其他任务
这种传输方式在处理事件时可能会有一些延迟,导致吞吐量可能会比OIO低。这是因为选择器工作方式导致的,它需要花费时间去通知状态变更。当然这种延迟也只是毫秒级的。可能听起来不像延迟,但如果你的网络应用在千兆网速的环境使用它就会累加起来。
有一个功能只有NIO传输提供,就是“零文件复制”。这个功能允许你快速有效的传输文件系统中的内容。这个功能不用将内容从内核空间复制到用户空间,就可以把文件系统的内容传输到网络堆栈中。
不过需要注意的是,不是所有操作系统都支持这个功能。要根据操作系统的文档看它是否支持这个功能。还有,如果你对数据进行加密/解密操作,这个功能的好处你就享受不到了。因为你需要将内容放到用户空间进行操作,所以只有传输文件原始数据才能真正用到这个功能。不过,你可以先加密,再使用网络应用传输加过密的文件内容。
一般使用到这个功能的就是大文件下载的FTP或HTTP服务。
下一小节我们将会讨论OIO传输,它提供了阻塞IO的传输方式
3.2、OIO-Old blocking I/O
OIO是Netty的一个折中方案。它也提供了统一的API,但实际是上它不是异步的,因为它使用的是java.net包里面的的阻塞API。乍一看,这种传输方式好像没什么用处,但是它也有它的使用场景。后面会介绍它的几个使用场景,不过现在先看一个比较特殊的。
假如你需要重构一些老的系统,里面使用了很多阻塞方法(例如JDBC)。这些代码可能并不能换成非阻塞方式。这个时候,你就可以先使用OIO的传输方式,然后再慢慢进化到真正的非阻塞方式。我们来看看它是怎么工作的。
因为OIO传输是基于java.net包里面的类实现的,所以它的实现逻辑和我们之前写的BIO代码差不多。
我们使用BIO写服务端代码时,一个线程用来接收新连接,然后每一个新连接进来就会开启一个新线程去编写相关的逻辑代码。这就需要连接上的IO操作都是阻塞的。如果多个连接使用同一个线程,很明显一个连接的IO操作会阻塞所有共享这个线程的连接。
了解了这些操作可能会阻塞代码,你可能会担心Netty使用的这些API实现的OIO也会有阻塞问题。这里Netty使用了Socket上的一个配置SO_TIMEOUT。这个参数指定了一个IO操作在多少毫秒后还没完成就算超时。如果IO操作超时了就会抛出一个超时异常SocketTimeoutException。Netty捕获这个异常,然后忽略它继续工作。下一个EventLoop执行的时候,再次尝试IO操作。不幸的是,目前只能这样处理,这样处理的问题就是捕获SocketTimeoutException是有代价的,代价就是要写到异常栈中。下图展示了这部分的主要逻辑。
开启线程处理连接,比如读,read()方法会阻塞只到读到数据或出现超时,读到数据处理然后执行业务逻辑,没有读到数据然后执行业务逻辑后重新再去执行read()方法。
现在你已经学习了Netty中最常用的两种传输方式,不过还有其他方式,我们也来了解一下。
3.3、Local-虚拟机中的传输
Netty中提供了一种叫Local的传输。这种传输方式主要是用在同一个JVM中之间的连接,API依然是Netty提供的统一API。这种传输方式和NIO一样是完全异步的。
每个Channel使用唯一的SocketAddress注册到虚拟机中。这个SocketAddress可以通过客户端连接。服务只要运行它就一直注册着。如果Channel关闭了,虚拟机就会注销这个SocketAddress,客户端也就不能连接了。
使用Local传输方式和使用其他传输没什么太大区别。不过有一点需要注意,Local传输方式的服务端和客户端必须运行在同一个JVM中,也就是说不能像NIO或OIO那样,一个进程启动服务端,一个进程启动客户端。这看起来是个限制,不过你仔细想想,本来就应该是这样。因为这种传输方式并没有真正去绑定IP和端口,也就是没有去使用真是的网络,所以它并不能像NIO和OIO那样工作。
3.3、Embedded-嵌入式传输方式
Netty还提供了一种嵌入式传输方式。和其他传输方式比较,这个压根就没有进行传输。不过既然Netty提供了这个,到底有傻子用呢?
简单来说,这个玩意主要是用来帮助你测试你的ChannelHandler编写的业务逻辑。因为很多东西Netty都帮我们封装好了,开发者主要是实现ChannelHandler,但是你测试ChannelHandler的时候走网络就有些浪费资源了。它的另外一个用处就是嵌入到ChannelHandler中,然后重用这些ChannleHandler并且不需要事件继承实现。
下面我们讨论一下什么场景使用什么样的传输方式
3.3、选择使用哪种传输
了解完所有的传输之后,我们可能会迷茫什么时候该什么哪个。就像前面说的,不是所有的传输方式都实现了所有协议。比如NIO和OIO支持TCP、UDP和SCTP,但是Local和嵌入式的没有支持任何协议。
下面列一下常用的经验,可以帮助我们在什么场景选择什么协议。
- 小并发量场景
- 大并发量场景
- 低延迟场景
- 基于阻塞代码
- 同一个JVM中
- 测试ChannelHandler
四、总结
这一章的主要内容,就是学习Netty的Transport,以及它提供了哪些传输。然后我们详细了解了每个传输,并且总结了一下什么场景选择什么传输方式。后面的章节我们还会介绍如果实现一个自己的传输方式。
下一章,我们会学习ByteBuf和MessageList,这两个东西是Netty传输数据时的数据容器。我们会学习怎么使用它们以及怎么利用它们实现性能最优。