什么是粘包拆包?

什么是粘包/拆包

TCP是个“流”式的协议,所谓流,就像河里的水,中间没有边界。TCP传输的数据,在网络上就是一连串的数据,没有分界线。TCP协议的底层,并不了解上层业务的具体定义,它会根据TCP缓冲区的实际情况进行包的划分。在业务层面认为一个完整的包,可能会被TCP拆分成多个小包进行发送,也可能把多个小的包封装成一个大的数据包进行发送,这就是所谓的TCP粘包拆包问题。

粘包/拆包可能发生的情况

TCP粘包/拆包,可能发生4种情况,如图1所示:

在这里插入图片描述

客户端发送了两个数据包P1和P2给服务端,服务端一次读取到的字节数是不确定的,可能存在以下4种情况:
(1)服务端分两次读取到了两个独立的数据包P1和P2,没有发送粘包和拆包;
(2)服务端一次读到了两个数据包,P1和P2粘在一起,这就是TCP粘包情况;
(3)服务端分两次读取到了两个数据包,第一次读取了完整的P1包和P2包的一部分,第二次读取到了P2包的剩余部分,这被称为TCP拆包;
(4)服务端分两次读取了两个数据包,第一次读取了P1包的一部分,第二次读取到了P1包的剩余部分,这也是TCP拆包;

TCP粘包/拆包发生的原因

TCP数据流最终发到目的地,需要通过以太网协议封装成一个个的以太网帧发送出去,以太网数据帧大小最小64字节,最大1518字节,除去header部分,其数据payload为46到1500字节。所以如果以太网帧的payload大于MTU(默认1500字节)就需要进行拆包。

粘包拆包问题解决方法

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

1)客户端在发送数据包的时候,每个包都固定长度,比如1024个字节大小,如果客户端发送的数据长度不足1024个字节,则通过补充空格的方式补全到指定长度;
2)客户端在每个包的末尾使用固定的分隔符,例如\r\n,如果一个包被拆分了,则等待下一个包发送过来之后找到其中的\r\n,然后对其拆分后的头部部分与前一个包的剩余部分进行合并,这样就得到了一个完整的包;
3)将消息分为头部和消息体,在头部中保存有当前整个消息的长度,只有在读取到足够长度的消息之后才算是读到了一个完整的消息;
4)通过自定义协议进行粘包和拆包的处理。

Netty提供的粘包拆包解决方案

1. FixedLengthFrameDecoder

对于使用固定长度的粘包和拆包场景,可以使用FixedLengthFrameDecoder,该解码一器会每次读取固定长度的消息,如果当前读取到的消息不足指定长度,那么就会等待下一个消息到达后进行补足。其使用也比较简单,只需要在构造函数中指定每个消息的长度即可。这里需要注意的是,FixedLengthFrameDecoder只是一个解码一器,Netty也只提供了一个解码一器,这是因为对于解码是需要等待下一个包的进行补全的,代码相对复杂,而对于编码器,用户可以自行编写,因为编码时只需要将不足指定长度的部分进行补全即可。下面的示例中展示了如何使用FixedLengthFrameDecoder来进行粘包和拆包处理:

public class EchoServer {
    public void bind(int port) throws InterruptedException {
        EventLoopGroup bossGroup = new NioEventLoopGroup();
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap bootstrap = new ServerBootstrap();
            bootstrap.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class).
                    option(ChannelOption.SO_BACKLOG, 1024).handler(new LoggingHandler(LogLevel.INFO)).
                    childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
//                     这里将FixedLengthFrameDecoder添加到pipeline中,指定长度为20

                    ch.pipeline().addLast(new FixedLengthFrameDecoder(20));
//                     将前一步解码得到的数据转码为字符串
                    ch.pipeline().addLast(new StringDecoder());
//                     这里FixedLengthFrameEncoder是我们自定义的,用于将长度不足20的消息进行补全空格
                    ch.pipeline().addLast(new FixedLengthFrameEncoder(20));
//                     最终的数据处理
                    ch.pipeline().addLast(new EchoServerHandler());
                }
            });
            ChannelFuture future = bootstrap.bind(port).sync();
            future.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

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

上面的pipeline中,对于入栈数据,这里主要添加了FixedLengthFrameDecoder和StringDecoder,前面一个用于处理固定长度的消息的粘包和拆包问题,第二个则是将处理之后的消息转换为字符串。最后由EchoServerHandler处理最终得到的数据,处理完成后,将处理得到的数据交由FixedLengthFrameEncoder处理,该编码器是我们自定义的实现,主要作用是将长度不足20的消息进行空格补全。下面是FixedLengthFrameEncoder的实现代码:

public class FixedLengthFrameEncoder extends MessageToByteEncoder<String> {
    private int length;

    public FixedLengthFrameEncoder(int length) {
        this.length = length;
    }

    @Override
    protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out) throws Exception {
//        对于超过指定长度的消息,这里直接抛出异常 
        if (msg.length() > length) {
            throw new UnsupportedOperationException("message length is too large, it's limited " + length);
        }
//        如果长度不足,则进行补全 
        if (msg.length() < length) {
            msg = addSpace(msg);
        }
        ctx.writeAndFlush(Unpooled.wrappedBuffer(msg.getBytes()));
    }

//    进行空格补全

    private String addSpace(String msg) {
        StringBuilder builder = new StringBuilder(msg);
        for (int i = 0; i < length - msg.length(); i++) {
            builder.append(" ");
        }
        return builder.toString();
    }
}

这里FixedLengthFrameEncoder实现了decode()方法,在该方法中,主要是将消息长度不足20的消息进行空格补全。EchoServerHandler的作用主要是打印接收到的消息,然后发送响应给客户端:

public class EchoServerHandler extends SimpleChannelInboundHandler<String> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        System.out.println("server receives message: " + msg.trim());
        ctx.writeAndFlush("hello client!");
    }
}

对于客户端,其实现方式基本与服务端的使用方式类似,只是在最后进行消息发送的时候与服务端的处理方式不同。如下是客户端EchoClient的代码:

public class EchoClient {
    public void connect(String host, int port) throws InterruptedException {
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(group).channel(NioSocketChannel.class).option(ChannelOption.TCP_NODELAY, true).
                    handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
//                    对服务端发送的消息进行粘包和拆包处理,由于服务端发送的消息已经进行了空格补全,并且长度为20,因而这里指定的长度也为20 
                    ch.pipeline().addLast(new FixedLengthFrameDecoder(20));
//                    将粘包和拆包处理得到的消息转换为字符串 
                    ch.pipeline().addLast(new StringDecoder());
//                    对客户端发送的消息进行空格补全,保证其长度为20 
                    ch.pipeline().addLast(new FixedLengthFrameEncoder(20));
//                    客户端发送消息给服务端,并且处理服务端响应的消息 
                    ch.pipeline().addLast(new EchoClientHandler());
                }
            });
            ChannelFuture future = bootstrap.connect(host, port).sync();
            future.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new EchoClient().connect("127.0.0.1", 8080);
    }
}

对于客户端而言,其消息的处理流程其实与服务端是相似的,对于入站消息,需要对其进行粘包和拆包处理,然后将其转码为字符串,对于出站消息,则需要将长度不足20的消息进行空格补全。客户端与服务端处理的主要区别在于最后的消息处理handler不一样,也即这里的EchoClientHandler,如下是该handler的源码:

public class EchoClientHandler extends SimpleChannelInboundHandler<String> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        System.out.println("client receives message: " + msg.trim());
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        ctx.writeAndFlush("hello server!");
    }
}

这里客户端的处理主要是重写了channelActive()和channelRead0()两个方法,这两个方法的主要作用在于,channelActive()会在客户端连接上服务器时执行,也就是说,其连上服务器之后就会往服务器发送消息。而channelRead0()主要是在服务器发送响应给客户端时执行,这里主要是打印服务器的响应消息。对于服务端而言,前面我们我们可以看到,EchoServerHandler只重写了channelRead0()方法,这是因为服务器只需要等待客户端发送消息过来,然后在该方法中进行处理,处理完成后直接将响应发送给客户端。如下是分别启动服务端和客户端之后控制台打印的数据:


serverserver receives message: hello server!


clientclient receives message: hello client!

2. LineBasedFrameDecoder与DelimiterBasedFrameDecoder

对于通过分隔符进行粘包和拆包问题的处理,Netty提供了两个编解码的类,LineBasedFrameDecoder和
DelimiterBasedFrameDecoder。这里LineBasedFrameDecoder的作用主要是通过换行符,即\n或者\r\n对数据进行处理;而DelimiterBasedFrameDecoder的作用则是通过用户指定的分隔符对数据进行粘包和拆包处理。同样的,这两个类都是解码一器类,而对于数据的编码,也即在每个数据包最后添加换行符或者指定分割符的部分需要用户自行进行处理。这里以DelimiterBasedFrameDecoder为例进行讲解,如下是EchoServer中使用该类的代码片段,其余部分与前面的例子中的完全一致:

@Overrideprotected void initChannel(SocketChannel ch) throws Exception {    String delimiter = "_$";    // 将delimiter设置到DelimiterBasedFrameDecoder中,经过该解码一器进行处理之后,源数据将会    // 被按照_$进行分隔,这里1024指的是分隔的最大长度,即当读取到1024个字节的数据之后,若还是未    // 读取到分隔符,则舍弃当前数据段,因为其很有可能是由于码流紊乱造成的    ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,        Unpooled.wrappedBuffer(delimiter.getBytes())));    // 将分隔之后的字节数据转换为字符串数据    ch.pipeline().addLast(new StringDecoder());    // 这是我们自定义的一个编码器,主要作用是在返回的响应数据最后添加分隔符    ch.pipeline().addLast(new DelimiterBasedFrameEncoder(delimiter));    // 最终处理数据并且返回响应的handler    ch.pipeline().addLast(new EchoServerHandler());}

上面pipeline的设置中,添加的解码一器主要有
DelimiterBasedFrameDecoder和StringDecoder,经过这两个处理器处理之后,接收到的字节流就会被分隔,并且转换为字符串数据,最终交由EchoServerHandler处理。这里DelimiterBasedFrameEncoder是我们自定义的编码器,其主要作用是在返回的响应数据之后添加分隔符。如下是该编码器的源码:

public class DelimiterBasedFrameEncoder extends MessageToByteEncoder<String> {  private String delimiter;  public DelimiterBasedFrameEncoder(String delimiter) {    this.delimiter = delimiter;  }  @Override  protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out)       throws Exception {    // 在响应的数据后面添加分隔符    ctx.writeAndFlush(Unpooled.wrappedBuffer((msg + delimiter).getBytes()));  }}

对于客户端而言,这里的处理方式与服务端类似,其pipeline的添加方式如下:

@Overrideprotected void initChannel(SocketChannel ch) throws Exception {    String delimiter = "_$";    // 对服务端返回的消息通过_$进行分隔,并且每次查找的最大大小为1024字节    ch.pipeline().addLast(new DelimiterBasedFrameDecoder(1024,         Unpooled.wrappedBuffer(delimiter.getBytes())));    // 将分隔之后的字节数据转换为字符串    ch.pipeline().addLast(new StringDecoder());    // 对客户端发送的数据进行编码,这里主要是在客户端发送的数据最后添加分隔符    ch.pipeline().addLast(new DelimiterBasedFrameEncoder(delimiter));    // 客户端发送数据给服务端,并且处理从服务端响应的数据    ch.pipeline().addLast(new EchoClientHandler());}

这里客户端的处理方式与服务端基本一致,关于这里没展示的代码,其与示例一中的代码完全一致,这里则不予展示。

3.LengthFieldBasedFrameDecoder与LengthFieldPrepender

这里LengthFieldBasedFrameDecoder与LengthFieldPrepender需要配合起来使用,其实本质上来讲,这两者一个是解码,一个是编码的关系。它们处理粘拆包的主要思想是在生成的数据包中添加一个长度字段,用于记录当前数据包的长度。LengthFieldBasedFrameDecoder会按照参数指定的包长度偏移量数据对接收到的数据进行解码,从而得到目标消息体数据;而LengthFieldPrepender则会在响应的数据前面添加指定的字节数据,这个字节数据中保存了当前消息体的整体字节数据长度。LengthFieldBasedFrameDecoder的解码过程如下图所示:

在这里插入图片描述

关于
LengthFieldBasedFrameDecoder,这里需要对其构造函数参数进行介绍:
maxFrameLength:指定了每个包所能传递的最大数据包大小;
lengthFieldOffset:指定了长度字段在字节码中的偏移量;
lengthFieldLength:指定了长度字段所占用的字节长度;
lengthAdjustment:对一些不仅包含有消息头和消息体的数据进行消息头的长度的调整,这样就可以只得到消息体的数据,这里的lengthAdjustment指定的就是消息头的长度;
initialBytesToStrip:对于长度字段在消息头中间的情况,可以通过initialBytesToStrip忽略掉消息头以及长度字段占用的字节。

这里我们以json序列化为例,对LengthFieldBasedFrameDecoder和LengthFieldPrepender的使用方式进行讲解。如下是EchoServer的源码:

public class EchoServer {  public void bind(int port) throws InterruptedException {    EventLoopGroup bossGroup = new NioEventLoopGroup();    EventLoopGroup workerGroup = new NioEventLoopGroup();    try {      ServerBootstrap bootstrap = new ServerBootstrap();      bootstrap.group(bossGroup, workerGroup)        .channel(NioServerSocketChannel.class)        .option(ChannelOption.SO_BACKLOG, 1024)        .handler(new LoggingHandler(LogLevel.INFO))        .childHandler(new ChannelInitializer<SocketChannel>() {          @Override          protected void initChannel(SocketChannel ch) throws Exception {            // 这里将LengthFieldBasedFrameDecoder添加到pipeline的首位,因为其需要对接收到的数据            // 进行长度字段解码,这里也会对数据进行粘包和拆包处理            ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2));            // LengthFieldPrepender是一个编码器,主要是在响应字节数据前面添加字节长度字段            ch.pipeline().addLast(new LengthFieldPrepender(2));            // 对经过粘包和拆包处理之后的数据进行json反序列化,从而得到User对象            ch.pipeline().addLast(new JsonDecoder());            // 对响应数据进行编码,主要是将User对象序列化为json            ch.pipeline().addLast(new JsonEncoder());            // 处理客户端的请求的数据,并且进行响应            ch.pipeline().addLast(new EchoServerHandler());          }        });      ChannelFuture future = bootstrap.bind(port).sync();      future.channel().closeFuture().sync();    } finally {      bossGroup.shutdownGracefully();      workerGroup.shutdownGracefully();    }  }  public static void main(String[] args) throws InterruptedException {    new EchoServer().bind(8080);  }}

这里EchoServer主要是在pipeline中添加了两个编码器和两个解码一器,编码器主要是负责将响应的User对象序列化为json对象,然后在其字节数组前面添加一个长度字段的字节数组;解码一器主要是对接收到的数据进行长度字段的解码,然后将其反序列化为一个User对象。下面是JsonDecoder的源码:

public class JsonDecoder extends MessageToMessageDecoder<ByteBuf> {  @Override  protected void decode(ChannelHandlerContext ctx, ByteBuf buf, List<Object> out)       throws Exception {    byte[] bytes = new byte[buf.readableBytes()];    buf.readBytes(bytes);    User user = JSON.parseObject(new String(bytes, CharsetUtil.UTF_8), User.class);    out.add(user);  }}

JsonDecoder首先从接收到的数据流中读取字节数组,然后将其反序列化为一个User对象。下面我们看看JsonEncoder的源码:

public class JsonEncoder extends MessageToByteEncoder<User> {  @Override  protected void encode(ChannelHandlerContext ctx, User user, ByteBuf buf)      throws Exception {    String json = JSON.toJSONString(user);    ctx.writeAndFlush(Unpooled.wrappedBuffer(json.getBytes()));  }}

JsonEncoder将响应得到的User对象转换为一个json对象,然后写入响应中。对EchoServerHandler,其主要作用就是接收客户端数据,并且进行响应,如下是其源码:

public class EchoServerHandler extends SimpleChannelInboundHandler<User> {  @Override  protected void channelRead0(ChannelHandlerContext ctx, User user) throws Exception {    System.out.println("receive from client: " + user);    ctx.write(user);  }}

对于客户端,其主要逻辑与服务端的基本类似,这里主要展示其pipeline的添加方式,以及最后发送请求,并且对服务器响应进行处理的过程:

@Overrideprotected void initChannel(SocketChannel ch) throws Exception {    ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 2, 0, 2));    ch.pipeline().addLast(new LengthFieldPrepender(2));    ch.pipeline().addLast(new JsonDecoder());    ch.pipeline().addLast(new JsonEncoder());    ch.pipeline().addLast(new EchoClientHandler());}
public class EchoClientHandler extends SimpleChannelInboundHandler<User> {  @Override  public void channelActive(ChannelHandlerContext ctx) throws Exception {    ctx.write(getUser());  }  private User getUser() {    User user = new User();    user.setAge(27);    user.setName("zhangxufeng");    return user;  }  @Override  protected void channelRead0(ChannelHandlerContext ctx, User user) throws Exception {    System.out.println("receive message from server: " + user);  }}

这里客户端首先会在连接上服务器时,往服务器发送一个User对象数据,然后在接收到服务器响应之后,会打印服务器响应的数据。

4. 自定义粘包与拆包器

原文:https://blog.csdn.net/lt_xiaodou/article/details/126191257

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值