反应式编程(三)什么是粘包、拆包?如何解决?

在 Java 语言中,传统的 Socket 编程分为两种实现方式,这两种实现方式也对应着两种不同的传输层协议:TCP协议UDP协议。但作为互联网中最常用的传输层协议 TCP,在使用时却会导致 粘包拆包 问题,接下来就让我们一起探索下这两个问题。

一、粘包、拆包介绍

1.1 什么是 TCP 协议?

TCP 全称是 Transmission Control Protocol(传输控制协议),它由 IETF 的 RFC 793 定义,是一种面向连接的点对点的传输层通信协议。

TCP 通过使用 序列号确认消息,从发送节点提供有关传输到目标节点的数据包的传递的信息。TCP 确保数据的可靠性,端到端传递,重新排序和重传,直到达到超时条件或接收到的数据包的确认为止。

在这里插入图片描述

TCP 是 Internet 上最常用的协议,它也是实现 HTTP(HTTP 1.0/HTTP 2.0)通讯的基础,当我们在浏览器中请求网页时,计算机会将 TCP 数据包发送到 Web 服务器的地址,要求它将网页返回给我们,Web 服务器通过发送 TCP 数据包流进行响应,然后 浏览器将这些数据包缝合在一起以形成网页

TCP 的全部意义在于它的可靠性,它通过对数据包编号来对其进行排序,而且它会通过让服务器将响应发送回浏览器说 “已收到”(ACK)来进行错误检查,因此在传输过程中不会丢失或破坏任何数据。

目前市场上主流的 HTTP 协议使用的版本是 HTTP/1.1,如下图所示:

在这里插入图片描述

1.2 什么是粘包、拆包?

粘包和拆包是 TCP协议 在数据传输过程中可能出现的现象,主要由于 TCP 协议本身的特点所导致。

粘包:当应用程序通过 TCP连接 连续发送多个小的数据包时,TCP 为了提高网络效率可能会把这些 小的数据包合并成一个大的数据块进行发送,多个数据包之间没有没有明确的分隔,导致无法对这些数据包进行正确的读取,需要自行进行 数据边界识别,这种情况称为“粘包”。

拆包:反之,如果一次发送的数据量很大,TCP 可能会将其 划分为多个数据包进行发送,而在接收端这些数据包可能不会按照原来的边界一次性接收,而是 分散在多次接收操作中,否则会导致读取的数据不完整,这种情况称为“拆包”。

注意:粘包、拆包一般是在 TCP 协议中发生,HTTP中由于携带了 Content-Length 请求头,一般不会发生粘包、拆包问题。

1.3 粘包、拆包的四种情况

客户端发送了两个包 P1P2 给服务端,服务端第一次读取到的字节数是不确定的,所以可能存在以下4种情况:

在这里插入图片描述

(1)服务端分两次读取到了2个独立的数据包 P1、P2,没有发生粘包和拆包
(2)服务端一次读取到了2个数据包,P1 和 P2 发生了粘包
(3)服务端分两次读取到了2个数据包,第1个数据包是部分的 P1,第2个数据包是剩余的 P1 和 完整的 P2,这是 TCP 拆包情况。
(4)服务端分两次读取了2个数据包,第1个数据包是完整的 P1 和部分的 P2,第2个数据包是剩余的 P2,这也是 TCP 拆包情况。

1.4 粘包、拆包的原因

粘包、拆包问题产生的原因可能有很多因素,从应用层到链路层中都有可能引起这个问题。

我们要先搞懂几个概念:

1)TCP协议中的滑动窗口机制

TCP 协议是一种可靠性传输协议,所以在传输数据的时候必须要等到对方的应答之后才能发送下一条数据,这种显然效率不高。TCP 协议为了解决这个传输效率的问题,引入了滑动窗口。

滑动窗口 就是在发送方和接受方都有一个缓冲区,这个缓冲区就是“窗口”。

假设发送方的窗口大小是 0~100KB,那么发送数据的时候,前 100KB 的数据不需要等待对方 ACK 应答即可全部发送。

如果发送的过程中收到了对方返回的某个数据包的 ACK,那么这个窗口会对应的向后滑动。

  • 比如:刚开始的窗口大小是 0~100KB,收到前 20KB 数据包的 ACK 之后,这个窗口就会滑动到 20~120KB 的位置,以此类推。

    (这里还有一个小问题,如果发送方一直未接收到前 20KB 的 ACK 消息,那么在发送完 0~100KB 的数据之后,窗口就会卡在那里,这就是典型的 队头阻塞问题,这里不展开讲解。 )

与发送方类似,接收方也有这么一个窗口,只会读取窗口内的数据并返回 ACK,返回 ACK 之后,接收窗口往后滑动。

注意: 对于 TCP 的滑动窗口,发送方的窗口起到了优化传输效率的作用,接收方的窗口起到了流量控制的作用

2)传输层的 MSS 与链路层的 MTU
  • MSS 是指传输层的最大报文长度限制。
  • MTU 则是链路层中最大数据包的大小限制。

一般 MTU 会限制 MSS。比如:MTU=1500,那么 MSS 每次传输的数据包大小只能是 MTU-40=1460(TCP 报文头大小为40)。

这个限制的原因是 为了避免出现网络堵塞。因为网卡会有带宽限制,如果一次发送一个 1GB 大小的数据包,没有限制直接发送,就会导致网络堵塞,并且超出网络硬件设备单次传输数据的最大限制。

每次传输的数据包大小超过 MSS 大小时,就会自动切割这个数据包,将大的数据包拆分成多个小包。

3)TCP协议中的 Nagle 算法

有这么一种情况,每次发送的数据包都非常小,比如只有 1 个字节,但是 TCP 的报文头默认有 40 个字节,数据 + 报文头一共是 41 个字节。如果这种较小的数据包经常出现,会造成过多的网络资源浪费。如果有 1w 个这样的数据包,那么总数据量中 有 400MB 都是报文头,只有 10MB 时真正的数据,传输效率只有 2.4%,浪费资源

所以 TCP 中引入了一种叫做 Nagle 的算法,如若连续几次发送的数据都很小,TCP会根据这个算法把多个数据合并成一个包发出,从而优化传输效率,避免网络资源浪费。

4)应用层的 接收缓冲区 和 发送缓冲区

对于操作系统的 IO 函数而言,网络数据不管是发送或者是接收,都不会去逐个读取,而是会先把接收、发送的数据放入到一个缓冲区中,然后批量进行操作。当然,发送和接收各自会对应有单独的缓冲区。

例如:现在要发送 “我叫王大锤” 这组数据,操作系统的 IO 函数会挨个将它们写入到发送缓冲区。接收方也是这样,会将他们挨个从接收缓冲区中读取出来。

5)原因总结

搞清楚上面几个概念之后,我们再来分析一下为什么会产生粘包、拆包的问题?

粘包原因: (多个包粘在一起。)

  • 应用层:接收方的接收缓冲区太大,导致读取多个数据包一起输出。
  • TCP滑动窗口:接收方窗口较大,导致发送方发出的多个数据包处理不及时,造成粘包。
  • Nagle 算法:由于发送方的单个数据包体积太小,导致多个包合并成一个包发送。

拆包原因: (一个包被拆成了多个)

  • 应用层:接收方缓存区太小,无法存放发送的单个数据包,因此被拆开读取。
  • TCP滑动窗口:接收方的窗口太小,无法一次性放下完整的数据包,只能读取其中的一部分。
  • MSS 限制:发送方的单个包大小超出了 MSS 限制,导致被拆分成了多个包。

二、粘包、拆包问题复现

2.1 粘包问题模拟:

1)服务端

DemoServer.java

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * 服务器端(只负责接收消息)
 */
public class DemoServer {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        System.out.println("Server started...");

        while (true) {
            Socket clientSocket = serverSocket.accept();
            BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        }
    }
}
2)客户端

DemoClient.java

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;

/**
 * 客户端(只负责发送消息)
 */
public class DemoClient {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1", 8080);

        try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))) {
            String message1 = "Message 1";
            String message2 = "Message 2";
            // 不做特殊处理,直接写出两个消息,没有明显的消息边界
            writer.write(message1);
            writer.write(message2);
            writer.flush();

            socket.close();
            System.out.println("Message sent.");
        }
    }
}
3)测试结果

先运行服务端,后运行客户端,服务端显示结果如下:

在这里插入图片描述

2.2 拆包问题模拟:

1)服务端

ServerSocket.java

import java.io.IOException;
import java.io.InputStream;
import java.net.Socket;

/**
 * 服务器端(只负责接收消息)
 */
public class ServerSocket {

    /**
     * 字节数组的长度
     */
    private static final int BYTE_LENGTH = 10;

    public static void main(String[] args) throws IOException {
        // 创建 Socket 服务器
        java.net.ServerSocket serverSocket = new java.net.ServerSocket(8080);
        System.out.println("Server started...");

        while (true) {
            // 获取客户端连接
            Socket clientSocket = serverSocket.accept();
            // 得到客户端发送的流对象
            try (InputStream inputStream = clientSocket.getInputStream()) {
                // 循环获取客户端发送的消息
                byte[] bytes = new byte[BYTE_LENGTH];
                // 读取客户端发送的消息
                while (inputStream.read(bytes, 0, BYTE_LENGTH) > 0) {
                    // 成功接收到有效消息并打印
                    System.out.println("接收到客户端的信息是:" + new String(bytes).trim());
                    bytes = new byte[BYTE_LENGTH];
                }
            }
        }
    }
}
2)客户端

ClientSocket.java

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;

/**
 * 客户端(只负责发送消息)
 */
public class ClientSocket {

    public static void main(String[] args) throws IOException {
        // 创建 Socket 客户端并尝试连接服务器端
        Socket socket = new Socket("127.0.0.1", 8080);
        // 发送的消息内容
        String message = "I'm read to study.";
        // 使用输出流发送消息
        try (OutputStream outputStream = socket.getOutputStream()) {
            // 发送消息
            outputStream.write(message.getBytes());
        }
        System.out.println("Message sent.");
    }
}
3)测试结果

先运行服务端,后运行客户端,服务端显示结果如下:

在这里插入图片描述


三、Netty如何解决粘包、拆包问题?

粘包、拆包的问题,我们借鉴的是 Netty 中的处理方式。Netty 官方提供了4种适用于不同场景的解决方式:

  • LineBasedFrameDecoder:基于 的解码器,遇到 “\n”、“\r\n” 会被作为行分隔符。
  • FixedLengthFrameDecoder:基于 固定长度 的解码器。
  • DelimiterBasedFrameDecoder:基于 分隔符 的解码器。
  • LengthFieldBasedFrameDecoder:LTC解码器,根据 预先定义好的长度字段 来识别帧的边界。

如下代码实例中会涉及到 Logback 日志打印,logback.xml 配置文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <contextName>${APP_NAME}</contextName>
    <springProperty name="APP_NAME" scope="context" source="spring.application.name"/>
    <springProperty name="LOG_FILE" scope="context" source="logging.file" defaultValue="../logs"/>
    <springProperty name="LOG_POINT_FILE" scope="context" source="logging.file" defaultValue="../logs/point"/>
    <springProperty name="LOG_MAXFILESIZE" scope="context" source="logback.filesize" defaultValue="50MB"/>
    <springProperty name="LOG_FILEMAXDAY" scope="context" source="logback.filemaxday" defaultValue="7"/>
    <springProperty name="ServerIP" scope="context" source="spring.cloud.client.ip-address" defaultValue="0.0.0.0"/>
    <springProperty name="ServerPort" scope="context" source="server.port" defaultValue="0000"/>

    <!-- 控制台日志 -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%date{HH:mm:ss} [%-5level] [%thread] %logger{17} - %m%n </pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <root level="DEBUG">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

3.1 短连接解决粘包问题(非官方)

所谓短连接就是一次性把数据发完,然后就断开连接。客户端断开连接之后,服务端会接收到一个 -1 的状态码,可以以这个 -1 作为每个数据包的边界。

Talk is cheap, show me the code,咱们直接上代码:

1)初始化器

ServerInitializer.java

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p> @Title ServerInitializer
 * <p> @Description 粘包、拆包问题的初始化器
 *
 * @author ACGkaka
 * @date 2024/3/27 16:44
 */
public class ServerInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        socketChannel.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
        socketChannel.pipeline().addLast(new ChannelInboundHandlerAdapter() {
            // 数据就绪事件,当收到客户端数据时会读取通道内的数据。
            @Override
            public void channelReadComplete(ChannelHandlerContext context) throws Exception {
                // 在这里直接输出通道内的数据信息
                System.out.println(context.channel());
                super.channelReadComplete(context);
            }
        });
    }
}
2)服务端

AdhesivePackageServer.java

import com.demo.config.ServerInitializer;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import lombok.extern.slf4j.Slf4j;

/**
 * <p> @Title AdhesivePackageServer
 * <p> @Description 通过短连接解决粘包问题的服务端
 *
 * @author ACGkaka
 * @date 2024/3/27 16:51
 */
@Slf4j
public class AdhesivePackageServer {
    public static void main(String[] args) {
        log.info("Start...");
        NioEventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel.class);
        server.childHandler(new ServerInitializer());

        server.bind("127.0.0.1", 8888);
        System.out.println("服务端启动成功....");
    }
}
3)客户端

Client.java

import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;

/**
 * <p> @Title Client
 * <p> @Description 客户端每次发送完数据之后就断开本次连接
 *
 * @author ACGkaka
 * @date 2024/3/27 16:55
 */
public class Client {

    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            sendData();
        }
    }

    private static void sendData() {
        EventLoopGroup worker = new NioEventLoopGroup();
        Bootstrap client = new Bootstrap();

        try {
            client.group(worker);
            client.channel(NioSocketChannel.class);
            client.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {

                    ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
                        //连接到服务端之后触发
                        @Override
                        public void channelActive(ChannelHandlerContext ctx) throws Exception {
                            //向服务端发送一个20字节的数据包,然后就断开连接
                            ByteBuf buffer = ctx.alloc().buffer(1);
                            buffer.writeBytes(new byte[]
                                    {'0', '1', '2', '3', '4',
                                            '5', '6', '7', '8', '9',
                                            'A', 'B', 'C', 'D', 'E',
                                            'M', 'N', 'X', 'Y', 'Z'});
                            ctx.writeAndFlush(buffer);
                            //发送完数据,就断开连接
                            ctx.channel().close();
                        }
                    });
                }
            });
            client.connect("127.0.0.1", 8888).sync();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            worker.shutdownGracefully();
        }
    }
}
4)测试结果

先运行服务端,后运行客户端发送数据,每次发送完数据之后就断开本次与服务端的连接。

在这里插入图片描述

结果如下,可以看到每次发送的数据没有出现粘包问题。

虽然说短连接可以解决粘包问题,但是还是有可能出现半包问题的 ,如果单次发送的数据包大小超过 MSS 限制,数据包就会被切割,还是会有半包的问题。

这种方式只能解决粘包问题,所以只适用于一些特定的场景。

3.2 定长帧解码器

这个是 Netty 官方提供的一种处理方式,定长帧其实就是固定每次数据包的大小,比如固定每个包的大小为 8 个字节,那么发送方每次最多发送 8 个字节的数据,不够的使用非常用字符自动补齐,然后接收方在接收的时候,每次也只接收 8 个字节大小的数据,这样就可以有效避免粘包半包问题。

Talk is cheap,show me the code:

FixedLengthFrameDecoderDemo.java

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.FixedLengthFrameDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p> @Title FixedLengthFrameDecoderDemo
 * <p> @Description 通过 Netty 官方提供的定长帧解码器解决粘包拆包问题。
 *
 * @author ACGkaka
 * @date 2024/3/27 20:11
 */
public class FixedLengthFrameDecoderDemo {

    /**
     * 缓冲区大小
     */
    private static int BYTE_LENGTH = 8;

    public static void main(String[] args) {
        EmbeddedChannel channel = new EmbeddedChannel(
                // 定长帧解码器
                new FixedLengthFrameDecoder(BYTE_LENGTH),
                new LoggingHandler(LogLevel.DEBUG)
        );

        // 调用三次发送到数据的方法
        sendData(channel, "ABC");
        sendData(channel, "123456");
        sendData(channel, "ABC123");
    }

    /**
     * 发送数据
     * @param channel 数据通道
     * @param data 要发送的数据
     */
    private static void sendData(EmbeddedChannel channel, String data) {
        // 获取要发送数据的字节长度
        byte[] bytes = data.getBytes();
        int dataLength = bytes.length;

        // 根据固定长度补齐要发送的数据
        StringBuilder alignString = new StringBuilder();
        if (dataLength < BYTE_LENGTH) {
            int alignLength = BYTE_LENGTH - bytes.length;
            for (int i = 1; i <= alignLength; i++) {
                alignString.append("*");
            }
        }

        // 拼接上对齐数据
        String message = data + alignString;
        byte[] msgBytes = message.getBytes();

        // 构建缓冲区,通过 channel 发送数据
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}

运行之后可以看到结果如下:

在这里插入图片描述

可以看到,不足 8 字节的数据会自动补齐到 8 字节大小。这种方式虽然可以解决粘包、拆包问题,但是也存在一些很明显的问题:

  • 适用场景有限,只能传输固定大小的数据,而且不够的还要在客户端补齐,补齐的数据相当于无效数据,占用网络资源
  • 如果发送的数据包大小超过了固定大小,还是会有拆包的问题。

3.3 行帧解码器

之前的定长解码器只适用于固定长度的数据,如果每次传输的数据包大小都是不确定的,那么就不适用了,为此需要一个应对可变数据长度的解码器,Netty 官方提供了 行帧解码器,看名字其实我们就可以推断出,这种解码器是以 作为每个数据包的边界的

Talk is cheap,show me the code:

LineFrameDecoderDemo.java

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.LineBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p> @Title LineFrameDecoderDemo
 * <p> @Description 通过 Netty 官方提供的行帧解码器解决粘包拆包问题。
 *
 * @author ACGkaka
 * @date 2024/3/27 19:54
 */
public class LineFrameDecoderDemo {

    public static void main(String[] args) {
        // 通过 Netty 提供的测试通道来代替服务端、客户端。
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一个行帧解码器(在超出1024后还未检测到换行符,就会停止读取)
                new LineBasedFrameDecoder(1024),
                // 加一个字符串处理器,可以处理中文,要不然中文数据显示不出来
                new StringDecoder(),
                new LoggingHandler(LogLevel.DEBUG)
        );

        // 调用三次发送数据的方法
        sendData(channel, "我叫王大锤");
        sendData(channel, "我在总结梳理粘包、拆包问题");
        sendData(channel, "知道的越多,不知道的越多");
    }

    /**
     * 发送数据
     */
    private static void sendData(EmbeddedChannel channel, String data) {
        // 在要发送的数据结尾,拼接上一个\n换行符(\r\n也可以)
        String message = data + "\n";
        // 获取发送数据的字节长度
        byte[] msgBytes = message.getBytes();

        // 构建缓冲区,通过 channel 发送数据
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}

运行结果如下:

可以看到,没有了之前的哪种对齐数据了,节省了网络资源。

在这里插入图片描述

看起来是比 定长帧解码器 好些,但是 行帧解码器 还是有可能出现拆包问题的,因为开头我们在添加这个解码器的时候,需要指定最大数据长度(1024),也就说如果我们单个数据包超过这个大小了,还是会被切割成多个数据包

3.4 分隔符帧解码器

如果我们不想以换行符作为每个数据包的边界,想要我们自己定义边界符号也是可以的,Netty 官方为我们提供了分隔符帧解码器,让我们可以自定义边界符号。

Talk is cheap,show me the code:

DelimiterFrameDecoderDemo.java

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.DelimiterBasedFrameDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p> @Title DelimiterFrameDecoderDemo
 * <p> @Description 通过 Netty 官方提供的分隔符帧解码器解决粘包拆包问题
 *
 * @author ACGkaka
 * @date 2024/3/27 20:32
 */
public class DelimiterFrameDecoderDemo {

    public static void main(String[] args) {
        // 自定义一个分隔符(记得要用 ByteBuf 对象来包装)
        ByteBuf delimiter = ByteBufAllocator.DEFAULT.buffer(1);
        delimiter.writeByte('|');

        // 通过 Netty 提供的测试通道来代替服务端、客户端
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一个分隔符帧解码器(传入自定义的分隔符)
                new DelimiterBasedFrameDecoder(1024, delimiter),
                new LoggingHandler(LogLevel.DEBUG)
        );

        sendData(channel, "123");
        sendData(channel, "979799");
        sendData(channel, "123o12p3i12po3iop21i3");
    }

    /**
     * 发送数据
     */
    private static void sendData(EmbeddedChannel channel, String data) {
        // 在要发送的数据结尾,拼接上一个*号(因为前面定义的分隔符为*号)
        String message = data + "|";
        // 获取发送数据的字节长度
        byte[] msgBytes = message.getBytes();

        // 构建缓冲区,通过channel发送数据
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer(8);
        buffer.writeBytes(msgBytes);
        channel.writeInbound(buffer);
    }
}

执行结果:

在这里插入图片描述

这个处理方式其实和 行帧解码器 一样,只是换了个边界符号而已,存在和行帧解码器一样的问题,也可能出现半包。

3.5 LTC解码器(推荐)

之前的解码器多多少少都有点小问题,那有没有一种 可以在任意场景使用 的解码器呢?当然是有的,Netty 官方提供了目前最完善的解码器:LTC解码器。

LTC解码器 一般可以用来做自定义协议的时候使用,可以 灵活的定义每个数据包中包含的各种信息。我们先来看一下这个解码器的构造方法:(要先理解这些参数表示的含义,才可以明白这个解码器是怎么用的)

public LengthFieldBasedFrameDecoder(
        int maxFrameLength,
        int lengthFieldOffset,
        int lengthFieldLength,
        int lengthAdjustment,
        int initialBytesToStrip) {
    this(maxFrameLength, 
         lengthFieldOffset, lengthFieldLength, lengthAdjustment,
         initialBytesToStrip, true);
}
  • maxFrameLength:数据最大长度,超出了会被分包。
  • lengthFieldOffset:长度字段偏移量,表示描述数据长度的信息从第几个字节开始。
  • lengthFieldLength:长度字段占用字节数,描述数据正文长度用了几个字节。
  • lengthAdjustment:长度调整数,表示在长度字段的第N个字节之后才是正文数据的开始。
  • initialBytesToStrip:跳过几个字节开始读取数据,可以用来跳过一些头部信息,直接读取正文数据。

Talk is cheap,show me the code。下面是使用示例:

LTCDecoderDemo.java

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.LengthFieldBasedFrameDecoder;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
 * <p> @Title LTCDecoderDemo
 * <p> @Description LTC解码器使用示例
 *
 * @author ACGkaka
 * @date 2024/3/28 8:43
 */
public class LTCDecoderDemo {

    public static void main(String[] args) {
        // 通过Netty提供的测试通道来代替客户端和服务端。
        EmbeddedChannel channel = new EmbeddedChannel(
                // 添加一个LTC解码器(超出1024后还是会进行分包操作)
                new LengthFieldBasedFrameDecoder(
                        1024, // 单个包最大传1024个字节,超出了分包
                        0, // 长度字段偏移量,表示描述数据长度的信息从第几个字节开始
                        4, // 长度字段占用字节数
                        0, // 长度调整数,表示在长度字段的第N个字节之后才是正文数据的开始
                        4 // 跳过几个字节开始读取数据,可以用来跳过一些头部信息,直接读取正文数据
                ),
                new StringDecoder(),
                new LoggingHandler(LogLevel.DEBUG)
        );

        // 调用发送数据的方法
        sendData(channel, "长度字段偏移量,表示描述数据长度信息从第几个字节开始");
        sendData(channel, "长度字段占用字节数");
        sendData(channel, "长度调整数,表示在长度字段的第N个字节之后才是正文数据的开始");
        sendData(channel, "跳过几个字节开始读取数据,可以用来跳过一些头部信息,直接读取正文数据");
    }

    /**
     * 发送数据
     */
    private static void sendData(EmbeddedChannel channel, String data) {
        // 获取要发送数据的字节以及长度
        byte[] dataBytes = data.getBytes();
        int dataLength = dataBytes.length;

        // 将数据长度写入到缓冲区,再将正文数据写入到缓冲区
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
        // 写入数据正文的长度(int占用4个字节)
        buffer.writeInt(dataLength);
        // 写入数据
        buffer.writeBytes(dataBytes);

        // 发送最终组装好的数据
        channel.writeInbound(buffer);
    }
}

执行结果:

在这里插入图片描述

3.6 解码器总结

Netty 官方提供了4种解码器:

  • LineBasedFrameDecoder:基于 的解码器,遇到 “\n”、“\r\n” 会被作为行分隔符。
  • FixedLengthFrameDecoder:基于 固定长度 的解码器。
  • DelimiterBasedFrameDecoder:基于 分隔符 的解码器。
  • LengthFieldBasedFrameDecoder:LTC解码器,根据 预先定义好的长度字段 来识别帧的边界。

其中 应用最广泛的是 LTC 解码器的处理方式,不管我们使用 Netty 官方提供的,还是基于原生 Java NIO 进行开发(现在估计没人会这么做了吧),LTC 的处理方式我们都可以借鉴。

虽然说相比较来说,LTC 的处理方式是最优的,但是我们在实际开发中还是要考虑业务场景的,没必要就只认定 LTC 解码器的这种处理方式,毕竟 技术是要为业务服务的


四、补充

4.1 为什么 UDP 不会发生粘包、拆包问题?

UDP(User Datagram Protocol,用户数据报协议)之所以不会发生 “粘包” 和 “拆包” 问题,是因为 UDP 是一种无连接的传输层协议,它以数据报文(Datagram)为单位进行数据传输。每个 UDP 数据报都有明确的边界,并且在传输过程中保持这一边界不变。

具体表现在以下几个方面:

  1. 独立的数据单元: 每个UDP数据报都是独立的数据单元,有自己的源端口和目的端口,以及携带的用户数据。无论发送多少个数据报,接收方总是以独立的数据报形式接收。
  2. 消息边界保护: UDP协议在每个数据报的 头部包含长度信息,接收方可以根据这个长度信息准确地提取出一个完整的UDP数据报内容,而不必担心数据与其他数据报混淆。
  3. 无顺序保证和重传机制: UDP不负责重新排序到达的数据报,也不会对未收到的数据报进行重传,这意味着数据报要么全部到达,要么丢失,但不会因为重传而合并在一起形成 “粘包”。
  4. 无流控机制: TCP协议为了避免网络拥塞会采取流量控制,可能会导致数据包的合并(粘包)和拆分,而UDP不执行任何流控,因此不会主动合并数据包。

总结起来,由于 UDP 不对数据报进行任何形式的合并或拆分处理,每个数据报都按照原始发送的边界进行传输和接收,所以 UDP在传输层上不会出现 “粘包” 和 “拆包” 现象。但是,应用程序在使用 UDP 时,如果发送的数据刚好跨越 UDP 数据报的边界,接收方还是需要自己处理数据边界问题,确保数据的完整性。

整理完毕,完结撒花~🌻





参考地址:

1.什么是粘包拆包,https://blog.csdn.net/qq_31960623/article/details/121056626

2.TCP协议中的粘包和半包问题,https://blog.csdn.net/xxxzzzqqq_/article/details/130008489

3.JAVA Socket编程学习10–解决TCP粘包分包问题,https://blog.csdn.net/m0_37739193/article/details/78738253

4.Socket粘包问题的3种解决方案,最后一种最完美!https://www.cnblogs.com/vipstone/p/14239160.html

5.一起学Netty(六)之 TCP粘包拆包场景,https://blog.csdn.net/linuu/article/details/51337748

  • 19
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不愿放下技术的小赵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值