处理基于流的传输
套接字缓冲区的一个小警告
在基于流的传输(如TCP/IP)中,接收的数据存储在套接字接收缓冲区中。不幸的是,基于流的传输的缓冲区不是数据包队列,而是字节队列。这意味着,即使我们将两条消息作为两个独立的数据包发送,操作系统也不会将它们视为两条消息,而是将它们视为一堆字节。因此,无法保证我们所读的内容与远程对等方所写的内容完全相同。例如,假设操作系统的TCP/IP堆栈已接收到三个数据包:
由于基于流的协议的这一一般属性,在应用程序中很有可能以以下碎片形式读取它们:
因此,接收部分,无论是服务器端还是客户端,都应该将接收到的数据碎片整理成一个或多个有意义的帧,应用程序逻辑很容易理解这些帧。在上述示例的情况下,接收到的数据应如下所示:
第一个解决方案
现在让我们回到时间Client示例(在netty(1))。我们这里也有同样的问题。32位整数是一个非常小的数据量,不太可能经常被分割。但是,问题是它可能会被分割,并且随着流量的增加,分割的可能性会增加。
最简单的解决方案是创建一个内部累积缓冲区,并等待所有4个字节都接收到内部缓冲区。以下是修复该问题的修改后的TimeClientHandler实现:
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();
}
}
1、ChannelHandler有两个生命周期侦听器方法:handlerAdded()和handlerRemoved()。我们可以执行任意(取消)初始化任务,只要它不会长时间阻塞。
2、首先,所有接收到的数据应累积到buf中。
3、然后,处理程序必须检查buf是否有足够的数据(本例中为4字节),然后继续执行实际的业务逻辑。否则,当更多数据到达时,Netty将再次调用channelRead()方法,最终所有4个字节都将累积。
第二种解决方案
尽管第一个解决方案解决了时间Client的问题,但修改后的处理程序看起来并不干净。想象一个更复杂的协议,它由多个字段组成,例如可变长度字段。我们的ChannelInboundHandler实现将很快变得无法维护。
正如我们可能已经注意到的,我们可以向ChannelPipeline添加多个ChannelHandler,因此,我们可以将一个单一ChannelHandler拆分为多个模块化ChannelHandler,以降低应用程序的复杂性。例如,我们可以将TimeClientHandler拆分为两个处理程序:
- 处理碎片问题的时间解码器
-
TimeClientHandler最初的简单版本。
幸运的是,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)
}
}
1、ByteToMessageDecoder是ChannelInboundHandler的一个实现,它可以轻松处理碎片问题。
2、每当接收到新数据时,ByteToMessageDecoder使用内部维护的累积缓冲区调用decode()方法。
3、当累积缓冲区中没有足够的数据时,decode()可以决定不向out添加任何内容。当接收到更多数据时,ByteToMessageDecoder将再次调用decode()。
4、如果decode()将对象添加到out,则表示解码器已成功解码消息。ByteToMessageDecoder将丢弃累积缓冲区的读取部分。请记住,我们不需要解码多条消息。ByteToMessageDecoder将继续调用decode()方法,直到它不向out添加任何内容。
现在我们有另一个处理程序要插入到ChannelPipeline中,我们应该修改TimeClient中的ChannelInitializer实现:
b.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new TimeDecoder(), new TimeClientHandler());
}
});
此外,Netty还提供了开箱即用的解码器,使我们能够非常轻松地实现大多数协议,并帮助我们避免最终使用无法维护的单一处理程序实现。
用POJO而不是ByteBuf
到目前为止,我们回顾的所有示例都使用ByteBuf作为协议消息的主要数据结构。在本节中,我们将改进时间协议Client和服务器示例,以使用POJO而不是ByteBuf。
在ChannelHandler中使用POJO的优势是显而易见的;通过将从ByteBuf中提取信息的代码从处理程序中分离出来,处理程序变得更易于维护和重用。在TIME客户端和服务器示例中,我们只读取一个32位整数,直接使用ByteBuf并不是一个主要问题。但是,我们会发现,在实现实际协议时,有必要进行分离。
首先,让我们定义一个名为UnixTime的新类型。
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();
}
}
我们现在可以修改时间译码器来生成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的一个实现,它将UnixtTime转换回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)
}
}
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,这只是一个简单的练习。
关闭应用程序
关闭 Netty 应用程序通常就像我们通过 shutdownGracefully() 关闭创建的所有 EventLoopGroups 一样简单。 当 EventLoopGroup 完全终止并且属于该组的所有 Channel 都已关闭时,它会返回一个 Future 通知我们。