三、Netty编解码&粘包拆包&零拷贝

2,318 阅读8分钟

一、Netty编解码器


Netty涉及到编解码的组件有ChannelChannelHandlerChannelPipe等,先大概了解下这几个组件的作用。

1、Channel组件

ChannelNetty网络通信的组件,客户端与服务端建立的一个连接通道,能够用于执行网络I/O操作。不同协议、不同的阻塞类型的连接都有不同的Channel类型与之对应。

2、ChannelHandler组件

ChannelHandler充当了处理入站和出站数据的逻辑容器。例如,实现ChannelInboundHandler接口(或继承ChannelInboundHandlerAdapter类),就可以接收入站事件和数据,这些数据随后会被应用程序的业务逻辑处理。当要给连接的客户端发送响应时,也可以从ChannelInboundHandler冲刷数据。业务逻辑通常写在一个或者多个ChannelInboundHandler中。ChannelOutboundHandler原理一样,只不过它是用来处理出站数据的。

3、ChannelPipeline组件

ChannelPipeline提供了ChannelHandler链的容器。以客户端应用程序为例,如果事件的运动方向是从客户端到服务端的,那么我们称这些事件为出站的,即客户端发送给服务端的数据会通过pipeline中的一系列ChannelOutboundHandler(ChannelOutboundHandler调用是从tailhead方向逐个调用每个handler的逻辑),并被这些Handler处理,反之则称为入站的,入站只调用pipeline里的ChannelInboundHandler逻辑(ChannelInboundHandler调用是从headtail方向逐个调用每个handler的逻辑)。

4、编码解码器

  • 解码器:负责处理入站InboundHandler数据,将字节数组转换为消息对象
  • 编码器:负责处理出站OutboundHandler数据,将消息对象转换为字节数组

当通过Netty发送或者接受一个消息的时候,就会发生一次数据的转换。入站消息会被解码,出站消息会被编码。

Netty提供了一系列实用的编码解码器,他们都实现了ChannelInboundHadnler或者ChannelOutboundHandler接口。在这些类中,channelRead方法已经被重写了。以入站为例,对于每个从入站Channel读取的消息,这个方法会被调用。随后,它将调用由已知解码器所提供的decode()方法进行解码,并将已经解码的字节转发给ChannelPipeline中的下一个ChannelInboundHandler

Netty提供了很多编解码器,例如:

  • StringEncoder字符串编码器
  • StringDecoder字符串解码器
  • ObjectEncoder对象编码器
  • ObjectDecoder对象解码器
  • FixedLengthFrameDecoder固定长度的解码器
  • LineBasedFrameDecoder以换行符为结束标识的解码器
  • DelimiterBasedFrameDecoder指定消息分隔符的解码器
  • LengthFieldBasedFrameDecoder基于长度通用解码器

5、自定义编解码器

  • 通过继承ByteToMessageDecoder自定义解码器
  • 通过继承MessageToByteEncoder<T>自定义编码器
/**
 * todo 自定义解码器
 */
public class ByteToLongDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        System.out.println("ByteToLongDecoder decode 被调用");
        //todo 因为long占8个字节, 需要判断大于等于8个字节时才能读取一个long
        if(in.readableBytes() >= 8) {
            out.add(in.readLong());
        }
    }
}
/**
 * 自定义编码器
 */
public class LongToByteEncoder extends MessageToByteEncoder<Long> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Long msg, ByteBuf out) throws Exception {
        System.out.println("LongToByteEncoder encode被调用");
        System.out.println("msg=" + msg);
        out.writeLong(msg);
    }
}

二、TCP粘包拆包


1、什么是TCP粘包拆包

TCP粘包:把多个小的包封装成一个大的数据包发送,发送方发送的若干数据包到接收方时粘成一个包

TCP拆包:把一个完整的包拆分为多个小包进行发送,发送方发送一个数据包到接收方时被拆分为若干个小包

2、为什么会出现粘包拆包现象

TCP是面向连接的,面向字节流的, 提供可靠交互。发送端为了提高发送效率,使用了Nagle算法,将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样做虽然提高了效率,但是接收端就难于分辨出完整的数据包了,因为面向流的通信是无消息保护边界的。

3、粘包拆包解决方案

Netty拆包的基类ByteToMessageDecoder

  • ByteToMessageDecoder内部维护了一个数据累积器,每次读取到数据都会进行累加,然后尝试对累加的数据进行拆包
  • 每次都将读取到的数据通过内存拷贝的方式,累积到ByteToMessageDecoder的数据累积器中
  • 调用子类的decode()方法对累积的数据尝试进行拆包

拆包解决思路: 基本思路就是不断的从TCP缓冲区中读取数据,每次读取完都需要判断是否是一个完整的数据包

  • 若当前读取的数据不足以拼接成一个完整的业务数据包,那就保留该数据,继续从tcp缓冲区中读取,直到得到一个完整的数据包
  • 若当前读到的数据加上已经读取的数据足够拼接成一个数据包,那就将已经读取的数据拼接上本次读取的数据,构成一个完整的业务数据包传递到业务逻辑,多余的数据仍然保留,以便和下次读到的数据尝试拼接
  • 关键点是如何判断是一个完整的数据包

方案一:设置定长消息(对应Netty提供的FixedLengthFrameDecoder解码器)

方案二:设置消息边界(分隔符,对应Netty提供的DelimiterBasedFrameDecoder解码器)

方案三:使用带消息头的协议,消息头存储消息开始标识及消息的长度信息Header+Body(对应Netty提供的LengthFieldBasedFrameDecoder解码器)

方案四:发送消息长度,自定义消息解码器

4、分隔符案例(部分代码)

//todo 客户端发送数据
public class ClientHandler extends ChannelInboundHandlerAdapter {
    /**
     * todo 当客户端连接服务器完成就会触发该方法
     * @param ctx
     * @throws Exception
     */
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        String message = "aaaaaaaaaaaaaaaa&_bbbbbbbbbbbbbbbbbb&_ccccccccccc&_";
        ByteBuf byteBuf = Unpooled.buffer(message.getBytes().length);
        byteBuf.writeBytes(message.getBytes());
        ctx.writeAndFlush(byteBuf);
    }
}
//todo 服务端添加分隔符解码器
public class EchoServer {
    public static void main(String[] args) {
        
//todo 设置两个线程组
serverBootstrap.group(bossGroup,workerGroup)
        .channel(NioServerSocketChannel.class)
        .option(ChannelOption.SO_BACKLOG,1024)
        .childHandler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                //todo 向pipeline加入分隔符解码器
                ByteBuf delimiter = Unpooled.copiedBuffer("&_".getBytes());
                ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,true,delimiter));
                ch.pipeline().addLast(new StringDecoder());
                ch.pipeline().addLast(new ServerHandler());
            }
        });
    }
}

5、自定义解码器案例(部分代码)

/**
 * todo 自定义协议包
 */
public class MyMessageProtocol {
    //todo 定义一次发送包体长度
    private int len;
    //todo 一次发送包体内容
    private byte[] content;

    public int getLen() {
        return len;
    }

    public void setLen(int len) {
        this.len = len;
    }

    public byte[] getContent() {
        return content;
    }

    public void setContent(byte[] content) {
        this.content = content;
    }
}
/**
 * todo 自定义编码器
 */
public class MyMessageEncoder extends MessageToByteEncoder<MyMessageProtocol> {
    @Override
    protected void encode(ChannelHandlerContext ctx, MyMessageProtocol msg, ByteBuf out) throws Exception {
        System.out.println("MyMessageEncoder encode 方法被调用");
        out.writeInt(msg.getLen());
        out.writeBytes(msg.getContent());
    }
}
/**
 * todo 自定义解码器
 */
public class MyMessageDecoder extends ByteToMessageDecoder {

    private int length = 0;

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        System.out.println("MyMessageDecoder decode 被调用");
        //todo 需要将得到二进制字节码-> MyMessageProtocol 数据包(对象)
        if(in.readableBytes() >= 4) {
            if (length == 0){
                length = in.readInt();
            }
            if (in.readableBytes() < length) {
                System.out.println("当前可读数据不够,继续等待 ...");
                return;
            }
            byte[] content = new byte[length];
            if (in.readableBytes() >= length){
                in.readBytes(content);
                //todo 封装成MyMessageProtocol对象,传递到下一个handler业务处理
                MyMessageProtocol messageProtocol = new MyMessageProtocol();
                messageProtocol.setLen(length);
                messageProtocol.setContent(content);
                out.add(messageProtocol);
            }
            length = 0;
        }
    }
}
//todo 服务端
public class MyServer {
    public static void main(String[] args) throws Exception {
        ServerBootstrap serverBootstrap = new ServerBootstrap();
        serverBootstrap.group(bossGroup, workerGroup)
            .channel(NioServerSocketChannel.class)
            .childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline pipeline = ch.pipeline();
                    //todo 添加自定义解码器
                    pipeline.addLast(new MyMessageDecoder());
                    pipeline.addLast(new MyServerHandler());
                }
            });
    }
}
//todo 服务端 handler
public class MyServerHandler extends SimpleChannelInboundHandler<MyMessageProtocol> {
    private int count;
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, MyMessageProtocol msg) throws Exception {
        System.out.println("====服务端接收到消息如下====");
        System.out.println("长度=" + msg.getLen());
        System.out.println("内容=" + new String(msg.getContent(), CharsetUtil.UTF_8));
        System.out.println("服务端接收到消息包数量=" + (++this.count));
    }
}
//todo 客户端
public class MyClient {
    public static void main(String[] args)  throws  Exception{
        bootstrap.group(group).channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();
                        //todo 添加自定义编码器
                        pipeline.addLast(new MyMessageEncoder());
                        pipeline.addLast(new MyClientHandler());
                    }
                });
    }
}
//todo 客户端 handler
public class MyClientHandler extends SimpleChannelInboundHandler<MyMessageProtocol> {
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        for(int i = 0; i< 200; i++) {
            String msg = "你好,我是张三!";
            //创建协议包对象
            MyMessageProtocol messageProtocol = new MyMessageProtocol();
            messageProtocol.setLen(msg.getBytes(CharsetUtil.UTF_8).length);
            messageProtocol.setContent(msg.getBytes(CharsetUtil.UTF_8));
            ctx.writeAndFlush(messageProtocol);
        }
    }
}

三、Netty零拷贝


1、直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,某些情况下这部分内存也会被频繁地使用,而且也可能导致OutOfMemoryError异常出现。Java里用DirectByteBuffer可以分配一块直接内存(堆外内存),元空间对应的内存也叫作直接内存,它们对应的都是机器的物理内存。

2、Netty零拷贝

Netty的接收和发送数据采用堆外直接内存,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。

如果使用传统的JVM堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存数据拷贝一份到直接内存中,然后才能写入Socket中。JVM堆内存的数据是不能直接写入Socket中的。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

3、直接内存的优缺点

优点:

  • 不占用堆内存空间,减少了发生GC的可能
  • java虚拟机实现上,本地IO会直接操作直接内存,而非直接内存则需要二次拷贝(堆内存=>直接内存=>系统调用=>硬盘/网卡)

缺点:

  • 初始分配较慢
  • 没有JVM直接帮助管理内存,容易发生内存溢出