CocoaAsyncSocket与粘包、拆包

2,390 阅读10分钟

CocoaAsyncSocket三方框架,其封装了TCP和UDP的socket,分别是GCDAsyncSocket和GCDAsyncUdpSocket,处理了iv4和ipv6,给开发者省去了不少麻烦,只需要按照规则使用即可

这里主要介绍基于TCP的GCDAsyncSocket,也会简单介绍GCDAsyncUdpSocket部分逻辑与应用

这是我写的可以发送文字和图片的demo(注意更改客户端连接的ip地址):服务端 --- 客户端

实际上平时我们接触的即时通信逻辑大体为:以服务器为中心,桥接来自客户端们发送的数据给另一个客户端,如下所示:

image.png

GCDAsyncSocket的基本使用

通过socket的交互就了解到,服务端和客户端的交互过程是有些不一样的,GCDAsyncSocket虽然简化了,但是改不一样的还是不一样,其为CocoaAsyncSocket中的tcp封装类

服务端的基本使用

初始化

通过直接设置代理,设置代理执行队列,且开启接收监听客户端的连接即可,ip是自动获取的,只需要设置端口号即可

self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self
    delegateQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)];
    
NSError *error;
//开启接收服务器
[self.socket acceptOnPort:8040 error:&error];
if (error) {
    NSLog(@"服务器开启失败:%@",error.localizedDescription);
}else {
    NSLog(@"服务器socket开启成功");
}

遵循协议

初始化后,需要执行其遵循的GCDAsyncSocketDelegate代理协议,实现下面几个协议

客户端连接到当前服务器的协议回调,返回了客户端的socket,注意此时还还没开启数据监听回调功能,需要主动调用readDataWithTimeout方法,读取监听数据,且每次读取到新数据,监听会取消,需要重新开启调用

//客户端已经连接到当前服务器
- (void)socket:(GCDAsyncSocket *)sock didAcceptNewSocket:(GCDAsyncSocket *)newSocket {
    [self.clientSockets addObject:newSocket];
    
    [newSocket readDataWithTimeout:-1 tag:10010]; //读取客户端发送过来的消息
}

任何socket断开连接后的回调

//socket断开连接
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err {
    NSLog(@"socket断开连接: %@", err.localizedDescription);
}

通过调用readDataWithTimeout监听数据到来,当客户端发送数据时,会回调该方法,注意读取完毕数据后,需要再次调用readDataWithTimeout方法继续监听客户端数据

//接收到客户端的数据
//消息结构 数据长度 + 数据类型 + 数据,需要解决粘包和拆包的问题,后面单独介绍
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
    [self reciveData:data]; //接收只有粘包的逻辑
    //[self reciveMoreData:data]; //接收粘包拆包逻辑
    //读取完毕数据之后,缓存区断开,需要重新监听
    [sock readDataWithTimeout:-1 tag:10010];
}

消息发送成功的回调

//消息发送成功
- (void)socket:(GCDAsyncSocket *)sock didWriteDataWithTag:(long)tag {
    NSLog(@"消息发送成功:%ld", tag);
}

客户端的基本使用

初始化

通过直接设置代理,设置代理执行队列,然后连接到指定ip的服务器

self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self 
    delegateQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)];
    
NSError *error;
//开启接收服务器
[self.socket connectToHost:@"192.168.1.1" onPort:8040 withTimeout:-1 error:&error];
if (error) {
    NSLog(@"连接服务器失败:%@",error.localizedDescription);
}

遵循协议

初始化后,需要执行其遵循的GCDAsyncSocketDelegate代理协议,实现下面几个协议

客户端连接服务器成功后,会回调该方法,注意此时还还没开启数据监听回调功能,需要主动调用readDataWithTimeout方法,读取监听数据,且每次读取到新数据,监听会取消,需要重新开启调用

- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(nonnull NSString *)host port:(uint16_t)port {
    NSLog(@"连接服务器成功");
    //需要开启读取数据监听
    [sock readDataWithTimeout:-1 tag:10086];
}

任何socket断开连接后的回调

//socket断开连接
- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err {
    NSLog(@"断开了与服务器的连接");
}

通过调用readDataWithTimeout监听数据到来,当服务端发送数据时,会回调该方法,注意读取完毕数据后,需要再次调用readDataWithTimeout方法继续监听服务端数据内容

//接收到客户端的数据
//消息结构 数据长度 + 数据类型 + 数据,需要解决粘包和拆包的问题,后面单独介绍
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
    [self reciveData:data]; //接收只有粘包的逻辑
    //[self reciveMoreData:data]; //接收粘包拆包逻辑
    //读取完毕数据之后,缓存区断开,需要重新监听
    [sock readDataWithTimeout:-1 tag:10086];
}

消息发送成功的回调

//消息发送成功
- (void)socket:(GCDAsyncSocket *)sock didWriteDataWithTag:(long)tag {
    NSLog(@"发送消息成功");
}

粘包、拆包

注意:无论什么骚操作,一个socket发送时需要在一个线程内有序进行,可以避免额外的数据解析逻辑

传输数据时,如果一个信息数据过大,会分段传输,需要接收端根据其信息,拼接到一起封包处理,然而实际传输过程中,由于接收端可能延迟接收,导致会一次性从缓存区读取出好几段数据,因此会出现粘包拆包等现象,下面通过图文流程介绍: image.png

情景1:包之间都是依次通过,读取读取过程及时,没有出现粘包,但是C数据被分为了C1、C2分段传输,因此出现了拆包情况,需要取出数据拼接,进行合并

情景2:B和C两个包被一次性从缓存区读取,出现粘包; D1和D2是被分段传输的数据,出现了拆包现象,同时D1、D2两段被同时从一个缓存区读取,因此也出现了粘包现象

情景3:B和C1被一次性从缓存区读取,出现粘包,同时B、C1,还有C2和D,同时出现了粘包现象,C1、C2为正常的拆包现象,需要合并

拆包:针对拆包现象,需要对数据取出拼接成一个完整的数据

粘包:针对粘包现象,需要根据指定数据长度进行分离出独立的包

方案一、传输数据结构与粘包处理

平时传输的数据包一般都有限制,加上现在的设备,数据大小一般都能一次性传输完毕,无需将一个数据包拆分成多个,因此不会出现拆包现象,平时传递大图片和视频时,可以走http的文件传输,最后用tcp传输的url文本,因此可以简化为只有粘包逻辑的出现(此过程如果服务端发送顺序出错,会出现接收顺序问题)

传输基本数据结构:数据长度(8) + 类型(4) + 数据(n)

其中数据长度为这段数据的总长度(为n,当前数据包长度:8+4+n),实际可能会加入其他数据,例如时间等,类型根据发送的内容来确定

下面是发送非拆包消息时的加工过程

- (void)sendData {
    NSMutableData *mData = [NSMutableData data];
    if (self.tfSendMessage.text.length > 0) {
        //给没个客户端发送一段数据
        const char *textStr = self.tfSendMessage.text.UTF8String;
        NSData *data = [NSData dataWithBytes:textStr length:strlen(textStr)];
        
        unsigned long dataLength = data.length;
        NSData *lenData = [NSData dataWithBytes:&dataLength length:8];
        [mData appendData:lenData];
        
        //文字类型
        unsigned int typeByte = 0x00000001;
        NSData *typeData = [NSData dataWithBytes:&typeByte length:4];
        [mData appendData:typeData];
        
        [mData appendData:data];
        NSLog(@"发送内容为:%@", self.tfSendMessage.text);
        self.tfSendMessage.text = @"";
    }else {
        //发送图片,其实实际上不一定在非要传递图片的,有的走的是http上传到文件服务器,然后利用返回的url在发送给对方
        NSData *imgData = [NSData dataWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"MMGG" ofType:@"jpeg"]];
        
        //展示发送的图片4s
        [self showImageInfo:imgData];
        
        //发送图片
        unsigned long dataLength = imgData.length;
        NSData *lenData = [NSData dataWithBytes:&dataLength length:8];
        [mData appendData:lenData];
        
        //图片类型
        unsigned int typeByte = 0x00000002;
        NSData *typeData = [NSData dataWithBytes:&typeByte length:4];
        [mData appendData:typeData];
        
        [mData appendData:imgData];
    }
    //发送消息
    [self.socket writeData:mData withTimeout:-1 tag:10086];
}

接收消息的过程要处理掉粘包的问题,因此需要一个循环解析掉几个连在一起的内容,将他们拆成独立的包

代码如下所示:

//处理粘包逻过程
- (void)reciveData:(NSData *)data {
    if (data.length < 1) return;
    
    //当前接收的数据包长度
    unsigned long totolLength = data.length;
    unsigned long currentLength = 0;
    //do while解决粘包问题,在里面进行拆包
    do {
        unsigned long length;
        unsigned int type;
        [data getBytes:&length range:NSMakeRange(currentLength, 8)];
        [data getBytes:&type range:NSMakeRange(currentLength + 8, 4)];
        //获取实际数据
        NSData *contentData = [data subdataWithRange:NSMakeRange(currentLength + 12, length)];
        
        if (type == 1) {
            //文字
            NSString *content = [[NSString alloc] initWithData:contentData encoding:NSUTF8StringEncoding];
            NSLog(@"接收的数据为:%@", content);
        }else if (type == 2) {
            //图片
            [self showImageInfo:contentData];
        }else {
            NSLog(@"不支持的数据类型");
        }
        currentLength += length + 12;
    } while (currentLength < totolLength);
}

方案二、传输数据结构与粘包拆包处理

实际工作中,可能有些人已经先入为主,已经提前完成了拆包的逻辑,内容不易更改,或者由于其他因素导致,因此会出现较大数据传输,因此会引发拆包现象,在解决粘包的过程,还需要解决拆包的问题(尤其是服务器端,还需要给每个客户端传来的额外在增加接收数据的字段)

注意:此方案一定要保证传输的数据顺序传输,否则不接收数据的顺序问题,可能会导致数据包整合混乱,数据接收失败,严重的会导致崩溃

传输数据结构:数据总长度(8) + 当前数据段长度(8) + 类型(4) + 数据(n)

其中数据总长度储存了完整数据长度(例如:一个完整视频大小),当前数据段长度储存的为拆包的大小(n),可以根据实际情况额外增加新字段

此种情况,在发送和接收到数据的时候,要额外处理一下拆包的过程,因此过程或稍复杂一些,不过其同时解决了只有粘包、只有拆包、粘包拆包的问题,因此实现逻辑叶建荣了上面的一种情况

发送分段处理代码逻辑如下:

//发送可以分段的数据格式
- (void)sendMoreData {
    NSMutableData *mData = nil;
    if (self.tfSendMessage.text.length > 0) {
        mData = [NSMutableData data];
        //给没个客户端发送一段数据
        const char *textStr = self.tfSendMessage.text.UTF8String;
        NSData *data = [NSData dataWithBytes:textStr length:strlen(textStr)];
        
        unsigned long dataLength = data.length;
        NSData *tolLenData = [NSData dataWithBytes:&dataLength length:8];
        [mData appendData:tolLenData];
        
        unsigned long length = data.length;
        NSData *lenData = [NSData dataWithBytes:&length length:8];
        [mData appendData:lenData];
        
        //文字类型
        unsigned int typeByte = 0x00000001;
        NSData *typeData = [NSData dataWithBytes:&typeByte length:4];
        [mData appendData:typeData];
        
        [mData appendData:data];
        NSLog(@"发送内容为:%@", self.tfSendMessage.text);
        self.tfSendMessage.text = @"";
        //发送消息
        [self.socket writeData:mData withTimeout:-1 tag:10086];
    }else {
        //发送图片,其实实际上不一定在非要传递图片的,有的走的是http上传到文件服务器
        、、然后利用返回的url在发送给对方
        NSData *imgData = [NSData dataWithContentsOfFile:
            [[NSBundle mainBundle] pathForResource:@"MMGG" ofType:@"jpeg"]];
        
        //展示发送的图片4s
        [self showImageInfo:imgData];
        
        //分段发送图片
        unsigned long dataLength = imgData.length;
        NSData *tolLenData = [NSData dataWithBytes:&dataLength length:8];
        unsigned currentIndex = 0;
        do {
            mData = [NSMutableData data];
            //开头追加总长度
            [mData appendData:tolLenData];
            
            unsigned long length = dataLength > 1000 ? 1000 : dataLength;
            dataLength -= length; //减少长度
            
            //加入当前数据段长度
            NSData *lenData = [NSData dataWithBytes:&length length:8];
            [mData appendData:lenData];
            
            //图片类型
            unsigned int typeByte = 0x00000002;
            NSData *typeData = [NSData dataWithBytes:&typeByte length:4];
            [mData appendData:typeData];
            
            [mData appendData:[imgData subdataWithRange:NSMakeRange(currentIndex, length)]];
            //发送消息
            [self.socket writeData:mData withTimeout:-1 tag:10086];
            
            currentIndex += length; //设置下一个节点索引
        } while (dataLength > 0);
    }
}

接收粘包拆包逻辑代码如下所示:

//同时处理粘包拆包逻辑,只处理粘包逻辑的,这个也同样适用,这个总长度为数据的总长度,不计算前面的
- (void)reciveMoreData:(NSData *)data {
    if (data.length < 1) return;
    
    //当前接收的数据包长度
    unsigned long totolLength = data.length;
    unsigned long currentLength = 0;
    //do while解决粘包问题,在里面进行拆包
    do {
        //处理粘包逻辑
        unsigned long datalength; //数据总长度
        unsigned long length; //当前数据包长度
        unsigned int type; //数据类型
        [data getBytes:&datalength range:NSMakeRange(currentLength, 8)];
        [data getBytes:&length range:NSMakeRange(currentLength + 8, 8)];
        [data getBytes:&type range:NSMakeRange(currentLength + 16, 4)];
        //获取实际数据
        NSData *contentData = [data subdataWithRange:NSMakeRange(currentLength + 20, length)];
        
        currentLength += length + 20;
        
        //处理拆包逻辑
        if (self.reciveData.length < totolLength) {
            [self.reciveData appendData: contentData];
        }
        unsigned long reciveLength = self.reciveData.length;
        if (reciveLength  == datalength) {
            if (type == 1) {
                //文字
                NSString *content = [[NSString alloc] initWithData:self.reciveData 
                    encoding:NSUTF8StringEncoding];
                NSLog(@"接收的数据为:%@", content);
            }else if (type == 2) {
                //图片
                [self showImageInfo:self.reciveData];
            }else {
                NSLog(@"不支持的数据类型");
            }
            self.reciveData = [NSMutableData data]; //重新初始化
        }else if (reciveLength > datalength) {
            NSLog(@"数据传输或解析出现错误");
            return;
        }
    } while (currentLength < totolLength);
}

GCDAsyncUdpSocket简介

GCDAsyncUdpSocket为CocoaAsyncSocket中的Udp的代码封装,使用起来更简单

udp为非连接型的通信机制,即:没有客户端服务端的区别,开启upd可以直接向某个ip发送消息,也可以直接接收某个ip发来的消息,由于事先没有建立连接确认,因此可靠性没有了保证,平时使用较少

但是如果经过调整,那么在某些领域则会有一番用途,例如:部分游戏操作,人物位置、操作等,消息发送比较频繁,由于玩游戏一般网络较好,丢包率本身就比较低,如果在给udp加上心跳确认来保证连接,那么udp将在游戏中成为一个相对很可靠的连接,部分信息即使发送失败,也就最多相当于断网操作失败,也是比较正常的操作

下面简单介绍一下 GCDAsyncUdpSocket的代码设置

创建socket

创建socket过程需要遵循GCDAsyncUdpSocketDelegate协议,穿件完毕只需要准备接收信息即可

// 1 创建socket
    if (!self.udpSocket) {
        self.udpSocket = [[GCDAsyncUdpSocket alloc] initWithDelegate:self 
           delegateQueue:dispatch_get_global_queue(0, 0)];
    }
    NSLog(@"创建socket 成功");
    // 2: 绑定socket
    NSError * error = nil;
    [self.udpSocket bindToPort:8060 error:&error];
    if (error) {
        //监听错误打印错误信息
        NSLog(@"error:%@",error);
    }else {
        // 3: 监听成功则开始接收信息
        [self.udpSocket beginReceiving:&error];
    }

常用的几个协议

// 连接成功
- (void)udpSocket:(GCDAsyncUdpSocket *)sock didConnectToAddress:(NSData *)address{
    NSLog(@"连接成功 --- %@",address);
}

// 连接失败
- (void)udpSocket:(GCDAsyncUdpSocket *)sock didNotConnect:(NSError *)error{
    NSLog(@"连接失败 反馈: %@",error);
}

// 发送数据成功
- (void)udpSocket:(GCDAsyncUdpSocket *)sock didSendDataWithTag:(long)tag{
    
    NSLog(@"%ld tag 发送数据成功",tag);
}

// 发送数据失败
- (void)udpSocket:(GCDAsyncUdpSocket *)sock didNotSendDataWithTag:(long)tag 
    dueToError:(NSError *)error{
    NSLog(@"%ld tag 发送数据失败 : %@",tag,error); 
}

// 接受数据的回调
- (void)udpSocket:(GCDAsyncUdpSocket *)sock didReceiveData:(NSData *)data 
    fromAddress:(NSData *)address withFilterContext:(id)filterContext{
    //在这里可以自行测试接收的数据
}

发送数据只需要直接发送到某个ip即可

[self..udpSocket sendData:data toHost:@"192.168.1.1" port:8070 withTimeout:-1 tag:10080];

最后

上面便是使用GCDAsyncSocket实现的服务端和客户端的交互过程,包括了粘包拆包,以及交互的过程

也简单介绍了GCDAsyncUdpSocket的简单使用和部分使用场景

GCDAsyncSocket交互代码案例已经实现(注意更改客户端连接的ip地址):服务端 --- 客户端