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

前言

本文旨以实例的方式,使用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地址:

端口为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条数据,在我们服务端,按照顺序接受到数据如图:

写在结尾:

本来不打算写应用篇的,但是很多朋友在问数据包相关的内容,而且正好之后的Read篇会涉及到这些,所以就当为了后面的内容做一个铺垫吧。

关于IM的路还有很长,路漫漫其修远兮,吾将上下而求索。

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

推荐阅读更多精彩内容