netty如何处理拆包与粘包

拆包与粘包是网络编程中必会出现的一个问题。因此,本文先介绍下什么是拆包与粘包,然后通过例子演示这个现象,最后再介绍netty中如何处理拆包与粘包

所谓的拆包就是发送方发送一条数据,接收方分为多次接收了该条数据。比如A发送123456789,B接收到了两条消息12345、6789,而实际上这是一条消息的;粘包就是发送方发送了多条数据,接收方读取数小于发送数。比如A发送123、456、789,B收到了两条消息,1234、56789等

造成拆包与粘包的原因主要有两个层面的,分别为应用层和传输层(tcp协议层面的),本文就不讲述传输层导致的,有兴趣的可以自己看下计算机网络tcp协议相关资料,本文主要分析应用层产生的原因。

在netty中产生这两个现象是因为ByteBuf缓冲区引起的,当ByteBuf的容量比较小时,而socket缓冲区数据又较多,那么ByteBuf就需要多次从socket缓冲区读取数据,导致拆包现象。当ByteBuf的容量比较大时,就可以一次性从socket缓冲区读取数据,导致粘包现象。netty中ByteBuf接收缓冲区的初始值为1024个字节,可以看下这篇文章

下面通过实际的例子来演示下

粘包例子

//客户端
ChannelFuture channelFuture = new Bootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();
                        pipeline.addLast(new LoggingHandler(LogLevel.DEBUG));
                    }
                })
                .connect(new InetSocketAddress("localhost", 8899));

        Channel channel = channelFuture.sync().channel();
        for (int i = 0; i < 10; i++) {
            channel.writeAndFlush(ByteBufAllocator.DEFAULT.buffer().writeByte(i));
        }
//服务器端
new ServerBootstrap()
                .group(new NioEventLoopGroup(), new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();
                        pipeline.addLast(new LoggingHandler(LogLevel.DEBUG));
                    }
                })
                .bind(8899);

客户端分10次发送了10条数据,每次发送一个字节。服务端收到的是一条数据,共10个字节,因为ByteBuf有足够的容量将socket缓冲区的数据一次性读取到应用程序中。

拆包例子
为了更好的演示拆包例子,手动将netty的ByteBuf的接收容量改为64个字节,而不是默认的1024个字节。

//服务端
new ServerBootstrap()
                .group(new NioEventLoopGroup(), new NioEventLoopGroup())
                .channel(NioServerSocketChannel.class)
//这里是修改默认的接收缓冲区初始大小
                .childOption(ChannelOption.RCVBUF_ALLOCATOR, new AdaptiveRecvByteBufAllocator(64, 64, 1024))
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();
                        pipeline.addLast(new LoggingHandler(LogLevel.DEBUG));
                    }
                })
                .bind(8899);
//客户端
ChannelFuture channelFuture = new Bootstrap()
                .group(new NioEventLoopGroup())
                .channel(NioSocketChannel.class)
                .handler(new ChannelInitializer<NioSocketChannel>() {
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ChannelPipeline pipeline = ch.pipeline();
                        pipeline.addLast(new LoggingHandler(LogLevel.DEBUG));
                    }
                })
                .connect(new InetSocketAddress("localhost", 8899));

        Channel channel = channelFuture.sync().channel();

        channel.writeAndFlush(ByteBufAllocator.DEFAULT.buffer().writeBytes(getContent()));

//获取要发送的内容
private static byte[] getContent() {
        byte[] content = new byte[65];
        for (int i = 0; i < 65; i++) {
            content[i] = (byte)i;
        }
        return content;
    }

这个例子中,客户端一次性发送了65个字节,服务端的接收ByteBuf容量初始值为64个字节,因此需要分两次读取,第一次读取64个字节,第二次读取1个字节,也就是产生了拆包现象。

那么如何处理这两个现象呢,首先就是接收方要知道发送方发送消息的边界,比如说,每条消息的长度多少、消息以什么结尾的等等。当知道了消息边界后,就可以对消息进行解析,获取到完整的一条消息。

netty中提供了4个handler来处理拆包与粘包,分别为LineBasedFrameDecoder、DelimiterBasedFrameDecoder、FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder

下面再介绍这几种handler的使用时,就不分别编写服务端和客户端了,用netty提供的EmbeddedChannel来测试

  • LineBasedFrameDecoder:当遇到了换行符,就当做是一条完整的消息。
/**LineBasedFrameDecoder构造参数的意义,1000表示这个handler解析的帧的最大长度
第二个参数表示的是解析出来的消息是否不包含换行分隔符、第三个参数,表示当解析超过最大帧长度时还未遇到换行分隔符,是否要报错
*/
EmbeddedChannel channel = new EmbeddedChannel(new LoggingHandler(LogLevel.DEBUG), new LineBasedFrameDecoder(1000, false, true),
        new ChannelInboundHandlerAdapter(){
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                ByteBuf buf = ((ByteBuf) msg);
                String content = buf.toString(StandardCharsets.UTF_8);
                System.out.println(content);
            }
        });
channel.writeInbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("hello\nworld\nwelcome\n".getBytes(StandardCharsets.UTF_8)));

在这个例子中,channel接收到了这样的一条消息hello\nworld\nwelcome\n,会根据换行符进行消息的解析处理,解析后有3条消息

  • DelimiterBasedFrameDecoder:与LineBasedFrameDecoder的基本一样,就是分隔符可以自定义,且可以定义多种分隔符
ByteBuf delimeter1 = Unpooled.buffer().writeBytes("\n".getBytes(StandardCharsets.UTF_8));
ByteBuf delimeter2 = Unpooled.buffer().writeBytes("\r".getBytes(StandardCharsets.UTF_8));
EmbeddedChannel channel = new EmbeddedChannel(new LoggingHandler(LogLevel.DEBUG), new DelimiterBasedFrameDecoder(10, true, true, delimeter1, delimeter2),
        new ChannelInboundHandlerAdapter(){
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                ByteBuf buf = ((ByteBuf) msg);
                String content = buf.toString(StandardCharsets.UTF_8);
                System.out.println(content);
            }
        });
channel.writeInbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("hello\nworld\nwelcome\r".getBytes(StandardCharsets.UTF_8)));
  • FixedLengthFrameDecoder:固定长度对消息进行拆分,若消息没有达到这个长度,那么就不是一条完整的消息
//这里每条消息设置的固定长度是5
EmbeddedChannel channel = new EmbeddedChannel(new LoggingHandler(LogLevel.DEBUG), new FixedLengthFrameDecoder(5),
        new ChannelInboundHandlerAdapter(){
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                ByteBuf buf = ((ByteBuf) msg);
                String content = buf.toString(StandardCharsets.UTF_8);
                System.out.println(content);
            }
        });
channel.writeInbound(ByteBufAllocator.DEFAULT.buffer().writeBytes("hello\nworld\nwelcome\r".getBytes(StandardCharsets.UTF_8)));
  • LengthFieldBasedFrameDecoder:这个比较复杂一些,会将消息分为两部分,一部分为消息头部,一部分为实际的消息体。其中消息头部是固定长度的,消息体是可变的,且消息头部一般会包含一个Length字段,有的自定义消息协议中Length字段值是指的整条消息的长度,包含头部本身,有的Length字段值指的是消息体实际的长度等等,而LengthFieldBasedFrameDecoder就是用来解析这种消息协议的,构造方法中有以下几个重要参数
    lengthFieldOffset: Length字段在帧的起始偏移位置
    lengthFieldLength: Length字段占用的字节数
    lengthAdjustment: 对Length值的调整数
    initialBytesToStrip: 解析后的实际消息需要跳过帧头部的字节数
    其中Length值+lengthAdjustment=帧中length字节后面的字节长度,比如看下这个例子
//   Length字段值为0x0010=16,表示16字节+lengthAdjustment (-3)= 13,
//表示的是Length占用字节后面的字节长度,也就是HDR2 + Actual Content的实际长度为13个字节。
//initialBytesToStrip=3,表示解析后的实际消息需要跳过帧头部的前3个字节,因此最后得到的消息是HDR2 + Actual Content,
//如果initialBytesToStrip=4,那么最后的实际消息是 Actual Content

   lengthFieldOffset   =  1
   lengthFieldLength   =  2
   lengthAdjustment    = -3 (= the length of HDR1 + LEN, negative)
   initialBytesToStrip =  3
  
   BEFORE DECODE (16 bytes)                       AFTER DECODE (13 bytes)
   +------+--------+------+----------------+      +------+----------------+
   | HDR1 | Length | HDR2 | Actual Content |----->| HDR2 | Actual Content |
   | 0xCA | 0x0010 | 0xFE | "HELLO, WORLD" |      | 0xFE | "HELLO, WORLD" |
   +------+--------+------+----------------+      +------+----------------+

下面通过实际的代码演示下

EmbeddedChannel channel = new EmbeddedChannel(new LoggingHandler(LogLevel.DEBUG),
        new LengthFieldBasedFrameDecoder(100, 2, 4, -8, 7),
        new ChannelInboundHandlerAdapter(){
            @Override
            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                ByteBuf buf = ((ByteBuf) msg);
                String content = buf.toString(StandardCharsets.UTF_8);
                System.out.println(content);
            }
        });
channel.writeInbound(ByteBufAllocator.DEFAULT.buffer().writeBytes(frame()));

/**
 * 这个例子中有3个头部 H1、Length、H2以及实际的content hello world
 * H1 占用 2个字节
 * Length占用4个字节
 * H2占用1个字节
 * content占用11个字节
 * lengthFieldOffset   = 2
 * lengthFieldLength   = 4
 * lengthAdjustment    = -8
 * initialBytesToStrip = 6
 *
 * @return
 */
private static ByteBuf frame() {
    ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
    buffer.writeShort(11);
    buffer.writeInt(20);
    buffer.writeByte(2);
    buffer.writeBytes("hello world".getBytes(StandardCharsets.UTF_8));
    return buffer;
}

这个程序最终输出的是content,hello world。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,783评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,360评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 142,942评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,507评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,324评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,299评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,685评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,358评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,652评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,704评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,465评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,318评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,711评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,991评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,265评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,661评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,864评论 2 335

推荐阅读更多精彩内容