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

From: https://www.jianshu.com/p/2e16572c9ddc

前言

本文旨以实例的方式,使用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即时通讯,从入门到“放弃”?][4]中提到过,这个框架每次读取数据,必须手动的去调用上述这些read方法,而我们之前的实现思路是,第一次连接成功的代理触发后调用:

[4]: https://www.jianshu.com/p/2dbb360886a8
  • (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的路还有很长,路漫漫其修远兮,吾将上下而求索。

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

http://chenzhao.date/2019/04/28/即时通讯下数据粘包、断包处理实例(基于CocoaAsyncSocket).html

Author

陈昭

Posted on

2019-04-28

Updated on

2021-12-27

Licensed under

You need to set install_url to use ShareThis. Please set it in _config.yml.
You forgot to set the business or currency_code for Paypal. Please set it in _config.yml.

Kommentare

You forgot to set the shortname for Disqus. Please set it in _config.yml.