Netty框架学习02-TCP粘包/拆包解决之道

粘包,拆包说明

TCP是个流的协议,就是没有界限的一串数据。他底层不了解业务数据的具体含义,他的数据数据传输的划分是根据TCP的缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓 的TCP粘包拆包。

下面做出详细的说明。

                                                  TCP粘包/拆包问题模拟图

假设客户端分贝发送 了两个数据包D1和D2给服务器,由于服务器端一次读取到的字节是不确定的,所以存在一下四种情况:

1、服务端收到了两个独立的数据包 D,D2

2、服务端接收的数据包D1D2沾在一起,称为tcp粘包

3、服务器接收两个数据包,第一个是完整的D1和D2的一部分,第二个包是D2的一部分,这被称为拆包

4、服务第一个包是D1的一部分,第二个包是D1的剩余部分加上D2的全部数据,这个情况是第三种情况类似

粘包,拆包的 产生原因

1、应用程序write写入的字节大小大于套接接口发送缓冲区的大小;

2、进行MSS大小的TCP分段;

3.以太网帧的payload大于MTU进行IP分片。

具体如下图:

粘包问题的解决策略

由于底层的TCP无法理解上层业务的数据,所以在底层是无法保证数据包不被拆分和重组的,这个问题只能通过上层的应用协议栈来解决,根据业界的主流协议的解决方案,方法可以归纳如下:

1.消息定长。例如每个报文的大小为固定长度200字节,如果不够,空位补空格;

2.在包尾增加回车换行符进行分割,例如ftp协议;

3.将消息分为消息头和消息体,消息头中包含表示消息总长度的字段,通常表设计思路为消息头的第一个字段使用int32来表示消息的总长度;

4.更复杂的应用层协议;  

demo

在前面的客户端服务器的示例中没有考虑半包问题,功能测试往往没有问题,但是一旦压力上来了,或者发送大报文之后,就会存在粘包/拆包问题。

对demo的改造:

服务端代码调整如下:

package com.soecode.lyf.demo.test.netty.server;


import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;

import java.util.Date;

/**
 * Netty时间服务器服务端
 * <p>
 * 主要用于对网络事件进行读写操作,
 * 通常我们只关心channelRead和exceptionCaught方法。
 *
 * @author 魏文思
 * @date 2019/11/22$ 10:06$
 */
public class TimeServerHandler extends ChannelHandlerAdapter {
    private  int  counter;
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        /**
         * 将msg转换成了Netty的ByteBuf对象。Bytebuf类似于jdk中的Java.nio.ByteBuffer对象,不过他提供 了更加强大和灵活的功能。
         * 通过ByteBuf的readablebytes方法可以获取缓冲区可读取的字节数,根据可读的字节数创建byte数组,通过ByteBuf的readBytes方法可以获取缓冲区可读的字节数,
         * 根据可读的字节数创建byte数组,通过Bytebuf的readBytes方法将缓冲区中的字节数组复制到新建的byte数组中,最后通过new String的构造函数获取请求消息。
         * 这时对请求消息进行判断,如果是QUERY TIME ORDER 则创建应答消息,通过ChannelHandlerContent的write方法异步发送应答消息给客户端。
         */
        ByteBuf buf = (ByteBuf) msg;
        byte[] req = new byte[buf.readableBytes()];
        buf.readBytes(req);
        /**
         * 解决粘包拆包问题,每读到一条消息,就统计一次,然后发送应答消息给客户端。服务端客户端接收到的消息一致
         */
        String body = new String(req, "UTF-8").substring(0,req.length-System.getProperty("line.separator").length());
        System.out.println("the  time server receive order ;" + body+"the counter is :"+ ++counter);
        String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date(System.currentTimeMillis()).toString() : "BAD ORDER";
        ByteBuf resp = Unpooled.copiedBuffer(currentTime.getBytes());
        ctx.write(resp);
    }

    @Override
    public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
        /**
         * 调用flush()方法它的作用是将消息发送队列中的消息写入到SocketChannel中发送给对方。
         * 从性能角度考虑,为了防止频繁你唤醒Selector进行消息发送,Netty的write方法并不直接将消息写入SocketChannel中,调用write方法只是把待发送的消息发送到缓存数组中,
         * 再通过调用flush方法,将发送缓存区中的消息全部写道Socketchannel中。
         *
         * 这种涉及思想在批量插入数据库的有类似操作。提高性能
         *
         */
        ctx.flush();
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        /**
         * 当发生异常时,关闭ChannelHandlerContext,释放和ChannelhanlerContext相关联的句柄资源。
         */
        ctx.close();
    }
}

没读到一条消息,就统计一次,然后发送应答消息给客户端。按照设计,服务端接收到的消息总数应该跟客户端发送的消息总数一致,而且请求消息删除回车符后应该为“QUERY TIME ORDER“

客户端的改造:

package com.soecode.lyf.demo.test.netty.client;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;
import lombok.extern.slf4j.Slf4j;

/**
 * @author 魏文思
 * @date 2019/11/22$ 14:07$
 */
@Slf4j
public class TimeClientHandler extends ChannelHandlerAdapter {
    private final ByteBuf firstMessage;
    private int counter;
    byte[] req;

    public TimeClientHandler() {
        /**
         * System.getProperty("line.separator")  换行符,不区分linux和windows这么写更保险
         */
        req = ("QUERY TIME ORDER".getBytes() + System.getProperty("line.separator")).getBytes();
        firstMessage = Unpooled.buffer(req.length);
        firstMessage.writeBytes(req);

    }

    /**
     * 当客户端和服务端tcp链路建立连接之后,Netty的NIO线程会调用channelActive,
     * 发送查询时间的指令给服务端,调用ChannelhandlerContext的writeAndflush方法将请求消息发送给服务端
     *
     * @param ctx
     * @throws Exception
     */

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        //循环发送一百条消息
        ByteBuf message = null;
        for (int i = 0; i < 100; i++) {
            message = Unpooled.buffer(req.length);
            message.writeBytes(req);
            ctx.writeAndFlush(message);
        }

        log.info("调用了 netty客户端处理器的适配器 的channelActive 方法");
    }

    /**
     * 当服务端返回应答消息时,channelRead方法被调用,将消息打印
     *
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        System.out.println("channelRead");
        ByteBuf buf = (ByteBuf) msg;
        byte[] req = new byte[buf.readableBytes()];
        buf.readBytes(req);
        String body = new String(req, "UTF-8");
        System.out.println("Now is :" + body+";the counter is :"+ ++counter);
    }

    /**
     * 当发生异常时,打印异常日志,释放客户端资源
     *
     * @param ctx
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        System.out.println("exceptionCaught");
        //释放资源
        log.warn("Unexceped exception from  downstream :" + cause.getMessage());
        ctx.close();
    }
}

       主要修改部分是channelActive方法里面的发送消息的业务。客户端和服务端链路建立成功之后,循环发送100条消息,每发送一条就刷新一次,保证每条消息都会被写入Channel中。按照我们的设计,服务端应该接受100次查询时间指令的请求。

       客户端每接受到服务端一条应答消息之后,就打印一次计数器

( System.out.println("Now is :" + body+";the counter is :"+ ++counter);)。

按照设计初衷,客户端应该打印100次服务器的系统时间。接下来看实际打印结果:

服务端:

the  time server receive order ;

[B@60d7912b
[B@60d7912b
...//此处省略60条
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d79the counter is :1
the  time server receive order ;b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912b
[B@60d7912bthe counter is :2

       服务端的运行结果表明它只接受到了两个消息,第一条消息包含了80条指令,第二条消息包含了20条指令,总数正好是100条。我们期望的是收到100条消息,每条包含一条"QUERY TIME ORDER"指令。这说明发生了粘包。

客户端:

channelRead
Now is :BAD ORDERBAD ORDER;the counter is :1

按照我们的设计初衷,客户端应该接受到100条消息,但是实际上只接受了一条。

因为服务端只接受到了2条请求消息,所以实际服务端只发送了2条应答,由于请求消息不满足查询条件,所以只返回了两条“BAD error消息”,说明服务端返回的消息也发生了粘包。所以当发生粘包拆包时我们的程序就无法正常工作。

怎么解决?使用LineBasedfraneDecoder解决TCP粘包问题

Nettry提供了多种编码器用于处理半包。只需要会使用就可以很容易解决这个问题。

代码改造

   在服务端的TimeServer里面加入他的处理粘包的连个解码器,LineBasedFrameDecoder和StringDecoder。这两个类的功能后面再补充。

代码如下:

TimeServerhandler的channelRead方法做调整:

  @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        String body = (String) msg;
        System.out.println("the  time server receive order ;" + body+"the counter is :"+ ++counter);
        String currentTime = "QUERY TIME ORDER".equalsIgnoreCase(body) ? new Date(System.currentTimeMillis()).toString() : "BAD ORDER";
        currentTime=currentTime+System.getProperty("line.separator");
        ByteBuf resp = Unpooled.copiedBuffer(currentTime.getBytes());
        ctx.writeAndFlush(resp);
    }

客户端

TImeClient做调整

TimeClientHandler调整之后

package com.soecode.lyf.demo.test.netty.client;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerAdapter;
import io.netty.channel.ChannelHandlerContext;
import lombok.extern.slf4j.Slf4j;

/**
 * @author 魏文思
 * @date 2019/11/22$ 14:07$
 */
@Slf4j
public class TimeClientHandler extends ChannelHandlerAdapter {
    private int counter;
    byte[] req;

    public TimeClientHandler() {
        /**
         * System.getProperty("line.separator")  换行符,不区分linux和windows这么写更保险
         */
        req = ("QUERY TIME ORDER".getBytes() + System.getProperty("line.separator")).getBytes();
    }

    /**
     * 当客户端和服务端tcp链路建立连接之后,Netty的NIO线程会调用channelActive,
     * 发送查询时间的指令给服务端,调用ChannelhandlerContext的writeAndflush方法将请求消息发送给服务端
     *
     * @param ctx
     * @throws Exception
     */

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        //循环发送一百条消息
        ByteBuf message = null;
        for (int i = 0; i < 100; i++) {
            message = Unpooled.buffer(req.length);
            message.writeBytes(req);
            ctx.writeAndFlush(message);
        }

        log.info("调用了 netty客户端处理器的适配器 的channelActive 方法");
    }

    /**
     * 当服务端返回应答消息时,channelRead方法被调用,将消息打印
     *
     * @param ctx
     * @param msg
     * @throws Exception
     */
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        //拿到的已经是解码之后的应答消息了
        String body = (String) msg;
        System.out.println("Now is :" + body+";the counter is :"+ ++counter);
    }

    /**
     * 当发生异常时,打印异常日志,释放客户端资源
     *
     * @param ctx
     * @param cause
     * @throws Exception
     */
    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        //释放资源
        log.warn("Unexceped exception from  downstream :" + cause.getMessage());
        ctx.close();
    }
}

调整的核心部分在channelRead方法里面之前的消息需要经过一些列的转化,现在直接就是我们需要的结果。

客户端和服务端的执行结果如下:

服务端:

the  time server receive order ;[B@7f61682bthe counter is :1
the  time server receive order ;[B@7f61682bthe counter is :2
....//此处省略了3-94行
the  time server receive order ;[B@7f61682bthe counter is :95
the  time server receive order ;[B@7f61682bthe counter is :96
the  time server receive order ;[B@7f61682bthe counter is :97
the  time server receive order ;[B@7f61682bthe counter is :98
the  time server receive order ;[B@7f61682bthe counter is :99
the  time server receive order ;[B@7f61682bthe counter is :100

客户端:

Now is :BAD ORDER;the counter is :1
...//此处省略2-96行
Now is :BAD ORDER;the counter is :97
Now is :BAD ORDER;the counter is :98
Now is :BAD ORDER;the counter is :99
Now is :BAD ORDER;the counter is :100

这样就实现了数据包半包问题的解决,是不是很简单,核心就是配置两个编码解码处理器。

LineBasedFrameDecoder和StringDecoder实现原理

        LineBasedFrameDecoder的工作原理就是它依次遍历ByteBuf中可读字节,判断是否有“\n”或者“\r\n”,如果有,就以此位置为结束位置,从可读索引到结束位置区间的字节就组成了一行。他是以换行符为结束标志的解码器,支持携带结束或者不携带结束符两种编码解码方式,同时支持配置单行的最大长度。如果连续读取到最大长度后仍然没有发现换行符,就会抛出异常,同时忽略掉之前读到的异常码流。

        StringDecoder的功能非常简单,就是将接受到的对象转成字符串,然后继续调用后面的Handler.LineBasedFrameDecoder+StringDecoder组合就是按行切换的文本解析器,它被设计用来支持TCP的粘包和拆包。

       如果消息格式不是以换行符结束的怎么办?当然Netty提供了多种支持TCP粘包、拆包的解码器,来满足用户的不同需求。

发布了90 篇原创文章 · 获赞 11 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/qq_35410620/article/details/103232569