Netty—TCP的粘包和拆包问题

一.前言

虽然TCP协议是可靠性传输协议,但是对于TCP长连接而言,对于消息发送仍然可能会发生粘贴的情形。主要是因为TCP是一种二进制流的传输协议,它会根据TCP缓冲对包进行划分。有可能将一个大数据包拆分成多个小的数据包,也有可能将多个小的数据包合并成一个数据包。

本篇文章将对TCP粘包和拆包进行介绍:

  • TCP粘包拆包问题及现象
  • 解决方式

### 二.TCP粘包拆包问题及现象

假设Client端发送两个数据包给Server端,如下图:

但是Server端实际接收到的数据包形式可能存在以上三种形式:

  1. 第一种形式是接收到一个数据包,其中客户端发送的两个数据包粘贴在一起。如:client端分别发送两个数据包都为Hello World,但是Server端只收到一个数据包为Hello WorldHello World。这就属于TCP粘包
  2. 第二种形式是先接受到了第一个数据包的一部分,然后又接收到了另外一部分和第二个数据包粘贴。同样以上例为准,Server端先接收到了Hell,然后又接收到了o WorldHello World。这属于TCP拆包
  3. 第三种形式也是接收到两个数据包,但是是先接收到了第一个数据包和第二个数据包的一部分的粘贴,然后又接收到第二个数据包的另外一部分。这就属于TCP粘包拆包

无论是以上哪种情况,从应用层的角度而言,Server端都将处理错误。首先以没有考虑TCP拆包和粘包的场景为例,分析下TCP拆包粘包将造成什么样的现象:

1.客户端编码:

public static class EchoClientHandler extends ChannelHandlerAdapter {

    static final String ECHO_REQ = "Hi, huaijin.Welcome to Netty.";

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        // 发送100次消息至server端
        for (int i = 0; i < 100; i++) {
            System.out.println("This is " + (i + 1) + " times send server: [" + ECHO_REQ + "]");
            ctx.writeAndFlush(Unpooled.copiedBuffer(ECHO_REQ.getBytes()));
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        ctx.close();
    }
}

这里关于Client的启动代码省略,重点关注业务Handler。其中Echo总共发送了100次消息给Server,如果按照正确的情形,Server端应该接受到100次,然后分别进行处理。但是实际的情形并不是这样。

2.服务端编码

public static class EchoServerHandler extends ChannelHandlerAdapter {

    /**
     * 原子计数器,统计接受到的次数
     */
    private AtomicInteger counter = new AtomicInteger(0);

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 接受到消息打印
        String body = (String) msg;
        System.out.println("This is " + counter.incrementAndGet() + " times receive client: [" + body + "]");
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        ctx.close();
    }
}

服务端中使用原子计数器统计接收到的包的次数并打印接受到的消息。下面运行下实例,客户端输出如下:

This is 1 times send server: [Hi, huaijin.Welcome to Netty.]
This is 2 times send server: [Hi, huaijin.Welcome to Netty.]
This is 3 times send server: [Hi, huaijin.Welcome to Netty.]
This is 4 times send server: [Hi, huaijin.Welcome to Netty.]
.... 中间部分省略
This is 97 times send server: [Hi, huaijin.Welcome to Netty.]
This is 98 times send server: [Hi, huaijin.Welcome to Netty.]
This is 99 times send server: [Hi, huaijin.Welcome to Netty.]
This is 100 times send server: [Hi, huaijin.Welcome to Netty.]

从中可以看出,Client端总共发送了100条消息至Server,但是Server端接收情况如下:

This is 1 times receive client: [Hi, huaijin.Welcome to Netty.]
This is 2 times receive client: [Hi, huaijin.Welcome to Netty.Hi, huaijin.Welcome to Netty.]
This is 3 times receive client: [Hi, huaijin.Welcome to Netty.Hi, huaijin.Welcome to Netty.]
This is 4 times receive client: [Hi, huaijin.Welcome to Netty.Hi, huaijin.Welcome to Netty.]
This is 5 times receive client: [Hi, huaijin.Welcome to Netty.]
... 省略
This is 69 times receive client: [Hi, huaijin.Welcome to Netty.]
This is 70 times receive client: [Hi, huaijin.Welcome to Netty.Hi, huaijin.Welcome to Netty.Hi, hu]
This is 71 times receive client: [aijin.Welcome to Netty.Hi, huaijin.Welcome to Netty.]
This is 72 times receive client: [Hi, huaijin.Welcome to Netty.]
... 省略
This is 84 times receive client: [Hi, huaijin.Welcome to Netty.Hi, huaijin.Welcome to Netty.]
This is 93 times receive client: [Hi, huaijin.Welcome to Netty.]
This is 94 times receive client: [Hi, huaijin.Welcome to Netty.]

由于发生了粘包导致Server端只接收到94次,其中有两条消息粘合在一起。

有以上的情形可以看出,当应用使用长连接并发发送请求时,会造成Server端接收到的请求数据发生混乱,从而处理错误。


### 三.解决方式

关于TCP拆包粘包的解决方式有很多,目前的主流解决方式有以下几种:

  1. 使用定长消息,Client和Server双方约定报文长度,Server端接受到报文后,按指定长度解析;
  2. 使用特定分隔符,比如在消息尾部增加分隔符。Server端接收到报文后,按照特定的分割符分割消息后,再解析;
  3. 将消息分割为消息头和消息体两部分,消息头中指定消息或者消息体的长度,通常设计中使用消息头第一个字段int32表示消息体的总长度;

当然netty作为成熟框架,提供了多种方式解决TCP的拆包粘包问题,通常称作为半包解码器。

netty中提供了基于分隔符实现的半包解码器和定长的半包解码器:

  • LineBasedFrameDecoder使用"\n"和"\r\n"作为分割符的解码器
  • DelimiterBasedFrameDecoder使用自定义的分割符的解码器
  • FixedLengthFrameDecoder定长解码器

这里仍然以上例为主,使用DelimiterBasedFrameDecoder作为半包解码器。

1.客户端编码

public static class EchoClientHandler extends ChannelHandlerAdapter {

    /**
     * 消息使用"$_"分割
     */
    static final String ECHO_REQ = "Hi, huaijin.Welcome to Netty.$_";

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        for (int i = 0; i < 100; i++) {
            System.out.println("This is " + (i + 1) + " times send server: [" + ECHO_REQ + "]");
            ctx.writeAndFlush(Unpooled.copiedBuffer(ECHO_REQ.getBytes()));
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        cause.printStackTrace();
        ctx.close();
    }
}

客户端代码改动较小,只是每条消息后使用分割符"$_"分割,然后发送消息。

2.服务端编码

服务端需要使用分割符解码器,利用其对粘包消息进行拆分:

/**
 * netty实现echo server
 *
 * @author huaijin
 */
public class EchoServer {

    public void bind(int port) throws InterruptedException {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workGroup = new NioEventLoopGroup();

        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workGroup)
                    .channel(NioServerSocketChannel.class)
                    .option(ChannelOption.SO_BACKLOG, 100)
                    .handler(new LoggingHandler(LogLevel.INFO))
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) throws Exception {
                            // 使用分隔符"$_"的半包解码器
                            ByteBuf byteBuf = Unpooled.copiedBuffer("$_".getBytes());
                            ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024, byteBuf));
                            ch.pipeline().addLast(new StringDecoder());
                            ch.pipeline().addLast(new EchoServerHandler());
                        }
                    });

            ChannelFuture f = b.bind(port).sync();
            f.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new EchoServer().bind(8080);
    }


    public static class EchoServerHandler extends ChannelHandlerAdapter {

        /**
         * 原子计数器,统计接受到的次数
         */
        private AtomicInteger counter = new AtomicInteger(0);

        @Override
        public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
            // 接受到消息打印
            String body = (String) msg;
            System.out.println("This is " + counter.incrementAndGet() + " times receive client: [" + body + "]");
        }

        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
            ctx.close();
        }
    }
}

当再次运行客户端和服务端代码时,服务端表现正常,接收到了100次:

This is 1 times receive client: [Hi, huaijin.Welcome to Netty.]
This is 2 times receive client: [Hi, huaijin.Welcome to Netty.]
This is 3 times receive client: [Hi, huaijin.Welcome to Netty.]
... 省略
This is 98 times receive client: [Hi, huaijin.Welcome to Netty.]
This is 99 times receive client: [Hi, huaijin.Welcome to Netty.]
This is 100 times receive client: [Hi, huaijin.Welcome to Netty.]

### 四.总结

本篇文章主要介绍了什么是TCP的拆包和粘包,并展示了拆包和粘包带来的现象。并通过netty提供的方案,是如何解决TCP拆包和粘包问题。

posted @ 2019-02-20 11:00  怀瑾握瑜XI  阅读(307)  评论(0编辑  收藏  举报