序言
小提示:如果想学习网络编程,首先需要清楚Java I/O和JUC,如果这两个不清楚,需要去学习完后再来看Netty,不然会很费解,同时会出现很多问题。
Java I/O
Java中的IO流分为字节流(byte)和字符流(buf),从流的角度就是输入流(in)和输出流(out),这些流的主要就是通过流的管道将数据从源头传输到目标地。
源头可以是文件、网络连接、内存、磁盘等,而目标地可以是文件、数据库、网络等。IO流提供了一组丰富的类和方法来实现不同类型的输入和输出操作。
JUC
并发编程,可以让CPU在工作时使用多个线程处理多个任务,提高处理效率.
BIO/NIO
BIO(同步阻塞通讯):在客户端进行读写请求时,会分配一个单独的线程去进行读写,并且这个读写是单向的,在进行read()/write()操作时如果I/O正在进行,线程则会被阻塞(wait),这样如果出现并发请求则需要创建大量线程。
NIO(同步非阻塞通信):一个线程处理多个连接请求,所有连接请求都会注册到多路复用器Selector上,Selector轮询到连接有IO请求就进行处理。(一个线程管理多个channel,不会让线程吊死在一个channel上).
目的
Netty 项目致力于提供异步事件驱动的网络应用程序框架和工具,用于快速开发可维护的高性能和高可扩展性的协议服务器和客户端。
换句话说,Netty 是一个 NIO 客户端服务器框架,可以快速轻松地开发协议服务器和客户端等网络应用程序。它极大地简化和精简了 TCP 和 UDP 套接字服务器开发等网络编程。
“快速简便”并不意味着生成的应用程序会存在可维护性或性能问题。Netty 的设计充分考虑了从许多协议(如 FTP、SMTP、HTTP 以及各种二进制和基于文本的旧协议)的实现中吸取的经验。因此,Netty 成功地找到了一种无需妥协即可实现易于开发、性能、稳定性和灵活性的方法。
有些用户可能已经找到了其他声称具有相同优势的网络应用程序框架,您可能想问 Netty 与其他框架有何不同。答案是它所基于的理念。Netty 旨在从第一天开始为您提供最舒适的 API 和实现体验。这不是有形的东西,但您会意识到,当您阅读本指南并使用 Netty 时,这种理念将使您的生活变得轻松得多。
基础概念
简单介绍
相较于BIO/NIO,Netty的实现更加复杂,但是Netty并不需要我们自己去实现这些复杂的代码,而是提供一系列接口供我们使用.
Netty服务流程图
这个概念图先放在这,第一眼肯定是不明白的,但是可以给我们一个大致的Netty运行流程和其组件的作用介绍,下面我们可以通过边写代码的方式边了解其中的API和组件及其作用。
编写一个拒绝响应的服务
世界上最简单的协议不是"hello,world",而是DISCRAD。它的唯一功能就是接收数据并立即丢弃,而没有任何其他操作。
要实现DISCARD协议,您只需要忽略所有接收到的数据。让我们直接从handler程序实现开始,它处理Netty生成的I/O事件。
package io.netty.example.discard;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
/**
* I/O服务处理器
*/
public class DiscardServerHandler extends ChannelInboundHandlerAdapter { // (1)
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) { // (2)
//读取(丢弃)客户端传来的数据
((ByteBuf) msg).release(); // (3)
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { // (4)
// 当抛出异常时关闭该Channel
cause.printStackTrace();
ctx.close();
}
}
- DiscardServerHandler继承了ChannelInboundHandlerAdapter,后者是ChannelInboundHandler的实现。ChannelInboundHandler提供了各种可以重写的事件处理方法(I/O事件)。现在,扩展ChannelInboundHandlerAdapter就足够了,而不是自己实现处理程序接口。
- 这里重写channelRead()事件处理程序方法。每当从客户端接收到新数据时,都会使用接收到的消息调用此方法。在这个例子中,接收到的消息的类型是ByteBuf。
- 为了实现DISCARD协议,处理程序必须忽略接收到的消息。ByteBuf是一个引用计数对象(多少对象或线程正在使用某个缓冲区),必须通过release()方法显式释放(否则可能生内存泄漏)。通常,channelRead()处理程序方法的实现方式如下。
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
try {
// Do something with msg
} finally {
ReferenceCountUtil.release(msg);
}
}
4.当Netty因I/O错误或处理程序事件时抛出的异常,使用Throwable调用exceptionCaught()事件处理程序方法。在大多数情况下,应记录捕获的异常,在此处关闭其相关channel并且进行一些其他的处理。
到现在为止,都还不错。我们已经实现了DISCARD服务的前半部分。现在剩下的就是编写main方法,该方法使用DiscardServerHandler启动服务。
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;
/**
* 拒绝服务类
*/
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)
// 绑定接收连接的端口
ChannelFuture f = b.bind(port).sync(); // (7)
// 等待socket关闭
// 在这个例子中不会发生,但你可以优雅的关闭
// 关闭你的服务
f.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}
public static void main(String[] args) throws Exception {
int port = 8080;
if (args.length > 0) {
port = Integer.parseInt(args[0]);
}
new DiscardServer(port).run();
}
}
- NioEventLoopGroup是一个处理I/O操作的多线程事件循环。Netty为不同类型的传输提供了各种EventoopGroup实现。在这个例子中,我们正在实现一个服务器端应用程序,因此将使用两个NioEventoopGroup。第一个通常被称为"Boss",接受客户端的连接请求。第二个通常被称为"Worker",一旦Boss接受(accept)连接,会将该连接注册到worker,worker就会处理该连接的stream。使用多少线程以及它们如何映射到创建的通道取决于EventoopGroup的实现,甚至可以通过构造函数进行配置。
- ServerBootstrap是一个设置服务器的辅助类。您可以直接使用Channel设置服务器。但是,这是一个乏味的过程,在大部分情况下不需要这样做。
- 在这里,我们指定使用NioServerSocketChannel类,该类用于实例化一个新的Channel用来接受请求连接。
-
这里指定的handler会在每个新接收的 Channel 上被执行。
ChannelInitializer
是一个特殊的处理器,专门用于帮助用户配置新的 Channel。通常你会通过在ChannelPipeline
中添加一些handler(比如DiscardServerHandler
)来配置新 Channel,以实现网络应用。随着应用程序复杂度的增加,你可能会往Pipeline
中添加更多handler,最终将这个匿名类提取为顶级类。简而言之,ChannelInitializer
用来初始化和配置新连接的管道。
这里画了一张图,每个连接的Channel里包含着一个ChannelPipeline(处理器链),通过它可以管理和组织一系列的处理器(handlers),ChannelPipeline会依次执行操作,比如编码(encoded)、解码(decode)、业务逻辑处理(handler)等。
5.您还可以设置特定的Channel实现的参数。我们正在编写一个TCP/IP服务,因此我们可以设置socket选项,如tcpNoDelay(消息立即发送)和keepAlive(保持获得状态)。请参阅ChannelOption的apidocs和特定的ChannelConfig实现,以了解支持的ChannelOption
s的概述。
6.注意到option()和childOption()了吗?option()用于接受传入连接的NioServerSocketChannel(服务器端Channel)。childOption()用于父ServerChannel接受的通道,在本例中为NioSocketChannel(客户端或服务器已接受的通信Channel)。
简单理解就是NioServerSocketChannel
负责监听连接NioSocketChannel
负责实际的数据传输。
7.现在准备运行了,剩下的就是绑定端口并启动服务器。这里,我们将服务器绑定到机器上所有网卡的 8080 端口。你可以调用 bind()
方法多次,并指定不同的绑定IP/端口(如不同的 IP 地址),从而监听多个IP/端口。
启动main程序后,我们的服务器会一直循环运行,而不会退出,当然我们也看不见仍和消息,但是其已经运行成功.
获取客户端数据
现在我们已经编写了第一个服务器,我们需要测试它是否真实有效。测试它的最简单方法是使用telnet命令。
telnet localhost 8080
但是,我们能说服务工作正常吗?我们无法真正知道这一点,因为它是一个拒绝响应的服务器。你根本不会得到任何回应。为了证明它确实有效,让我们修改服务以打印它收到的内容。
我们已经知道,每当收到数据时,都会调用channelRead()方法。让我们将一些代码放入DiscardServerHandler的channelRead()方法中:
@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)
}
}
1.这个循环其实可以简化为:
System.out.println(in.toString(io.netty.util.CharsetUtil.US_ASCII))
2.您可以在此处执行in.release()。
如果再次使用telnet命令,您将看到服务器打印它收到的内容。
编写Echo服务
到目前为止,我们都在消耗数据,并没有回应客户端。然而,服务器通常应对请求做出响应。让我们学习如何通过实现ECHO协议向客户端编写响应消息,其中任何接收到的数据都会被发回。
与我们在前面的部分中实现的拒绝响应服务的唯一区别是,它将接收到的数据发送回,而不是将接收的数据打印到控制台。因此,再次修改channelRead()方法就足够了:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ctx.write(msg); // (1)
ctx.flush(); // (2)
}
- ChannelHandlerContext对象提供了各种操作,使您能够触发各种I/O事件和操作。在这里,我们调用write(Object)来逐字写入接收到的消息。请注意,与DISCARD示例不同,这里没有手动释放接收到的msg,因为当 Netty 将消息写出到网络(wire)时,它会自动为你释放msg。
- ctx.write(Object)不会将消息写入网络。这只是写入了缓冲区中。然后通过ctx.flush()刷新到网络上。或者,为了简洁起见,您可以调用ctx.writeAndFlush(msg)。
如果再次运行telnet命令,您将看到服务器发回您发送给它的任何内容。
编写时间服务
本节中要实现的协议是TIME协议。它与前面的示例不同,因为它发送一条包含32位整数的msg,而不接收任何请求,并在消息发送后关闭连接。在这个例子中,您将学习如何构造和发送消息,以及在完成时关闭连接。
因为我们将忽略任何接收到的数据,在建立连接后立即发送消息,所以这次我们不使用channelRead()方法。相反,我们应该重写channelActive()方法。以下是实施过程:
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位整数。
- 要发送新消息,我们需要分配一个新的缓冲区来包含该消息。我们要写一个32位整数,因此我们需要一个容量至少为4字节的ByteBuf。通过ChannelHandlerContext.alloc()获取当前ByteBufAllocator,并分配一个新的缓冲区。
- 写入并刷新到网络中,同时获取一个异步的回调类,ChannelFuture表示尚未发生的I/O操作。这意味着,任何请求的操作可能尚未执行,因为Netty中的所有操作都是异步的。例如,以下代码可能会在发送消息之前关闭连接:
因此,您需要在ChannelFuture完成后调用close()方法,该方法由write()方法返回,并在写入操作完成时通知其侦听器。请注意,close()也可能不会立即关闭连接,它会返回ChannelFuture。Channel ch = ...; ch.writeAndFlush(message); //还没发送消息就关闭了Channel ch.close();
- 那么,当写请求完成时,我们如何得到通知呢?这就像在返回的ChannelFuture中添加ChannelFutureListener一样简单。在这里,我们创建了一个新的匿名ChannelFutureListener,它在操作完成时关闭Channel。
或者,您可以使用预定义的监听器简化代码:f.addListener(ChannelFutureListener.CLOSE);
要测试我们的时间服务器是否按预期工作,您可以使用UNIX rdate命令:
$ rdate -o <port> -p <host>
处理基于流的传输
socket缓冲区的小漏洞
在基于流的传输(如TCP/IP)中,接收到的数据被存储到socket接收缓冲区中。不幸的是,流传输的缓冲区不是数据包队列,而是字节队列。这意味着,即使你将两条消息作为两个独立的数据包发送,操作系统也不会将它们视为两条消息,而只是一堆字节。因此,无法保证您所获取的内容与远程发送的内容完全一致。例如,让我们假设操作系统的TCP/IP栈已经接收到三个数据包:
由于基于流的协议的这种一般属性,在您的应用程序中很有可能以以下碎片形式读取它们:
不管接收端是服务器还是客户端,都需要将接收到的数据重新组装(即“解碎片”)成一个或多个对应用逻辑有意义的帧。这样,应用程序才能理解数据。在上述例子中,接收到的数据需要像指定的那样进行帧处理,以确保能够被正确解释和使用:
第一种解决方案
现在让我们回到TIME客户端示例。我们这里也有同样的问题。32位整数是一个非常小的数据量,它不太可能被分割。问题是它可能会被碎片化,碎片化的可能性会随着流量的增加而增加。
PS:小数据通常不会被分割成多个部分来传输,因此大部分情况下数据会一次性发送完成。但并不是绝对不会分割,在网络传输中,数据包的大小和传输速率会受到网络延迟、带宽等因素的影响。当流量增加时,网络资源可能会变得紧张,导致数据不能一次性完整地传输,而是被拆分成多个较小的部分。这些小部分被依次传输到接收端,接收端需要重新组合这些分片数据,直到完整的数据包(比如一个32位整数)完全接收并可以被处理。
最简单的解决方案是创建一个内部累积缓冲区,并等待所有4个字节都被接收到内部缓冲区中。
以下是修复该问题的修改后的TimeClientHandler实现:
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
中的handlerAdded()
和handlerRemoved()
是两个生命周期监听方法。handlerAdded()
当handler被添加到管道时调用,handlerRemoved()
在handler被移除时调用。这两个方法可以用于执行初始化或清理任务,但要确保这些任务不会耗费过长时间!!!。它们的作用是帮助你在处理器的生命周期中执行必要的资源管理或准备工作,同时保证不会阻塞系统的正常运行。- 首先,所有接收到的数据都写入到buf中。
- 然后,检查buf是否有足够的数据(在本例中为4个字节),有则继续执行实际的业务逻辑后关闭Channel。否则,当更多数据到达前一直累加buf数据.
第二种解决办法
虽然第一个解决方案解决了TIME客户端的问题,但修改后的处理程序看起来并不具备扩展。想象一个由多个字段组成的更复杂的协议,例如可变长度字段。ChannelInboundHandler实现将很快变得无法维护。
您可能已经注意到,您可以向ChannelPipeline添加多个ChannelHandler,因此,您可以将一个单一的ChannelHandler拆分为多个模块化ChannelHandler,以降低应用程序的复杂性。例如,您可以将TimeClientHandler拆分为两个处理程序:
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()方法。
- 当累积缓冲区中没有足够的数据时,decode()可以决定不向输出中添加任何内容。当收到更多数据时,ByteToMessageDecoder将再次调用decode()。
- 如果decode()将一个对象添加到out中,则意味着解码器成功解码了一条消息。ByteToMessageDecoder将释放累积缓冲区的读取部分。记住,您不需要解码多条消息。ByteToMessageDecoder将继续调用decode()方法,直到它没有向out添加任何内容。
现在我们有另一个handler要插入ChannelPipeline,我们应该修改TimeClient中的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.
使用对象代替ByteBuf
到目前为止,我们回顾的所有示例都使用ByteBuf作为协议消息的主要数据结构。在本节中,我们将改进TIME协议客户端和服务器示例,以使用POJO而不是ByteBuf。
在ChannelHandlers中使用POJO的优势是显而易见的;这将从ByteBuf中获取信息的代码与handler分离,handler将可以更好的维护和复用。在TIME客户端和服务器示例中,我们只读取一个32位整数,直接使用ByteBuf并不难。然而,您会发现在实现真实世界的协议时,有必要进行分离。
首先,让我们定义一个名为UnixTime的新类型。
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()));
}
随着解码器的更新,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);
}
现在,唯一缺少的部分是编码器,它是ChannelOutboundHandler的一个实现,可以将UnixTime转换回ByteBuf。它比编写解码器简单得多,因为在编码消息时不需要处理数据包碎片和组装。
public class TimeEncoder extends ChannelOutboundHandlerAdapter {
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) {
UnixTime m = (UnixTime) msg;
ByteBuf encoded = ctx.alloc().buffer(4);
encoded.writeInt((int)m.value());
ctx.write(encoded, promise); // (1)
}
}
这一行有很多重要的事情。
首先,我们按原样传递原始ChannelPromise,以便Netty在编码数据实际写入线路时将其标记为成功或失败。
其次,我们没有调用ctx.flush()。有一个单独的处理程序方法void flush(ChannelHandlerContext ctx),用于覆盖flush()操作。
为了进一步简化,您可以使用MessageToByteEncoder:
public class TimeEncoder extends MessageToByteEncoder<UnixTime> {
@Override
protected void encode(ChannelHandlerContext ctx, UnixTime msg, ByteBuf out) {
out.writeInt((int)msg.value());
}
}
剩下的最后一个任务是在TimeServerHandler之前将TimeEncoder插入服务器端的ChannelPipeline中。
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 TimeEncoder(),new TimeServerHandler());
}
})
总结
在本章中,我们快速浏览了Netty,并演示了如何使用Netty编写一个完全可用的网络应用程序。
在接下来的章节中会有更多关于Netty的详细信息。我们还鼓励您查看io.Netty.example包中的Netty示例。
本文章是为学习Netty网络编程所写,根据项目需求会时不时更新。参考Netty官方手册。