Netty进阶 黏包与半包问题的处理,数据解码器详解

概述

假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到的字节数是不确定的,故可能存在以下情况。

(1)服务端一次接收到了两个数据包,D1和D2粘合在一起,被称为TCP黏包;

(2)服务端分两次读取到了两个数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这被称为TCP半包;

NIO的解决方法:

客户端和服务器约定一个包的固定大小,服务器按预定长度读取,缺点是浪费宽带。

客户端发送消息的时候指定一个分隔符,如\n,但是此方法效率较低,需要一个一个去找分隔符。

(常用)跟TCP一样,TLV类型,(Type,Length,Value),数据包含有一个固定大小的包头,指定后面的数据有多大,分配合适的buffer,缺点是buffer需提前分配,如果内容过大,影响server吞吐量

只要使用的是TCP协议传输都会有黏包和半包问题

Netty基础 NIO SocketChannel 网络编程 自制小型服务器,多线程优化*_清风拂来水波不兴的博客-CSDN博客

上面的这段描述是Nio学习时的解决方法,比较复杂,可以参考之前Nio的教程,链接如上。

Netty已经给我们封装好了一系列解决方法,下面将讲解Netty的黏包和半包问题

Netty消息边界的处理

黏包演示

服务器代码:

@Slf4j
public class NettyServer {
    public static void main(String[] args) {
        NioEventLoopGroup boss = new NioEventLoopGroup();
        NioEventLoopGroup worker = new NioEventLoopGroup(7);
        ServerBootstrap bootstrap = new ServerBootstrap()
                .group(boss, worker)
                .channel(NioServerSocketChannel.class);
        bootstrap.childHandler(new ChannelInitializer() {
            @Override
            protected void initChannel(NioSocketChannel ch) throws Exception {
                ch.pipeline().addLast(new LoggingHandler());
                ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                    @Override
                    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                        ByteBuf buf = (ByteBuf) msg;
                        System.out.println(Thread.currentThread().getName()+buf.toString());
                    }
                });
            }
        });
        try {
            ChannelFuture channelFuture = bootstrap.bind(8081).sync();
            channelFuture.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            log.error("server error",e);
            e.printStackTrace();
        }finally {
            boss.shutdownGracefully();
            worker.shutdownGracefully();
        }
    }
}

客户端代码:

@Slf4j
public class NettyClient {
    public static void main(String[] args) {
        NioEventLoopGroup group = new NioEventLoopGroup();
        Bootstrap bootstrap = new Bootstrap().group(group).channel(NioSocketChannel.class)
                .handler(new ChannelInitializer() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                            @Override
                            //会在channel建立好后触发Active事件
                            public void channelActive(ChannelHandlerContext ctx) throws Exception {
                                for (int i = 0; i < 10; i++) {
                                    ByteBuf buf=ctx.alloc().buffer(16);
                                    buf.writeBytes(new byte[]{'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'});
                                    ctx.writeAndFlush(buf);
                                }
                            }
                        });
                    }
                });

        try {
            ChannelFuture future = bootstrap.connect(new InetSocketAddress("localhost", 8081)).sync();
            future.channel().closeFuture().sync();
        } catch (InterruptedException e) {
           log.error("client",e);
        }finally {
            group.shutdownGracefully();
        }
    }
}

解读:客户端连接至服务器之后循环10次,每次发送16个字节的数据,服务器接收之后打印出来。

结果如下:Netty进阶 黏包与半包问题的处理,数据解码器详解_第1张图片

 可以看到,服务器把160B当做一个包来处理了,而我们预期的是服务器接收10次,这就是典型的一个黏包现象,

半包演示

在服务器代码设置全局接收缓冲区为10个字节

Netty进阶 黏包与半包问题的处理,数据解码器详解_第2张图片

 结果如下

Netty进阶 黏包与半包问题的处理,数据解码器详解_第3张图片

 第一次32B,第二次50B,既有黏包也有半包问题,但为什么不是每次10B呢?具体还是跟tcp缓冲区采用的算法,以及接收方接收数据包到缓存的速率和应用程序从tcp缓冲区读取数据的速率有关

TCP滑动窗口

要理解原理需要知道TCP协议的一些只是,这里只做比较粗略的解释^_^

TCP以一个段(segment)为单位,每发送一个段就需要进行一次确认应答(ack)处理,但如果这么做,缺点是包的往返时间越长性能就越差

客户端和服务器都维护了一个滑动窗口,服务一次最多只能接收一定字节数量的数据,进行流量控制和缓冲数据。

传输层可以把数据分段,把一个完整的数据分成几个部分,这样就有可能一个窗口内不能发出所有数据,造成半包问题;接收方的窗口如果比较空闲,一次性把好几个部分的数据都接收了就发生了黏包。

现象分析

黏包:

  • 应用层:接收方ByteBuf设置太大(Netty默认1024)
  • TCP滑动窗口:假设发送方256 bytes 表示一个完整报文,但由于接收方处理不及时且窗口大小足够大,这256 bytes字节就会缓冲在接收方的滑动窗口中,当滑动窗口中缓冲了多个报文就会粘包
  • TCP Nagle算法:会造成粘包

半包:

  • 应用层:接收方ByteBuf 小于实际发送数据量
  • 滑动窗口:假设接收方的窗口只剩了128 bytes,发送方的报文大小是256 bytes,这时放不下了,只能先发送前128 bytes,等待ack后才能发送剩余部分,这就造成了半包
  • MSS限制:当发送的数据超过MSS限制后,会将数据切分发送,就会造成半包

解决方案

1.短连接

客户端发起连接,发送完我认为的一条完整消息,就立刻断开连接,消息的连接和建立就当做消息的边界。可以看出此方法比较鸡肋。

服务器代码不变,客户端代码稍微调整了一下,即主线程循环十次,每次代码都是新的对象,新的连接。发生了10次tcp连接

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        send();
    }
}
private static void send() {
          //省略……
   ByteBuf buf=ctx.alloc().buffer(16);
   buf.writeBytes(new byte[]{'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'});
   ctx.writeAndFlush(buf);
   //发送完后就立即把channel进行关闭
    ctx.channel().close();

         //省略……
}

 结果正确解决了黏包问题。

但是这种方法却不能解决半包问题

我们调整服务器的接收缓冲区,调成16,每次只能从对应的channl里接收16字节(因为这是最小值),客户端改为每次发送18字节的数据。

ServerBootstrap bootstrap = new ServerBootstrap()
  //调整系统的接收缓存去(滑动窗口),默认1024
//  .option(ChannelOption.SO_RCVBUF,10)//option是针对全局的
  //调整netty的接收缓冲区(ByteBuf大小),这个参数指一次从接收缓冲区最多读多少,默认1024,最小16
  //childOption是针对每个channel连接的接收的
 .childOption(ChannelOption.RCVBUF_ALLOCATOR,new AdaptiveRecvByteBufAllocator(16,16,16))
 .group(boss, worker)
 .channel(NioServerSocketChannel.class);

Netty进阶 黏包与半包问题的处理,数据解码器详解_第4张图片

 可以看到半包问题出现了,客户端虽然每次一次性发18字节,但是服务器一次读不了那么多,分两次读取了。把原本一条完整的数据分成了两个数据

2.定长解码器

这是Netty自带的一种解决这种黏包半包的问题的。如下package io.netty.handler.codec包下的类

Netty进阶 黏包与半包问题的处理,数据解码器详解_第5张图片

Netty进阶 黏包与半包问题的处理,数据解码器详解_第6张图片

  1. 服务器和客户端约定每个消息的字节长度位3字节
  2. 服务器接收到A后,发现不足3字节,先不把A传给下一个Handler,等到下次接收到数据长度够3的时候,再截取成3字节发送到下一个Handler
  3. 当服务器接收到DEFG时,把DEF传给下一个Handler进行处理,剩下的G留给后序的数据

服务器代码:

@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
    //设定固定的消息大小为3字节,不出意外都放最前面
    ch.pipeline().addLast(new FixedLengthFrameDecoder(3));
    ch.pipeline().addLast(new LoggingHandler());
    ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            ByteBuf buf = (ByteBuf) msg;
            System.out.println(Thread.currentThread().getName()+buf.toString());
        }
    });

客户端:

buf.writeBytes(new byte[]{'A'});
buf.writeBytes(new byte[]{'B','C'});
buf.writeBytes(new byte[]{'D','E','F','G'});
buf.writeBytes(new byte[]{'H','I'});

 可以看到结果每次固定三字节。Netty进阶 黏包与半包问题的处理,数据解码器详解_第7张图片

 客户端发送数据如果一条消息少于规定的字符数,必须在后面填充数据,达到规定的字节数,缺点就是浪费资源

3.行解码器

使用分隔符来确定消息的边界,客户端和服务器确定一个结尾分隔符,服务器一个一个解码,如果读到该分隔符,表明当前读取的数据包为一个,开始读取下一个包。

Netty已经提供了两种解码器如下

1)以换行符作为分隔符的类,也就是支持"\n"和"\r\n"作为分隔符,有兴趣的可查看decode源码

Netty进阶 黏包与半包问题的处理,数据解码器详解_第8张图片

 该类其中的maxLength为最大接收长度,如果迟迟遇不到分隔符,达到maxLength后会抛异常。

2)自定义解码器,由我们自己diy分隔符

Netty进阶 黏包与半包问题的处理,数据解码器详解_第9张图片

演示:

同样在服务器代码中给第一个管道加入一个以换行符结尾的解码器

ch.pipeline().addLast(new LineBasedFrameDecoder(2048));

 客户端发送一段消息,每条独立的消息以换行符结尾

buf.writeBytes(new byte[]{'1','2','\r','\n','3','4','5','\n','6','\n'});

服务器正确的接收到12  345  6这三条消息。

缺点:服务器需要每个每个字符的去查找分隔符,效率较低;处理字符数据比较合适,但如果内容本身包含了分隔符(字节数据常常会有此情况),那么就会解析错误

4.LengthFieldBasedFrameDecoder解码器*

基于长度解析字段,看如下类

Netty进阶 黏包与半包问题的处理,数据解码器详解_第10张图片

几个比较重要的字段 

  • lengthFieldOffset:整个完整消息长度字段的偏移量
  • lengthFieldLength:长度字段所占用的字节长度,数据包的最大字节数为2^(该长度*8)
  • lengthAdjustment:长度字段为基准,还有几个字节是内容
  • lengthBytesToStrip:从头剥离几个字节

 源码提供了很多的说明文档,这里我带大家看一看:

1)

Netty进阶 黏包与半包问题的处理,数据解码器详解_第11张图片

lengthFieldOffset为0表我这个消息的第0字节开始就是代表我的长度的,lengthFieldLength为2表示我一共用2个字节来表示我这个数据的长读,长度0x00C十机制为12,也就是"HELLO, WORLD"

2)

Netty进阶 黏包与半包问题的处理,数据解码器详解_第12张图片

 lengthBytesToStrip为2表示从头开始把前两个字节的数据剥离出来

3)

Netty进阶 黏包与半包问题的处理,数据解码器详解_第13张图片

 lengthAdjustment为2表示从长度字段之后再调整2个字节的数据才是数据内容

 4)

Netty进阶 黏包与半包问题的处理,数据解码器详解_第14张图片

相信看了前面三个示例,这里大家都能看懂为什么AFTER DECODER为此数据了吧!

当该解码器发现实际收到的消息长度少于帧中的消息长度,那他就不会立马交给下一个Handler,他会继续接收,等收到的消息到达指定的长度后,才算接收完一个完整消息,发给下一个handler

我们的服务器如果采用此解码器解析数据,我们就需要设计应用层协议,设计一个统一的消息格式

演示

同样在Pipeline第一个位置加入该Handler,构造方法如下,需要根据自己设定的协议构造

第一个参数为最大帧长度,后面四个就是我们前面讲过的四个字段!

 客户端设计一个简单的方法,设计一个固定消息格式的协议,也就是发送消息前把该消息头部加一个长度标识。

    //设计协议,把Str的数据写到buf前,把长度写入
    public static void writeBuf(ByteBuf buf,String str){
        byte[] bytes = str.getBytes();
        buf.writeInt(bytes.length);//长度为首
        buf.writeBytes(bytes);
    }

发送如下数据

Netty进阶 黏包与半包问题的处理,数据解码器详解_第15张图片

 结果为:

 

 可以看到服务器能够正确的分割消息,但是前面的长度给我们也打印出来了,我们可以在构造方法里把lengthBytesToStrip设置为4,这时候就不会显示上面的无用字符了,反正这些都是我们根据需求设计的

LengthFieldBasedFrameDecoder解码器特别适合我们下一篇的自定义协议消息格式,让你欣赏下它的真正威力!

 下一篇讲解自定义协议设计与解析

你可能感兴趣的:(netty,java,开发语言,netty)