Netty 5.x 入门指南

翻译自官网: http://netty.io/wiki/user-guide-for-5.x.html
技术文档翻译的一点心得翻译者最好先自己全文理解一遍再开始翻译,因为如果自己都糊里糊涂的,很多晦涩的英语表达最多只能直译,从而读者更加难以理解其含义。而且人都是懒惰的,翻译到后来发现前面翻译的不是很友好,也会懒得再去改了。所以先全文理解一遍再开始翻译吧,以中文友好的方式来翻译,直译往往只能起到反作用

Note: 本文中Socket没有翻译成套接字,就是Socket。原因是,套接字说实话在中文里也没有具象的意义,也无法给读者带来形象的理解,所以等于没翻译。而只要有了解过网络编程的人几乎都知道Socket,并且在大脑里有映像了(比套接字更形象),没有了解网络编程的读者也没有关系,百度Socket可以了解含义。

搭建时间服务器

本节中我们将实现一个TIME协议。这个协议和之前的例子不同,它不处理任何收到的请求,并且发送一个包含32-bit的整数的消息,发送成功后即关闭链接。在这个例子中,我们将学习如何构建和发送一个消息,并且在完成发送后关闭链接。
因为我们根本不关注接受到的请求里的具体内容,我们只是想在链接建立后立马发送一个消息,所以我们这次决定放弃channelRead()方法,而是使用channelActive方法(重写它)。以下是具体的实现:

package io.netty.example.time;

public class TimeServerHandler extends ChannelHandlerAdapter {

    @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() {

            public void operationComplete(ChannelFuture future) {
                assert f == future;
                ctx.close();
            }
        }); // (4)
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}
  1. 上面提到过,channelActive()方法将在链接建立并且准备好传输时被调用。我们要做的就是重写这个方法,返回当前的时间(32-bit整数)。
  2. 为了发送一个新的消息,我们需要分配一个用来存储消息的缓冲区。我们将在这个消息里写入一个32-bit整数,因此我们需要一个容量至少是4字节的[ByteBuf](http://netty.io/5.0/api/io/netty/buffer/ByteBuf.html)。通过ChannelHandlerContext.alloc()获取一个[ByteBufAllocator](http://netty.io/5.0/api/io/netty/buffer/ByteBufAllocator.html)分配器对象,并且用它来分配一个新的缓冲区。
  3. 没啥特别的,把创建好的消息写入链接通道并且发送。
    等等,flip()函数去哪了?我们之前在每次发送消息前不是都会调用下java.nio.ByteBuffer.flip()吗!这里却没有了。为啥?因为ByteBuf没有这个函数,但是它有2个指针,一个是读数据用的,另一个则是写数据用的。写数据的那个下标指针会在写入数据时递增,同时读数据的下标指针则保持不变。写指针,和读指针分别代表了消息的开始和结束位置。
    相反的,如果不显示调用flip()方法的话,NIO缓冲区(指的是ByteBuffer)是无法轻易知道消息内容的开始和结束位置的。如果不显示调用flip()方法的话,你可能会陷入困境:发送了空数据,或者错误的数据。不过大家放心吧,在Netty中是不可能发生这样的问题的,因为我们为不同类型的操作分别设置了不同的下标指针。慢慢的你就会发现没有了flip的生活是多么棒(注解:可以理解作者的愉快心情,很多时候一个细微但是频繁的操作如果得到简化,生活确实可以变得更为简单高效。)!
    另外比较有意思的是,ChannelHandlerContext.write()(和writeAndFlush())方法返回了一个[ChannelFuture](http://netty.io/5.0/api/io/netty/channel/ChannelFuture.html)对象。一个ChannelFuture对象代表了一个还未发生的I/O操作。在Netty中所有操作都是异步执行的(由另外的线程负责执行),所以很可能方法调用完毕了,但是请求的操作还没有完成。举个例子,下面的代码很可能在消息发送前就关闭了链接:
Channel ch = ...;
ch.writeAndFlush(message);
ch.close();

因此,你需要在ChannelFuture对象被返回后才能调用close()方法。这个ChannelFuture对象是由write()方法返回的,它将在写入操作完成后通知它的监听者。需要注意的是,其实close()方法也不会立即完成,它也返回也别ChannelFuture对象。

  1. 当写入请求完成后我们如何获得通知呢?很简单,只需要为返回的ChannelFuture添加一个监听者。这里我们创建一个新的异步ChannelFutureListener,它将在操作完成后关闭链接通道。
    还有个更简答的办法:使用预定义的监听器:
f.addListener(ChannelFutureListener.CLOSE);

为了测试我们的时间服务器,我们可以使用UNIX的rdate命令:

$ rdate -o  -p 

其中,是我们再main()函数中定义的端口,通常是localhost

基于流的传输协议处理方法

关于Socket缓冲区的一点说明

在一个基于流的传输协议中(如TCP/IP),接受到的数据时储存在一个Socket缓冲区中的。然而基于流的传输协议使用的缓冲区并不是一个以对象为单位的队列,而是一个以字节为单位的队列。这意味着就算我们发送2个独立的包来表达2个消息,操作系统也不会把他们当作是2条消息,而是当作一串字节流。因此,我们无法保证从Socket中读取的和对方(remote peer还是英文更形象)写入的数据是一致的。举个例子,假设某操作系统的TCP/IP协议栈收到了3个包:



根据基于流的协议的一般特性可以知道,很大概率会出现以下的读取顺序:



因此,不管是服务端还是客户端都需要把收到的数据进行整理,组装成有意义的数据,以便更好的在上层应用逻辑中使用。在上面的例子中,接受的数据需要被整理成以下格式的数据帧:

第一种解决方案

下面我们回到TIME客户端例子中,我们遇到了同样的问题(重组收到的数据帧)。当然了,一个32-bit的整数是非常小的,似乎不太会出现碎片化的情况。不过,问题在于它可以被碎片化,并且被碎片的可能性将随着传输量增长而增长。
最简单的方案是创建一个内部增量缓冲区并且等待4个字节都被收到。下面是一个改进的TimeClientHandler实现,它修复了碎片化问题:

package io.netty.example.time;

import java.util.Date;

public class TimeClientHandler extends ChannelHandlerAdapter {
    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包含2个生命周期监听方法:handlerAdded()handlerRemoved()。你可以handlerAdded()中做任意初始化工作,或者在handlerRemoved()中做任何销毁工作,只要它们别阻塞太久就好。
  2. 首先,所有接受到的数据将被积蓄在buf中。
  3. 然后,处理方法必须先检查buf是否有足够的数据(本例中为4字节),接着处理其他业务逻辑。否则,当更多的数据到达时,Netty将会再次调用channelRead()方法,最终将会在buf中累计4字节数据。

第二种解决方案

尽管第一种方案能很好的解决TIME例子中的碎片化问题,修改过的消息处理器代码看起来不是很干净(加了很多其他代码)。想象一下,如果有一个更加复杂的协议,它涉及的数据字段众多,并且有些字段的长度还会变化(字符串,数组等),如此,第一种方法就Hold不住了。
你可能已经注意到了,我们可以添加不止一个ChannelHandler到一个ChannelPipeline中,所以我们可以把一个整体的ChannelHandler拆分为多个模块的ChannelHandler,从而降低应用的复杂度(代码逻辑的复杂度)。举个例子,我们可以把TimeClientHandler切分成2个消息处理器:

  • TimeDecoder用来处理碎片化问题
  • 当前TimeClientHandler的精简版。

幸运的是,Netty提供了一个可扩展类用来帮助我们编写第一个消息处理器:

package io.netty.example.time;

public class TimeDecoder extends ByteToMessageDecoder { // (1)
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) { // (2)
        if (in.readableBytes() < 4) {
            return; // (3)
        }

        out.add(in.readBytes(4)); // (4)
    }
}
 
 
  1. ByteToMessageDecoder是ChannelHandler的一个实现,它会帮助我们处理碎片化问题。
  2. 只要有新的数据到达时,ByteToMessageDecoder调用decode()方法来处理此次新到的数据。
  3. 如果新到的数据到达后,积蓄缓冲区[见下方注解]中的数据还未足够(本例中为4字节),decode()方法将不往out(注解:用于储存这次消息数据的受体变量)中添加任何东西(说白了就是不会处理当前的数据)。如果新到的数据使得积蓄缓冲区中的数据足够被处理了,decode方法就会处理积蓄缓冲区中的数据并且添加到out
  4. 如果decode()方法把对象添加到了out,它意味着解码器成功解码了一条消息。然后ByteToMessageDecoder将丢弃已经积蓄缓冲区中已经被读取的部分。说这么多,我们只要记住我们不需要解码多个消息,ByteToMessageDecoder会一直调用decode()方法直到不在往out里添加东西为止。(注解:细心的读者会发现,这里是有疑问的,第3步中,我们说到,当积蓄缓冲区中的数据不足量时,decode也选择不往out里添加东西,所以作者的意思应该是,即数据已经发送完毕,out里也没有新的消息时。只不过判断数据是否发送完毕是Netty帮我们做了。)

注解:积蓄缓冲区,Cumulative buffer。译者认为,缓冲区广为人知的意义是:当资源从高速输出者A流向低速处理者B时,B无法承受资源流入速度,因此需要一个缓冲中间件C,这也是“缓冲”的含义。这种默认意义的缓冲区,可以认为是“降速缓冲区”,计算机中诸如连接池等都是如此原理,生活中的例子比如长江大坝。但其实反过来也是成立的:当资源从低速输出者B流向高速处理者A时,我们也可以使用缓冲区,因为有时候A消耗资源的时候对资源量有要求。比如本例中,decode()处理资源的条件是有足够量的数据时。生活中的例子有高楼上的蓄水池,没有缓冲区的话,水流也能运上来,但是水压不足,说不定还断断续续,为了保证消费者的用水体验,加入了高楼上的蓄水池,等到水压达到一定程度时再放水使用。

注意我们又多了一个消息处理器需要加入到ChannelPipeline,我们需要修改TimeClient中ChannelInitializer的实现:

b.handler(new ChannelInitializer() {
    @Override
    public void initChannel(SocketChannel ch) throws Exception {
        ch.pipeline().addLast(new TimeDecoder(), new TimeClientHandler());
    }
});

如果你是个爱冒险的人,你可能想试试ReplayingDecoder,它可以进一步简化解码器。但是在你决定使用之前最好先查阅相关API的文档。

public class TimeDecoder extends ReplayingDecoder {
    @Override
    protected void decode(
            ChannelHandlerContext ctx, ByteBuf in, List out) {
        out.add(in.readBytes(4));
    }
}
 
 

另外,Netty提供了盒外(有点类似黑盒的意思,就是使用的时候无需过多关注内部的逻辑,是OOP的封装特性体现)解码器,它们让我们可以轻松的实现大部分协议,并且帮助我们避免了一整块非常复杂的消息处理器实现。查阅下面的文档,可以获取更详细的例子:

  • 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的好处是很明显的;使用POJO将把从ByteBuf中抽取某一类信息的过程提取出来作为一段单独的代码逻辑(一个函数,或者一个类),从而让消息处理器代码将变得更容易维护,重用性也将提高。在TIME客户端和服务端例子中,我们只是读取一个32-bit的整数,所以直接使用ByteBuf也没什么大问题。然而,当我们开发一个实际的协议时,你就会发现分割消息抽取过程代码是多么重要了。(注解:ByteBuf是字节流,面向字节流获取解析消息对象更像C语言的风格,即面相过程编程,而使用了POJO后,每种消息都有自己的解析逻辑,更加符合OOP的思想,代码重用性、可维护性也必然会提高)。
首先,我们定义一个新的类型,叫做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 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);
}

好了,现在唯一遗漏的修改是添加一个编码器,它是ChannelHandler的一个实现,用来把一个UnixTime对象编码成ByteBuf对象。这个过程比编写解码器简单多了,因为编码过程不涉及碎片化和整合碎片的问题:

package io.netty.example.time;

public class TimeEncoder extends ChannelHandlerAdapter {
    @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传递给了write函数,这样Netty就可以在编码数据被写入发送通道时标记这个过程是成功还是失败了。
    第二、我们没有调用ctx.flush()。有一个另外的消息处理器方法void flush(ChannelHandlerContext ctx),它重写了flush()操作。

要进一步简化代码,我们还可以使用MessageToByteEncoder:

public class TimeEncoder extends MessageToByteEncoder {
    @Override
    protected void encode(ChannelHandlerContext ctx, UnixTime msg, ByteBuf out) {
        out.writeInt((int) msg.value());
    }
}

最后要做的就是把TimeEncoder绑定到服务端的ChannelPipeline中了,注意顺序要在TimeServerHandler之前,就把这个留作课后作业把。

关闭应用

关闭一个Netty应用很简单,只需要使用shutdownGracefully()关闭所有的创建的EventLoopGroup就可以关闭Netty应用。这个函数返回了一个Future对象,它可以在EventLoopGroup被彻底终止并且所有属于它们的Channel也都被关闭后通知监听器。

总结

在本章中,我们通过一个实例演示学习了如何基于Netty编写一个功能齐全的网络应用。
在接下来的章节种,会有更详细的信息。我们鼓励大家查阅io.netty.example包中的Netty实例。
Netty社区随时欢迎你来提问或者提出建议,一来可以更好的帮助你,二来可以帮助改进Netty和它的文档。

你可能感兴趣的:(Netty 5.x 入门指南)