Netty 解决TCP粘包/半包使用

网络通信时,如何解决粘包/半包、丢包或者包乱序的问题?

  • 如果是 TCP 协议,面向连接(经历三次握手和四次挥手)、传输可靠((保证数据正确性,保证数据顺序)),在大多数场景下,是不存在丢包和包乱序问题的,因为 TCP 通信是可靠通信方式,TCP 协议栈通过序列号和包重传应答确认机制保证数据包的有序和一定被正确发到目的地;
  • 如果是 UDP 协议,面向非连接、传输不可靠(丢包[数据丢失])。如果不能接受少量丢包,那就要自己在 UDP 的基础上实现类似 TCP 这种有序和可靠传输机制了(例如 RTP协议、RUDP 协议)。

所以,对于 TCP 协议来说,我们需要关注的是如何粘包/半包问题。

说明一下,半包:半包不是说只收到了全包的一半,而是说收到了全包的一部分,有时我们叫拆包。

一、TCP粘包/半包

1、什么是 TCP 粘包/半包问题?

应用A 通过网络发送数据向应用B 发送消息,大概会经过如下阶段:

  • 阶段一:应用A 把流数据发送到 TCP发送缓冲区。
  • 阶段二:TCP发送缓冲区把数据发送到达 B服务器 TCP接收缓冲区。
  • 阶段三:应用B 从 TCP接收缓冲区读取流数据。

Netty 解决TCP粘包/半包使用_第1张图片

假设应用A 分别发送了两个数据包 D1 和 D2 给应用B,由于应用B一次读取到的字节数是不确定的,故可能存在以下 4 种情况。

(1)应用B 分两次读取到了两个独立的数据包,分别是 D1 和 D2,没有粘包和拆包;

(2)应用B 一次接收到了两个数据包,D1 和 D2 粘合在一起,被称为 TCP 粘包;

(3)应用B 分两次读取到了两个数据包,第一次读取到了完整的 D1 包和 D2 包的部分内容,第二次读取到了 D2 包的剩余内容,这被称为 TCP 拆包;

(4)应用B 分两次读取到了两个数据包,第一次读取到了 D1 包的部分内容 D1_1,第二次读取到了 D1 包的剩余内容 D1_2 和 D2 包的整包,这被称为 TCP 拆包;

由于 TCP 协议本身的机制,它会存在 TCP 粘包/半包问题。

TCP发送数据原由:

  • 因为TCP本身传输的数据包大小就有限制,所以应用发出的消息包过大,TCP会把应用消息包拆分为多个TCP数据包发送出去。
  • Negal算法的优化,当应用发送数据包太小,TCP为了减少网络请求次数的开销,它会等待多个消息包一起,打成一个TCP数据包一次发送出去。

TCP接收方的原由:

  • 因为TCP缓冲区里的数据都是字符流的形式,没有明确的边界,因为数据没边界,所以应用从TCP缓冲区中读取数据时就没办法指定一个或几个消息一起读,而只能选择一次读取多大的数据流,而这个数据流中就可能包含着某个消息包的一部分数据。

1.1 发生的原因

粘包的主要原因:

  • 发送方每次写入数据 < 套接字(Socket)缓冲区大小
  • 接收方读取套接字(Socket)缓冲区数据不够及时

半包的主要原因:

  • 发送方每次写入数据 > 套接字(Socket)缓冲区大小
  • 发送的数据大于协议的 MTU (Maximum Transmission Unit,最大传输单元),因此必须拆包。

其实我们可以换个角度看待问题:

  • 从收发的角度看,便是一个发送可能被多次接收,多个发送可能被一次接收。
  • 从传输的角度看,便是一个发送可能占用多个传输包,多个发送可能共用一个传输包。

根本原因,其实是:

  • TCP 是流式协议,消息无边界。
    (PS : UDP 虽然也可以一次传输多个包或者多次传输一个包,但每个消息都是有边界的,因此不会有粘包和半包问题。)

2、如何解决 TCP粘包半包问题

就像上面说的,UDP 之所以不会产生粘包和半包问题,主要是因为消息有边界,因此,我们也可以采取类似的思路。

解决问题的根本手段:找出消息的边界。

2.1 改成短连接

将 TCP 连接改成短连接,一个请求一个短连接。这样的话,建立连接到释放连接之间的消息即为传输的信息,消息也就产生了边界。

这样的方法就是十分简单,不需要在我们的应用中做过多修改。但缺点也就很明显了,效率低下,TCP 连接和断开都会涉及三次握手以及四次握手,每个消息都会涉及这些过程,十分浪费性能。

因此,并不推荐这种方式。

2.2 封装成帧

封装成帧(Framing),也就是原本发送消息的单位是缓冲大小,现在换成了帧,这样我们就可以自定义边界了。

一般有4种方式:

2.2.1 固定长度

这种方式下,消息边界也就是固定长度即可。

优点就是实现很简单,缺点就是空间有极大的浪费,如果传递的消息中大部分都比较短,这样就会有很多空间是浪费的。

因此,这种方式一般也是不推荐的。

2.2.2 分隔符

这种方式下,消息边界也就是分隔符本身。

优点是空间不再浪费,实现也比较简单。缺点是当内容本身出现分割符时需要转义,所以无论是发送还是接受,都需要进行整个内容的扫描。

因此,这种方式效率也不是很高,但可以尝试使用。

2.2.3 专门的 length 字段

这种方式,就有点类似 Http 请求中的 Content-Length,有一个专门的字段存储消息的长度。作为服务端,接受消息时,先解析固定长度的字段(length字段)获取消息总长度,然后读取后续内容。

优点是精确定位用户数据,内容也不用转义。缺点是长度理论上有限制,需要提前限制可能的最大长度从而定义长度占用字节数。

因此,十分推荐用这种方式。

2.2.4 其他方式

其他方式就各不相同了,比如 JSON 可以看成是使用{}是否成对。这些优缺点就需要大家在各自的场景中进行衡量了。

二、Netty 中的实现

Netty 中解决粘包半包

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

Netty 支持上面所讲的封装成帧(Framing)中的前三种方式,简单介绍下:
Netty 解决TCP粘包/半包使用_第2张图片

1、固定长度

FixedLengthFrameDecoder采用的是定长协议:即把固定的长度的字节数当做一个完整的消息。

FixedLengthFrameDecodert提供了以下构造方法:

    public FixedLengthFrameDecoder(int frameLength) {
        ObjectUtil.checkPositive(frameLength, "frameLength");
        this.frameLength = frameLength;
    }
  • frameLength参数:我们指定的消息长度。

注意:FixedLengthFrameDecoder并没有提供一个对应的编码器,因为接收方只需要根据字节数进行判断即可,发送方无需编码。

例如:我们规定每个报文的大小为固定长度 5个字节,表示一个有效报文,如果不够,空位补空格;

1)服务端

            bootstrap.group(bossGroup, workerGroup)
                ...
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        socketChannel.pipeline()
                                .addLast(new FixedLengthFrameDecoder(5)) //固定长度 5个字节
                                .addLast(new FixedLengthServerHandler());
                    }
                });
	@Override
	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		// 获取客户端发送过来的消息
		ByteBuf in = (ByteBuf) msg;
		String request = in.toString(CharsetUtil.UTF_8);
		System.out.println("Server Accept[" + request + "] and the counter is:" + counter.incrementAndGet());
		ctx.writeAndFlush(Unpooled.copiedBuffer("Welcome to Netty!".getBytes()));
	}

2)客户端

            bootstrap.group(eventExecutors)/*将线程组传入*/
                    ...
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            socketChannel.pipeline()
                                    .addLast(new FixedLengthFrameDecoder(5)) //固定长度 5个字节
                                    .addLast(new FixedLengthClientHandler());
                        }
                    });
	@Override
	protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) throws Exception {
		// 接收服务端发送过来的消息
		System.out.println("client Accept[" + msg.toString(CharsetUtil.UTF_8) + "] and the counter is:" + counter.incrementAndGet());
	}

	/**
	 * 客户端被通知channel活跃后 channel活跃后,做业务处理
	 */
	@Override
	public void channelActive(ChannelHandlerContext ctx) throws Exception {
		// 发送消息到服务端
		ByteBuf in1 = Unpooled.buffer().writeBytes("CHARGE".getBytes());
		ByteBuf in2 = Unpooled.buffer().writeBytes(" and ".getBytes());
		ByteBuf in3 = Unpooled.buffer().writeBytes("ABCDEFGH".getBytes());
		ctx.writeAndFlush(in1);
		ctx.writeAndFlush(in2);
		ctx.writeAndFlush(in3);

	}

3)先启动服务端,再启动客户端,结果如下:
Netty 解决TCP粘包/半包使用_第3张图片

2、分隔符

在包尾增加分割符,比如回车换行符进行分割,例如 FTP 协议;

2.1 回车换行符进行分割

LineBasedFrameDecoder采用的通信协议格式非常简单:使用换行符\n或者\r\n作为依据,遇到\n或者\r\n都认为是一条完整的消息。

LineBasedFrameDecoder提供了2个构造方法,如下:

    public LineBasedFrameDecoder(int maxLength) {
        this(maxLength, true, false);
    }

    public LineBasedFrameDecoder(int maxLength, boolean stripDelimiter, boolean failFast) {
        this.maxLength = maxLength;
        this.failFast = failFast;
        this.stripDelimiter = stripDelimiter;
    }

其中:

  • maxLength:
    表示一行最大的长度,如果超过这个长度依然没有检测到\n或者\r\n,将会抛出TooLongFrameException
  • failFast:
    与maxLength联合使用,表示超过maxLength后,抛出TooLongFrameException的时机。如果为true,则超出maxLength后立即抛出TooLongFrameException,不继续进行解码;如果为false,则等到完整的消息被解码后,再抛出TooLongFrameException异常。
  • stripDelimiter:
    解码后的消息是否去除\n,\r\n分隔符。

1)服务端

            bootstrap.group(bossGroup, workerGroup)/*将线程组传入*/
                ...
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        //回车换行符
                        socketChannel.pipeline()
                                .addLast(new LineBasedFrameDecoder(1024))
                                .addLast(new LineBaseServerHandler());
                    }
                });
	@Override
	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		// 获取客户端发送过来的消息
		ByteBuf in = (ByteBuf) msg;
		String request = in.toString(CharsetUtil.UTF_8);
		System.out.println("Server Accept[" + request + "] and the counter is:" + counter.incrementAndGet());
		String resp = "Hello," + request + ". Welcome to Netty World!" + System.getProperty("line.separator");
		ctx.writeAndFlush(Unpooled.copiedBuffer(resp.getBytes()));
	}

2)客户端

            bootstrap.group(eventExecutors)/*将线程组传入*/
                    ...
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            //回车换行符
                            socketChannel.pipeline()
                                    .addLast(new LineBasedFrameDecoder(1024))
                                    .addLast(new LineBaseClientHandler());
                        }
                    });
	@Override
	public void channelActive(ChannelHandlerContext ctx) throws Exception {
		// 发送消息到服务端
		ByteBuf msg = null;
		String request = "charge LineBasedFrameDecoder(1024),回车换行符" + System.getProperty("line.separator");
		for (int i = 0; i < 5; i++) {
			msg = Unpooled.buffer(request.length());
			msg.writeBytes(request.getBytes());
			ctx.writeAndFlush(msg);
		}
	}

3)先启动服务端,再启动客户端,结果如下:
Netty 解决TCP粘包/半包使用_第4张图片

2.2 自定义分割符

DelimiterBasedFrameDecoder是一个分隔符解码器。我们可以自定义消息分隔符。

DelimiterBasedFrameDecoder提供了多个构造方法,比如下面两个

    public DelimiterBasedFrameDecoder(int maxFrameLength, ByteBuf delimiter) {
        this(maxFrameLength, true, delimiter);
    }

    public DelimiterBasedFrameDecoder(int maxFrameLength, boolean stripDelimiter, ByteBuf delimiter) {
        this(maxFrameLength, stripDelimiter, true, delimiter);
    }

其中:

  • maxFrameLength:
    表示一行最大的长度,可以同时接受多个分隔符,
    如果长度超过1024(可以指定),并且没有找到分隔符,则会抛异常。
    如果长度小于1024,并且没有找到分隔符,会缓存收到的消息,直到接收到分隔符,或者超出1024抛异常。
    同时存在多个分隔符时,优先匹配长度最短的分隔符,如果一样长,则哪个先出现,匹配哪个。
  • stripDelimiter:
    解码后的消息是否去除分隔符。
  • delimiter
    我们自定义分隔符

1)服务端

public class DelimiterEchoServer {

    public static final String DELIMITER_SYMBOL = "@~@";
    public static final int PORT = 19997;

    public static void main(String[] args) throws InterruptedException {
        DelimiterEchoServer delimiterEchoServer = new DelimiterEchoServer();
        System.out.println("服务器即将启动");
        delimiterEchoServer.start();
    }

    public void start() throws InterruptedException {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();/*服务端启动必须*/
            bootstrap.group(bossGroup, workerGroup)
                .channel(NioServerSocketChannel.class)
                .localAddress(new InetSocketAddress(PORT))
                .childHandler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel socketChannel) throws Exception {
                        // 自定义分割符
                        ByteBuf delimiter = Unpooled.copiedBuffer(DELIMITER_SYMBOL.getBytes());
                        socketChannel.pipeline()
                                .addLast(new DelimiterBasedFrameDecoder(1024, delimiter))
                                .addLast(new DelimiterServerHandler());
                    }
                });
            System.out.println("MyServer 服务端已经准备就绪...");
            ChannelFuture channelFuture = bootstrap.bind().sync();
            System.out.println("服务器启动完成,等待客户端的连接和数据...");
            channelFuture.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

}
/**
	 * 服务端读取到网络数据后,做业务处理
	 *
	 * @param ctx
	 * @param msg
	 * @throws Exception
	 */
	@Override
	public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
		// 获取客户端发送过来的消息
		ByteBuf in = (ByteBuf) msg;
		String request = in.toString(CharsetUtil.UTF_8);
		System.out.println("Server Accept[" + request + "] and the counter is:" + counter.incrementAndGet());
		String resp = "Hello," + request + ". Welcome to Netty World!" + DelimiterEchoServer.DELIMITER_SYMBOL;
		ctx.writeAndFlush(Unpooled.copiedBuffer(resp.getBytes()));
		// ctx.close();
	}

	/**
	 * 服务端读取完成网络数据后,做业务处理
	 * @param ctx
	 * @throws Exception
	 */
	@Override
	public void channelReadComplete(ChannelHandlerContext ctx) throws Exception {
		// 发送消息给客户端
		System.out.println("channelReadComplete------");
		// ctx.writeAndFlush(Unpooled.EMPTY_BUFFER).addListener(ChannelFutureListener.CLOSE);
	}

2)客户端

            bootstrap.group(eventExecutors)/*将线程组传入*/
                    ...
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel socketChannel) throws Exception {
                            //自定义分割符
                            ByteBuf delimiter = Unpooled.copiedBuffer(DelimiterEchoServer.DELIMITER_SYMBOL.getBytes());
                            socketChannel.pipeline()
                                    .addLast(new DelimiterBasedFrameDecoder(1024, delimiter))
                            .addLast(new DelimiterClientHandler());
                        }
                    });
	@Override
	public void channelActive(ChannelHandlerContext ctx) throws Exception {
		// 发送消息到服务端
		ByteBuf msg = null;
		String request = "charge 自定义分割符 \\@\\~\\@ , 发送数据。" + DelimiterEchoServer.DELIMITER_SYMBOL;
		for (int i = 0; i < 10; i++) {
			msg = Unpooled.buffer(request.length());
			msg.writeBytes(request.getBytes());
			ctx.writeAndFlush(msg);
			System.out.println("发送数据到服务器");
		}
	}

3)先启动服务端,再启动客户端,结果如下:
Netty 解决TCP粘包/半包使用_第5张图片

3、专门的 length 字段

专门的 length 字段:将消息分为消息头和消息体,消息头中包含表示消息总长度(或者消息体长度)
的字段,通常设计思路为消息头的第一个字段使用 int32 来表示消息的总长度,使用
LengthFieldBasedFrameDecoder,后面再了解使用它。

上面几个都有一个共同的父类 ByteToMessageDecoder 解码器
Netty 解决TCP粘包/半包使用_第6张图片

到此,Netty 解决TCP粘包/半包使用有所了解,解码的更多逻辑可以查看源码。

参考文章:

  • TCP 粘包和半包 介绍及解决(上):https://network.51cto.com/article/604760.html
  • TCP拆包、半包、粘包:https://zhuanlan.zhihu.com/p/126279630

– 求知若饥,虚心若愚。

你可能感兴趣的:(#,Netty,Netty解决TCP粘包/半包)