美文网首页iOS开发常用iOS CraziesiOS 开发每天分享优质文章
即时通讯下数据粘包、断包处理实例(基于CocoaAsyncSoc

即时通讯下数据粘包、断包处理实例(基于CocoaAsyncSoc

作者: 涂耀辉 | 来源:发表于2017-02-08 13:48 被阅读5742次
    前言

    本文旨以实例的方式,使用CocoaAsyncSocket这个框架进行数据封包和拆包。来解决频繁的数据发送下,导致的数据粘包、以及较大数据(例如图片、录音等等)的发送,导致的数据断包。

    本文实例Github地址:即时通讯的数据粘包、断包处理实例

    注:文章内容属于应用的范畴,内容相对简单易懂。给大家对数据包的处理提供了一个思路, 希望能抛砖引玉。
    它是楼主CocoaAsyncSocket系列Read篇解析的一个前置插曲,至于详细的实现原理,作者会在后续的文章中写出。

    正文
    一、什么是粘包?

    经常我们发现,如果用客户端同一时间发送几条数据,而服务端只能收到一大条数据,类似下图:


    如图,由于传输的过程为数据流,经过TCP传输后,三条数据被合并成了一条,这就是数据粘包了。

    那么为什么会造成粘包呢?

    原来这是因为TCP使用了优化方法(Nagle算法)。
    它将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。
    这么做优点也很明显,就是为了减少广域网的小分组数目,从而减小网络拥塞的出现。

    具体的内容感兴趣的可以看看这两篇文章:
    TCP之Nagle算法&&延迟ACK
    TCP NAGLE算法和实现

    而UDP就不会有这种情况,它不会使用块的合并优化算法。
    这里说到了就顺便提一下,由于它支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息)。

    当然除了优化算法,TCP和UDP都会因为下面两种情况造成粘包:

    • 发送端需要等缓冲区满才发送出去,造成粘包
    • 接收方不及时接收缓冲区的包,造成多个包接收。
    二、什么是断包?

    断包应该还是比较好理解的,比如我们发送一条很大的数据包,类似图片和录音等等,很显然一次发送或者读取数据的缓冲区大小是有限的,所以我们会分段去发送或者读取数据。
    类似下图:


    无论是粘包还是断包,如果我们要正确解析数据,那么必须要使用一种合理的机制去解包。这个机制的思路其实很简单:

    • 我们在封包的时候给每个数据包加一个长度或者一个开始结束标记。
    • 然后我们拆包的时候就能区分每个数据包了,再按照长度或者分解符去分拆成各个数据包。

    Talk is cheap. Show me the code

    三、实例:基于CocoaAsyncSocket的封包,拆包处理。

    开始动手之前,我们需要去理解下面这几个方法

    //读取数据,有数据就会触发代理
    - (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;
    //直到读到这个长度的数据,才会触发代理
    - (void)readDataToLength:(NSUInteger)length withTimeout:(NSTimeInterval)timeout tag:(long)tag;
    //直到读到data这个边界,才会触发代理
    - (void)readDataToData:(NSData *)data withTimeout:(NSTimeInterval)timeout tag:(long)tag;
    

    还记得我们之前讲:iOS即时通讯,从入门到“放弃”?中提到过,这个框架每次读取数据,必须手动的去调用上述这些read方法,而我们之前的实现思路是,第一次连接成功的代理触发后调用:

    - (void)readDataWithTimeout:(NSTimeInterval)timeout tag:(long)tag;
    

    之后每次收到消息之后,都在去调用一次这个方法,超时为-1,即不超时。这样我们每次收到消息,都会即时触发我们读取消息的代理:

    - (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
    

    然而这么做显然没有考虑数据的拆包,如果我们一条一条的发送文字信息,自然没什么问题。如果我们一次发送数条,或者发送大图片。那么问题就出来了,我们解析出来的数据显然是不对的。

    这时候我们就需要另外两个read方法了,一个是读取到指定长度,另一个是读取到指定边界。
    我们通过自己定义的数据边界,去调用这两个方法,而触发的读取代理,得到的数据才是正确的一个包的数据。

    所以我们的核心思路有了:
    1. 封包的时候给每个包的数据加一个标记,来标明数据的长度和类型(类型显然是需要的,我们需要知道它是文本、图片、还是录音等等,来用正确的方式处理这个数据)。
    2. 拆包的时候,先获取到我们给每个包的标记,然后根据标记的数据长度,去获取数据。最后再根据标记的类型去处理数据。(文字输出、图片展示、录音播放等等)。

    接着我们可以开始动手了:
    这里我们首先需要一个服务端,一个客户端。为了简单,我们都用OC来实现。

    其中我们客户端用手机,服务端我们用Xcode模拟器。(由于Xcode只能同一时间运行一个模拟器...)

    这里我们用客户端封包发送数据,然后服务端拆包解析数据。

    我们先来看看客户端的代码:

    static  NSString * Khost = @"10.10.100.48";
    static const uint16_t Kport = 6969;
    //建立连接
    - (BOOL)connect
    {
        return  [gcdSocket connectToHost:Khost onPort:Kport error:nil];
    }```
    初始化略过了,大家可以看看`github`中的代码,这里需要说的是,为了连接上本机的服务端,我们这里的`host`为服务端的`IP`地址:
     ![](http:https://img.haomeiwen.com/i2702646/ffac883e52194d51.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
    
    端口为6969(只需和服务端`accpet`端口一致即可)。
    
    注意:如果大家要运行`github`上的demo,只需修改这个`host`地址即可,把它改成你电脑(服务端)的IP地址。
    
    接着我们来看看`write`方法,我们在该方法中进行封包:
    

    //发送消息

    • (void)sendMsg
      {

      NSData *data = [@"你好" dataUsingEncoding:NSUTF8StringEncoding];
      NSData *data1 = [@"猪头" dataUsingEncoding:NSUTF8StringEncoding];
      NSData *data2 = [@"先生" dataUsingEncoding:NSUTF8StringEncoding];

    NSData *data3  = [@"今天天气好" dataUsingEncoding:NSUTF8StringEncoding];
    NSData *data4  = [@"吃饭了吗" dataUsingEncoding:NSUTF8StringEncoding];
    
    [self sendData:data :@"txt"];
    [self sendData:data1 :@"txt"];
    [self sendData:data2 :@"txt"];
    [self sendData:data3 :@"txt"];
    [self sendData:data4 :@"txt"];
    
    NSString *filePath = [[NSBundle mainBundle]pathForResource:@"test1" ofType:@"jpg"];
    
    NSData *data5 = [NSData dataWithContentsOfFile:filePath];
    
    [self sendData:data5 :@"img"];
    

    }

    • (void)sendData:(NSData *)data :(NSString *)type
      {
      NSUInteger size = data.length;

      NSMutableDictionary *headDic = [NSMutableDictionary dictionary];
      [headDic setObject:type forKey:@"type"];
      [headDic setObject:[NSString stringWithFormat:@"%ld",size] forKey:@"size"];
      NSString *jsonStr = [self dictionaryToJson:headDic];

    NSData *lengthData = [jsonStr dataUsingEncoding:NSUTF8StringEncoding];
    
    
    NSMutableData *mData = [NSMutableData dataWithData:lengthData];
    //分界
    [mData appendData:[GCDAsyncSocket CRLFData]];
    
    [mData appendData:data];
    
    
    //第二个参数,请求超时时间
    [gcdSocket writeData:mData withTimeout:-1 tag:110];
    

    }

    • (NSString *)dictionaryToJson:(NSDictionary *)dic
      {
      NSError *error = nil;
      NSData *jsonData = [NSJSONSerialization dataWithJSONObject:dic options:NSJSONWritingPrettyPrinted error:&error];
      return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
      }
    总共上述两个方法,也很简单,我们发送了6条数据,前5条为文本形式,最后一条是一个20多M的图片。当我们点击发送的时候会触发这个方法,这6条数据会被同时发出。
    
    这里我们来看看我们是如何封包的:
    - 我们定义了一个`headDic`,这个是我们数据包的头部,里面装了这个数据包的大小和类型信息(当然,你可以装更多的其他标识信息。)然后我们把它转成了`json`,最后转成`data`。
    - 然后我们把这个`head`拼在最前面,接着拼了一个:
    

    [GCDAsyncSocket CRLFData]

    这个是什么呢?其实它就是一个`\r\n`。我们用它来做头部的边界。(又或者我们可以规定一个固定的头部长度,来作为边界,这里仅仅是提供给大家一个思路)。
    - 最后我们把真正的数据包给拼接上。
    
    注:如果你想的更远的话,甚至可以在结尾,再拼一个包结束的标识符,后面我们会讲到为什么可以这么做。这里暂时先这样。
    
    **就这样,我们完成了数据的封包和发送。**
    
    ######客户端有了,接着我们来看看服务端是如何来拆包的:
    首先我们需要监听本机`6969`端口。(完整代码可以见`github`)
    
    

    static const uint16_t Kport = 6969;

    //等待连接

    • (BOOL)accept
      {
      NSError *error = nil;

      BOOL isSuccess = [gcdSocket acceptOnPort:Kport error:&error];
      if (isSuccess) {
      NSLog(@"监听成功6969端口成功,等待连接");
      return YES;
      }else{
      NSLog(@"监听失败,原因:%@",error);
      return NO;
      }
      }

    当客户端连接上来后,调用成功接收到客户端连接的代理方法:
    
    • (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(GCDAsyncSocket *)newSocket
      {
      NSLog(@"接受到socket连接");

      [_sockets addObject:newSocket];
      [newSocket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];
      }

    这里需要注意的是,成功接收到连接后,调用代理我们必须把新生成的这个`newSocket`保存起来,如果它被销毁了,那么连接就断开了,这里我们把它放到了一个数组中去了。
    这里需要注意的是,成功连接后,我们就调用了:
    

    [newSocket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];

    还记得我们封包的时候,数据包头部之后拼了这么一个分解符`data`。这样,当有数据包传输过来我们就能获取到这个数据包的头部(后面的信息先不读取)。
    
    接着我们来看看服务端的`read`代理方法是如何拆包的:
    
    • (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
      {
      //先读取到当前数据包头部信息
      if (!currentPacketHead) {
      currentPacketHead = [NSJSONSerialization
      JSONObjectWithData:data
      options:NSJSONReadingMutableContainers
      error:nil];
      if (!currentPacketHead) {
      NSLog(@"error:当前数据包的头为空");

            //断开这个socket连接或者丢弃这个包的数据进行下一个包的读取
            
            //....
            return;
        }        
      
        NSUInteger packetLength = [currentPacketHead[@"size"] integerValue];
        
        //读到数据包的大小
        [sock readDataToLength:packetLength withTimeout:-1 tag:110];
      
        return;
      

      }

    //正式的包处理
    NSUInteger packetLength = [currentPacketHead[@"size"] integerValue];
    //说明数据有问题
    if (packetLength <= 0 || data.length != packetLength) {
        NSLog(@"error:当前数据包数据大小不正确");
        return;
    }
    
    NSString *type = currentPacketHead[@"type"];
    
    if ([type isEqualToString:@"img"]) {
        NSLog(@"图片设置成功");
        self.recvImg.image = [UIImage imageWithData:data];
    }else{
        
        NSString *msg = [[NSString alloc]initWithData:data encoding:NSUTF8StringEncoding];
        NSLog(@"收到消息:%@",msg);
    }
    
    currentPacketHead = nil;
    
    [sock readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];
    

    }

    这个方法也很简单,我们判断,如果`currentPacketHead`(当前数据包的头部)为空,则说明这次读取,是一个头部信息,我们去获取到该数据包的头部信息。并且调用下一次读取,读取长度为从头部信息中取出来的数据包长度:
    

    [sock readDataToLength:packetLength withTimeout:-1 tag:110];

    这样当`GCDAsyncSocket`中数据缓冲区长度达到我们需要读取的`length`就能触发代理方法的第二次回调。(具体原理实现会在楼主的`GCDAsyncSocket`解析的后续系列`Read`篇中去讲,敬请期待)。
    这时候因为`currentPacketHead`不为空,所以我们就知道是去获取一个数据包,我们从头部信息中拿到数据包的类型,如果是文本或者图片,则分别输出或展示到屏幕上。读取完成后我们再次调用:
    

    [sock readDataToData:[GCDAsyncSocket CRLFData] withTimeout:-1 tag:110];

    这样就开始了下一个数据包的头部信息读取。
    **就这样,整个数据拆包的处理就完成了**。
    
    接着我们来讲讲我们之前所说的为什么可以在数据包之后加一个结束标识符。我们数据很可能在传输的过程中,丢失了一部分,或者头部信息不可读,导致我们无法正常读取这个数据包。
    可能我们会有一个应用场景,当出现错误包的时候,我们就直接抛弃掉它,直接开始下一个数据包的读取(当然现实中,我们往往是需要重新发送,这里仅仅是举一个应用场景)。这样这个结束标识符就起作用了,我们可以直接把数据读取到这个错误包的结束标识处,不做任何处理,这样相当于丢弃掉这个错误包了。
    
    ######最后我们来看看运行效果:
    我们客户端手机连接上服务器后,点击发送,发出我们上述客户端写的6条数据,在我们服务端,按照顺序接受到数据如图:
    
    
    ![](http:https://img.haomeiwen.com/i2702646/994fce4ea278b718.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
    
    
    
    #####写在结尾:
    本来不打算写应用篇的,但是很多朋友在问数据包相关的内容,而且正好之后的`Read`篇会涉及到这些,所以就当为了后面的内容做一个铺垫吧。
    
    **关于`IM`的路还有很长,路漫漫其修远兮,吾将上下而求索。**

    相关文章

      网友评论

      • ZhengYaWei:博主,我想问如果我只是在原始数据头部拼接了两个字节的数据长度,中间没有拼接[GCDAsyncSocket CRLFData],这个要怎么解决粘包问题。因为当初和后端商量协议的时候中间都没有添加[GCDAsyncSocket CRLFData],所以现在不知道怎么解决了。 求助
      • 西风颂:亲,麻烦问一下只通过包结束的标识符来区分粘包问题这样可靠吗?最好的办法是什么?
      • 勤奋happyfire:写的不错,这个库处理粘包问题还真是方便。
        有几点我觉得可能有些问题:
        1)关于UDP,文中说
        TCP和UDP都会因为下面两种情况造成粘包:
        发送端需要等缓冲区满才发送出去,造成粘包
        接收方不及时接收缓冲区的包,造成多个包接收
        看到这儿我特意又去搜了一下,UDP应该是不会有粘包问题的。楼主可以再查证一下。
        2)关于“数据包之后加一个结束标识符”这一段,保证数据完整和正确不是TCP协议做的事情吗,还需要应用层去操心吗?为什么应用层还有可能要丢弃所谓的错误包呢?感觉这个是底层用UDP时会处理的事情。
      • 谈daxia:大兄弟,请问一下我的服务器和客户端通讯的数据媒介没有用json, 而是Probuffer, 然后客户端收到服务器数据时也碰到粘包的问题了,请问下如何解析出来呢?
        涂耀辉:@谈daxia 其实是一样的啊。。你把长度拼在PB数据头部,读到长度之后,在去读取指定长度的PB数据,解析出来就行了啊。。
        谈daxia:@涂耀辉 大兄弟,文章我已经几遍了,可是我实际碰到问题和文章描述的有一点出入啊:
        因为我在方法代理方法-(void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag 中收到的data数据是Probuffer转化而来的,然后这个data如果自己转json或者字典都是不成功的。只能调用Probuffer的方法进行解析成GPBMessage对象。

        然后如果这里出现了粘包问题,几条消息在一起,解析后只能获取第一条消息,后面的几条就没解析出来了。
        是这个现象。
        涂耀辉:@谈daxia 看文章啊。。
      • 小羊孩子:大神,从git上下载了你的demo。修改成自己的ip。但是连接失败。返回host为(null) port为0
        小羊孩子:@涂耀辉 问下,GCDAsyncSocket调用writeData方法,用的是字节流还是字符流?能控设置发送的流形式吗
        小羊孩子:@涂耀辉 打开的。跟后台服务器是可以的,自己电脑上测试不行
        涂耀辉:@小羊孩子 server打开了么
      • Crycil:楼主你好,请问下心跳包怎么处理,有具体实例吗:clap:
        小羊孩子:大神,从git上下载了你的demo。修改成自己的ip。但是连接失败。返回host为(null) port为0
        涂耀辉:@Crycil 可以看看我之前的从入门到放弃那篇文章
      • 六月的第三天:已赞赏,很不错的博文。学习了
      • OhYoung1990:- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port;
        这个代理方法会多次执行是啥原因
        OhYoung1990:@涂耀辉 恩恩,知道了,封包错了被服务端踢了。。:pensive:
        涂耀辉:@OhYoung1990 这个方法连接成功才会调用一次,检查检查写的代码是不是有问题。。
      • 63a34d4f342d:大神求教.想用cocoaAsyncSocket + voip实现永久后台的聊天 ,voip之前的方法好像都不行了. 有思路吗? 不想用音乐占后台.内部应用不上Store
        涂耀辉:@wxdeng 推送只是一个提醒手段啊,真正消息拉取,还是进到app里,去服务端拉数据啊,不存在信息丢失
        63a34d4f342d:@涂耀辉 iOS10废弃了. voip现在不提供socket了,该走推送了. 推送的话怕即时性不高 丢失信息
        涂耀辉:@wxdeng 我记得是可以的,方法不行了的原因是什么?不能走APNS么?
      • R0b1n_L33:“packetLength <= 0” 的判断应当在读取到当前数据包头部信息的时候就做
        63a34d4f342d:大神求教.想用cocoaAsyncSocket + voip实现永久后台的聊天 ,voip之前的方法好像都不行了. 有思路吗? 不想用音乐占后台.内部应用不上Store
        tb144135:你好,现在也再玩socket,有一些问题,可以交流一下吗,我qq:1441355528
        涂耀辉:@ljysdfz 这里只是一个demo。。要完整加判断的话,篇幅怎么也不止这么大,看看思路就好。。
      • 开发者头条_程序员必装的App:感谢分享!已推荐到《开发者头条》:https://toutiao.io/posts/y5kz3d 欢迎点赞支持!
        欢迎订阅《iOS的学习笔记》https://toutiao.io/subjects/58931
      • 程序员小福子:在- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag方法中,两次进行if (!currentPacketHead)判断,第二次的
        if (!currentPacketHead) {
        NSLog(@"error:当前数据包的头为空");
        //断开连接
        [self disConnect];
        return;
        }
        判断根本就进不来,因为一旦判断成立就会进第一个判断里面了,然后return
        涂耀辉:确实这个判断有点小问题,不过不影响运行结果,文章已经修改过来了:smile:
      • af62ac510495:涂神,我的IM入门就靠你了!!坐等你更新了!
        涂耀辉:@Hyman_boy 额。。会尽快的:smiley:
      • d7059cb4225d:客户端给服务端每次传一个包的大小没有限制吗
        涂耀辉:@手撕包菜1989 这里头部已经读完了,CRLFData在读完头部的时候就已经被框架内部给截掉了,这时候是读正式的包数据了,所以用的是packetLength
        d7059cb4225d:@涂耀辉 嗯嗯,好的,这个方法[sock readDataToLength:packetLength withTimeout:-1 tag:110]获取的数据只是packetLength标记的长度,没有前面标记长度和[GCDAsyncSocket CRLFData]的data吗?
        涂耀辉:@手撕包菜1989 可以根据实际情况加限制啊
      • 不留名的黄子嘉:在didreaddata这个方法的监控超时时间设置不应该写在开头吗?当currentpackethead存在的时候 进不到那个方法socket就完全收不到消息了…
        涂耀辉:@不留名的黄子嘉 :smile:有问题随时沟通~
        不留名的黄子嘉:@不留名的黄子嘉 看错 下面还有一句..:sweat:
      • Ryan文濤:坐等下一篇
        涂耀辉:@Ryan文濤 尽快了,最近事有点多。。

      本文标题:即时通讯下数据粘包、断包处理实例(基于CocoaAsyncSoc

      本文链接:https://www.haomeiwen.com/subject/qplmittx.html