前言
问题
如今我们使用一般目的的应用和类库来相互交流。例如,我们经常使用HTTP客户端库来从Web服务器端获取信息和通过RPC的 方式来调用WebService。然而,一般目的的协议或实现并不能很好地伸缩。就像我们不会使用常规的HTTP协议来交换大文件,电子邮件以及实时的消息如金融信息或多用户的游戏数据。这些都是针对特殊目的实现的高度优化的协议。例如,你可能会针对基于ajax的聊天应用,媒体流以及大文件传输场景使用优化的协议。甚至你会根据你自己的需要设计并实现一个全新的协议。另外一种不可避免的情况是你必须处理遗留下来的与老系统进行交互的协议。那种情况下需要关心的是在不牺牲最终应用稳定性和性能的情况如何快速地实现那个协议。
解决方案
Netty项目是在尽最大努力提供一个异步的,基于事件驱动的网络应用框架以及为可维护的,高性能的,高扩展性的协议服务器和客户端的快速部署提供工具支持。
换句话说,Netty是一个NIO的客户端和服务端框架,通过它能使我们快速简单地开发网络应用(如协议服务端与客户端)。它极大地简化和流水线化了网络编程,如TCP和UDP网络服务器的开发。
简单快速并不意味着会导致应用程序出现可维护性与性能问题。Netty在设计时就考虑到了许多协议的实现如FTP,SMTP,HTTP以及一些二进制和文本的遗留协议的一些经验。作为结果,Netty成功地找到了一种在不妥协的前提下实现开发简单,高性能,高稳定性,灵活性的方式。
一些用户可能已经发现一些其它的网络应用框架声明有同样的优势,并且你可能会问Netty与其它框架的不同之外在哪里。答案就在于它所基于的哲理。Netty设计的初衷就是为了在API的角度和实现的第一天开始就提供最好的用户体验。这不是有开有的东西,但你会意识到,当你阅读这篇教程并与Netty玩时,你的生活将变得更加容易。
开始
这个章节围绕Netty的核心结构,通过简单的例子让你快速入门。当你在本章末尾的时候,你可以快速写一个客户端和服务器程序。
如果你喜欢自顶向下的方式学习一些东西,你可能会喜欢从第二章开始,再回到这里。
开始之前
运行本章中引入的例子的最低要求有两个:最新的Netty版本以及JDK1.6或以上。最新版本的Netty在下载页进行下载。为了下载正确版本的JDK,请到JDK供应商的网站上进行下载。
正如你所读到的,你可能会对本章中所引入的类在点疑惑。请在需要知道API明细的时候查看对应的API。为方便起见,在这文章的类都链接到了在线的文档。另外,请不要犹豫向Netty Project Community发送邮件,并让我们知道是否有不正确的信息,语法错误,打印错误以及提高文档的一些好的想法。
写一个废弃的服务器
现实中的最简单的协议并不是"Hello,World!",而是DISCARD。这是一个不进行响应,直接丢弃收到的数据的协议。
为了实现DISCARD协议,你唯一需要做的事情是忽略任何接收到的数据。我们直接从控制器的实现开始,这个控制器用来处理Netty产生的IO事件:
package io.netty.example.discard;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
/**
* Handles a server-side channel.
*/
public class DiscardServerHandler extends ChannelInboundHandlerAdapter { // (1)
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) { // (2)
// Discard the received data silently.
((ByteBuf) msg).release(); // (3)
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { // (4)
// Close the connection when an exception is raised.
cause.printStackTrace();
ctx.close();
}
}
- DiscardServerHandler继承了ChannelInboundHandlerAdapter,它实现ChannelInboundHandler接口。ChannelInboundHandler提供了一系列的用于覆盖的事件处理方法。对现在来说,继承ChannelInboundHandlerAdapter已经足够了,你并不需要自己实现它实现ChannelInboundHandler接口。
- 我们在这里覆盖了channelReload()事件处理方法。这个方法无论在什么时候,只要在客户端接收到数据就会被调用。在这个例子中,接收到的数据类型是ByteBuffer。
- 为了实现DISCARD协议,事件处理函数必须丢弃接收到的数据。ByteBuffer是一个引用对象,因此,它必须调用release()方法来释放。请记住必须由事件处理函数来释放任何传递给事件处理函数的对象。通常情况下,channelRead()处理函数像这样实现:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
try {
// Do something with msg
} finally {
ReferenceCountUtil.release(msg);
}
}
- 当异常对象被Nettey抛出时,这些异常对象通常由IO异常或者在处理消息时由消息处理函数抛出的,此时exceptionCaught()事件处理函数会被调用。在大多数情况下,异常信息需要被记录,并且相关的通道需要被关闭。尽管这个方法的实现是由你根据具体的异常情况来决定的。例如,在关闭连接之前,你可能想发送包含错误代码的响应消息。
到目前为止,一切正常。我们已经实现了DISCARD服务器的前半部分。接下来我们来运行DiscardServer中的main方法来启动服务器。
package io.netty.example.discard;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
/**
* Discards any incoming data.
*/
public class DiscardServer {
private int port;
public DiscardServer(int port) {
this.port = port;
}
public void run() throws Exception {
EventLoopGroup bossGroup = new NioEventLoopGroup(); // (1)
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap(); // (2)
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class) // (3)
.childHandler(new ChannelInitializer<SocketChannel>() { // (4)
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new DiscardServerHandler());
}
})
.option(ChannelOption.SO_BACKLOG, 128) // (5)
.childOption(ChannelOption.SO_KEEPALIVE, true); // (6)
// Bind and start to accept incoming connections.
ChannelFuture f = b.bind(port).sync(); // (7)
// Wait until the server socket is closed.
// In this example, this does not happen, but you can do that to gracefully
// shut down your server.
f.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws Exception {
int port;
if (args.length > 0) {
port = Integer.parseInt(args[0]);
} else {
port = 8080;
}
new DiscardServer(port).run();
}
}
- NIOEventLoopGroup是一个事件循环,它是用来处理IO操作。Netty针对不同的传输协议提供了一系列的EventLoopGroup实现类。在这个例子中,我们正在实现一个服务端的应用,并且使用了两个NIOEventGroup。第一个叫做boss,用来接收新进来的连接。第二个叫做worker,用来处理已接收的连接的流量。一旦boss接收了连接,并且将这个连接注册到worker上。使用多少线程以及它们映射到创建的通道上是由EventLoopGroup实现决定的,甚至也可以通过构造器来配置。
- ServerBootstrap是一个帮助类来创建服务器。你也可以通过使用Channel直接创建一个服务器。然而,请注意这是一个无聊的过程,而且你在大多数情况下也不需要那样做。
- 在这里,我们指定了一个NioServerSocketChannel类初始化了一个新的通道来接收新进来的连接。
- 每个新进来的通道的事件处理函数都会被重新评估。ChannelInitializer是一个用来配置新通道的特殊的处理函数。在配置新通道的ChannelPipeline时,你很有可能会像这样添加一些处理函数如DiscardServerHandler来实现网络应用。随着应用程序变得越来越复杂,你很有可能会向管道中添加更多的处理函数,并最终提取这个匿名函数到类中。
- 你也可以针对通道的实现来设定参数。我们在写一个TCP/IP服务器,所以我们可以设置一些socket的选项,如tcpNoDelay和keepAlive。请查看ChannelOption的javaDoc以及特殊的ChannelConfig实现来获得更多信息。
- 你注意到option()和childOption()选项吗?option()是用于NioServerChannel实现接收新的连接。childOption()是用于父ServerSocketChannel(在这里是NioServerSocketChannel)所接收的连接。
- 我们现在可以开始了。剩下的就是绑定端口和启动了服务器了。我这里我们绑定了端口8080。你可以调用bind()方法来任意绑定端口。
查看接收的数据
既然我们已经写了我们的第一个服务器,我们需要测试来检测这工作是否正常。最简单的方式是使用telnet来进行测试。例如,你可以在命令行输入telnet 8080,并输入一些内容。
然而,我们可以说服务器工作正常吗?我们并不能够确定,因为它是一个Discard服务器。我们不会得到任何响应。为了验证它正在工作,我们修改服务器让它输出所接收到的数据。
我们现在已经知道channelReload()方法会在接收到数据时被调用。我们在DidcardServerHandler类中的channelReload方法中添加一些代码。
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf in = (ByteBuf) msg;
try {
while (in.isReadable()) { // (1)
System.out.print((char) in.readByte());
System.out.flush();
}
} finally {
ReferenceCountUtil.release(msg); // (2)
}
}
- 效率低下的loop事实上可以简化为 System.out.println(in.toString(io.netty.util.CharsetUtil.US_ASCII))。
- 可选的,你也可以使用in.release()方法。
如果你再次运行telnet命令,你会发现服务器输入你所接收到的数据。 完整的discard服务器的代码在io.netty.example.discard包下。
写一个Echo服务器
到目前为止,我们已经消费了数据但并没有一点响应。一个服务器,然而一般都是用来响应请求的。让我们通过实现g 一个Echo协议来学习如何向客户端响应请求,在这个例子里,服务器返回接收到的数据。
与前一小节我们实现的discard服务器不同的是它返回客户端接收到的数据,而不是在控制台输出。因此,修改channelReload()方法已经足够了。
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ctx.write(msg); // (1)
ctx.flush(); // (2)
}
- ChannelHandlerContext对象提供了各种操作来让你触发各种IO事件以及操作。在这里,我们调用了write(Object)方法来逐字地写入接收到的数据。请注意,除非你像DISCARD例子中的那样,我们不会释放任何接收到的数据。因为,在将数据写入到总线上时,Netty将释放资源的决定权交给你。
- ctx.write(Object)不会将消息写入到总线上。它内部是缓存的,然后是通过ctx.flush()方法将它刷新到总线上。可选的,你可以使用ctx.writeAndFlush(msg)来简化操作。
如果你再次运行telnet命令,你会发现服务器向你发回你所发送的数据。
echo服务器的完整代码在io.netty.example.echo包下。
编写一个时间服务器
这个小节需要实现的协议是TIME协议。这跟以前的例子有点不同。在这个例子中,它会发送一个32位的整数,同时并不会接收任何请求。当消息发送完成之后会关闭连接。在这个例子中,你将会学习到如何构造和发送消息,在完成发送时关闭连接。
由于我们会忽略掉在连接建立好以后接收到的任何数据,我们这次不使用channelReload方法。作为替代,我们重写了channelActive方法。以下是具体实现:
package io.netty.example.time;
public class TimeServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(final ChannelHandlerContext ctx) { // (1)
final ByteBuf time = ctx.alloc().buffer(4); // (2)
time.writeInt((int) (System.currentTimeMillis() / 1000L + 2208988800L));
final ChannelFuture f = ctx.writeAndFlush(time); // (3)
f.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) {
assert f == future;
ctx.close();
}
}); // (4)
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
- 正如所介绍的,channelActive()方法会在连接建立的时候被调用,并准备好产生流量。在这个方法中,我们写入一个32位的整数来代表当前时间。
- 为了发送一条消息,我们需要分配一个新的ByteBuffer,这个ByteBuffer会包含消息。我们会写入一个32位的整数,因此,我们需要一个ByteBuffer,它的长度是四个字节。通过ChannelHandlerContext.alloc()方法来获得ByteBufferAllocator,并分配新的Buffer。
- 一般情况下,你们会写入构造过的消息。
但是请等一下,flip去哪里了?在NIO中我们发送数据之前不需要调用java.nio.ByteBuffer.flip()方法吗?ByteBuf没有这样的方法,因为这有两个指针,一个是用来读的,一个是用来写的。写的指针会随着数据写入的增加而增加,而读指针不会发生变化。
相反,NIO Buffer在没有调用flip方法这前,并不会提供一个清除方式来计算消息是从哪里开始与结束的。当你没有调用flip方法,你会遇到一些麻烦。因为没有数据或错误的数据将会被发送。这种错误不会发生在Netty中,因为我们针对不同的类型有不同的指针。你会发现随着你熟悉netty,你的生活将会变得更加容易-一个没有flipping out的生活。
另外一个需要注意的点是ChannelHandlerContext.write()方法和writeAndFlush()方法,它们会返回一个ChannelFutrue。ChannelFuture代码一个还没有出现IO操作。这意味着,任何请求操作可能还没有被操作,因为所有的操作在Netty中都是异步的。例如,下面的代码可能在发送消息之前就关闭连接了。
Channel ch = ...;
ch.writeAndFlush(message);
ch.close();
因此,你需要在write()方法返回的ChannelFuture方法完成之后调用close()方法。它会通知它的监听器所有的操作都已经完成了。请注意,close()方法也不会立刻关闭连接,它会返回一下ChannelFuture。
在写请求完成之后我们要怎样被通知?这只需要简单地为返回的ChannelFuture对象添加一个ChannelFutrueListener。在这里,我们创建了一个匿名的ChannelFutureListener,在这个Listener中会在操作完成之后关闭通道。
可选的,你可以使用预定义的监听器:
f.addListener(ChannelFutureListener.CLOSE);
为了检测我们的时间服务器是否预期的工作,可以使用unix的rdate命令:
rdate -o <port> -p <host>
端口号是main函数是指定的端口号,一般情况下是localohst。
写一个时间客户端
不像DISCARD和ECHO服务器,我们需要为TIME协议提供一个客户端。因为用户无法将一个整数转换为日历中的某个日期。在这个小节中,我们将讨论如何让服务器工作正常,并学习如何用Netty写一个客户端。
服务端和客户端最大的也是唯一的不同之处是Netty中的客户端使用了不同的Bootstrap和Channel实现。我们来看一下以下的代码:
package io.netty.example.time;
public class TimeClient {
public static void main(String[] args) throws Exception {
String host = args[0];
int port = Integer.parseInt(args[1]);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
Bootstrap b = new Bootstrap(); // (1)
b.group(workerGroup); // (2)
b.channel(NioSocketChannel.class); // (3)
b.option(ChannelOption.SO_KEEPALIVE, true); // (4)
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new TimeClientHandler());
}
});
// Start the client.
ChannelFuture f = b.connect(host, port).sync(); // (5)
// Wait until the connection is closed.
f.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
}
}
}
- BootStrap与ServerBootstrap类似,除了它是针对非服务器通道,如客户端或其他无连接的通道。
- 如果你仅指定不念旧恶EventLoopGroup,它将既被用于boss group,也会被用于work group。尽管在客户端不会使用boss group。
- 使用了NioSocketChannel来代替NioServerSocketChannel。
- 注意到在这里我们没有像ServerBootstrap那样使用使用childOption。因为客户端的SocketChannel是没有父亲的。
- 你应该调用connect()方法来代替bind()方法。
正如你所看到的,这跟服务端的代码不是完全不一样。那么ChannelHandler的实现是怎么样的呢?它应该接收一个32个字节的整数,并将它转换为可供人类阅读的格式,打印转换后的时间,最后关闭连接。
package io.netty.example.time;
import java.util.Date;
public class TimeClientHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf m = (ByteBuf) msg; // (1)
try {
long currentTimeMillis = (m.readUnsignedInt() - 2208988800L) * 1000L;
System.out.println(new Date(currentTimeMillis));
ctx.close();
} finally {
m.release();
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
- 在TCP/IP中,Netty从其它节点读取的数据会放入到ByteBuffer中。
这看上去很简单,看上去跟服务端的例子差不多。然而,这个handler有时会拒绝工作,并搜出IndexOutOfBoundsException,这个会在下一小节进行详细介绍。
处理基于流的传输
Socket Buffer的小警告
在基于流的传输通道(如TCP/IP)中,接收到的数据是放在Socket接收缓存中的。不幸的是,基于流的传输通道不是数据包的队列而是基于字节的队列。这意味着,即使你是通过两个独立的包发送数据的,操作系统并不会把它们当成两条消息,而是一堆字节。因此,你所读取到的数据并不一定是远程的其它节点写入的数据。例如,我们想像一下操作系统的TCP/IP栈接收到了三个包。
第一种解决方案
让我们回到TIME客户端的例子。我们也有相同的问题。一个32位的整数是一个非常小的数据,它不太可能会被分割。然而,问题是它可以被分割,分割的可能性会导致流量增加。
最简单的方法是创建一个内部的累计的缓存,并且等到所有的四个字节都写入到缓存。以下是修改过的TimeClientHandler实现,这个实现修复了这个问题。
package io.netty.example.time;
import java.util.Date;
public class TimeClientHandler extends ChannelInboundHandlerAdapter {
private ByteBuf buf;
@Override
public void handlerAdded(ChannelHandlerContext ctx) {
buf = ctx.alloc().buffer(4); // (1)
}
@Override
public void handlerRemoved(ChannelHandlerContext ctx) {
buf.release(); // (1)
buf = null;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ByteBuf m = (ByteBuf) msg;
buf.writeBytes(m); // (2)
m.release();
if (buf.readableBytes() >= 4) { // (3)
long currentTimeMillis = (buf.readUnsignedInt() - 2208988800L) * 1000L;
System.out.println(new Date(currentTimeMillis));
ctx.close();
}
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
- ChannelHandler有两个生命周期相关的方法:handlerAdd()和handlerRemoved()。你可以执行任意初始化任务,只要这些任务不要阻塞太长的时间。
- 首先, 所有接收到的数据都会放入到buf中。
- 然后,handler必须检查buffer是否有足够的数据。在这个例子中是四个字节,并且处理实际的业务逻辑。不然,Netty会当数据到达时调用channelReload方法,并最终累计到4个字节。
第二个解决方案
尽管第一个方案已经解决了TIME客户端所遇到的问题,修改后的handler看上去并不是那样的干净。想像一下一个更加复杂的协议,它由多个字段组成,如一个可变的长度字段。你的ChannelInboundHandler的实现类马上会变得不可维护。
正如你所看到的,你可以添加不止一个Channel到ChannelPipeLine。因此,你可以将一个整体的ChannelHandler分割成多个模块化的Handler来降低系统的复杂性。例如,你可以将TimeClinetHandler分割成两个Handler。
- TimeDecoder负责处理片段问题。
- 最初的简单版本的TimeClinetHandler。
幸运的是,Netty提供了一个要扩展的类来帮助你编写程序。
package io.netty.example.time;
public class TimeDecoder extends ByteToMessageDecoder { // (1)
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { // (2)
if (in.readableBytes() < 4) {
return; // (3)
}
out.add(in.readBytes(4)); // (4)
}
}
- ByteToMessageDecoder是一个ChannelInboundHandler的实现类,它主要用来处理分片事件。
- ByteToMessageDecoder会在接收到新数据时调用了decode()方法,这个方法内部维护了一个累加的buffer。
- decode方法可以决定是否需要添加内容到out对象。ByteToMessageDecoder方法会在接收到更多数据时调用decode方法。
- 如果decode方法往out中添加了新的对象。这意味着deceder成功地编码了一条消息。ByteToMessageDecoder会丢弃累计的buffer中已经读到的数据。请记住,你不需要解码多条消息,ByteToMessageDecoder会一直调用 decode直到out中没有添加数据为止。
既然我们已经往ChannelPipeLine中插入了一个新的handler,我们应该修改ChannelInitializer实现:
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new TimeDecoder(), new TimeClientHandler());
}
});
如果你是一个敢于冒险的人,你可能会想要尝试ReplayingDecoder,它使得解码变得更加容易。你需要查阅API来获得更多信息。
public class TimeDecoder extends ReplayingDecoder<Void> {
@Override
protected void decode(
ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
out.add(in.readBytes(4));
}
}
另外,Netty也提供了开箱即用的解码器来帮助你实现你的协议更加容易,避免出现整体的,不可维护的handler实现。请查看以下包来获得更多的例子:
- io.netty.example.factorial for a binary protocol, and
- io.netty.example.telnet for a text line-based protocol.
用POJO代替ByteBuf
到目前为止的所有例子使用了ByteBuf作为协议消息的主要结构。在这个例子中,我们会实现TIME协议的客户端和服务端的例子,这些例子中使用了POJO代替了ByteBuf。
在ChannelHandler中使用POJO的优势是非常明显的。我的Handler将变得更加可维护与可重用,并且可以将我们的代码从解析ByteBuf中的数据分离出来。在TIME的客户端和服务端的例子中,我们只读取了一个32位的整数,并且这并不是一个直接使用ByteBuf的主要问题。然而在现实中的协议我们发现很有必要进行分离。
首先,我们定义了一个新的类型叫做UnixTime。
package io.netty.example.time;
import java.util.Date;
public class UnixTime {
private final long value;
public UnixTime() {
this(System.currentTimeMillis() / 1000L + 2208988800L);
}
public UnixTime(long value) {
this.value = value;
}
public long value() {
return value;
}
@Override
public String toString() {
return new Date((value() - 2208988800L) * 1000L).toString();
}
}
我们现在可以修改TimeDecoder来产生一个UnixTime来代替ByteBuf。
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < 4) {
return;
}
out.add(new UnixTime(in.readUnsignedInt()));
}
在更新后的Decoder中,TimeClientHandler不再使用ByteBuf了。
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
UnixTime m = (UnixTime) msg;
System.out.println(m);
ctx.close();
}
代码变得更加简单与优雅了,是不是?同样的技术也可以应用在服务端,这次我们先更新一下TimeServerHandler。
@Override
public void channelActive(ChannelHandlerContext ctx) {
ChannelFuture f = ctx.writeAndFlush(new UnixTime());
f.addListener(ChannelFutureListener.CLOSE);
}
现在,少了的部分是一个编码器,它是一个ChannelInboundHandler的实现类。现在编写解码器变得更加简单了,因为现在没有必要处理包分段和编码消息时进行装配。
@Override
public void channelActive(ChannelHandlerContext ctx) {
ChannelFuture f = ctx.writeAndFlush(new UnixTime());
f.addListener(ChannelFutureListener.CLOSE);
}