iOS Socket -- GCDAsyncSocket

766 阅读12分钟

一、Socket

1.网络体系结构和网络协议

在说socket之前,先要简单说一说网络体系结构。OSI(Open System Interconnection Reference, 开放式系统互联通信参考)将计算机网络体系结构划分为以下七层:

image.png

其中媒体层是网络工程师所研究的对象,主机层则是用户所面向和关心的内容。

常应用到的传输协议有http协议、tcp/udp协议、ip协议分别对应于应用层、传输层、网络层。TCP/IP是传输层协议,主要解决数据如何在网络中传输;而HTTP是应用层协议,主要解决如何包装数据。

我们在传输数据时,可以只使用传输层(TCP/IP),那样的话由于没有应用层,便无法识别数据内容,如果想要使传输的数据有意义,则必须使用应用层协议,应用层协议很多,有HTTP、FTP、TELNET等等,也可以自己定义应用层协议。WEB使用HTTP作传输层协议,以封装HTTP文本信息,然 后使用TCP/IP做传输层协议将它发送到网络上。Socket是对TCP/IP协议的封装,Socket本身并不是协议,而是一个调用接口(API),通过Socket,我们才能使用TCP/IP协议。

2.Http和Socket连接区别

2.1 socket连接:

建立起一个TCP连接需要经过“三次握手”: 第一次握手:客户端发送syn包(syn=j)到服务器,并进入SYN_SEND状态,等待服务器确认; 第二次握手:服务器收到syn包,必须确认客户的SYN(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RECV状态; 第三次握手:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手。 握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据。断开连接时服务器和客户端均可以主动发起断开TCP连接的请求,断开过程需要经过“四次握手”。

socket连接就是所谓的长连接,理论上客户端和服务器端一旦加你其连接将不会主动断掉;但是由于各种环境因素可能会连接断开,比如说:服务器端或客户端主机down了,网络故障,或者两者之间长时间没有传输数据,网络防火墙可能会断开该连接以释放网络资源。所以当一个socket连接中没有数据传输的时候,那么为了维持连接需要发送心跳消息,具体心跳消息格式是开发者自己定义的。

2.2 HTTP连接

HTTP协议即超文本传送协议(HypertextTransfer Protocol ),是Web联网的基础,也是手机联网常用的协议之一,HTTP协议是建立在TCP协议之上的一种应用。 HTTP连接最显著的特点是客户端发送的每次请求都需要服务器回送响应,在请求结束后,会主动释放连接。从建立连接到关闭连接的过程称为“一次连接”。 1)在HTTP 1.0中,客户端的每次请求都要求建立一次单独的连接,在处理完本次请求后,就自动释放连接。 2)在HTTP 1.1中则可以在一次连接中处理多个请求,并且多个请求可以重叠进行,不需要等待一个请求结束后再发送下一个请求。 由于HTTP在每次请求结束后都会主动释放连接,因此HTTP连接是一种“短连接”,要保持客户端程序的在线状态,需要不断地向服务器发起连接请求。通常的 做法是即时不需要获得任何数据,客户端也保持每隔一段固定的时间向服务器发送一次“保持连接”的请求,服务器在收到该请求后对客户端进行回复,表明知道客户端“在线”。若服务器长时间无法收到客户端的请求,则认为客户端“下线”,若客户端长时间无法收到服务器的回复,则认为网络已经断开。

二.GCDAsyncSocket

连接
  • 预连接,检查delegate,delegateQueue,是否已经连接,支持IPv4/IPv6,
  • lookupHost,获取server地址
  • lookup,建立连接
  • connectWithAddress4:address6:,通过lookup调用
    • 调用createSocket,创建客户端socket
    • connectSocket,调用connect()函数连接服务器
服务器开始监听(acceptOnPort)

这里最终调用的是acceptOnInterface方法

  • 通过getInterfaceAddress4:address6:获取本地的IPv4/IPv5 地址以及端口
  • 创建IPv4/IPv6的socket,并且进行bind本机地址
  • 调用listen函数进行监听这个socket
  • 获取客户端连接回调,创一个DISPATCH_SOURCE_TYPE_READ的source并且启动,然后设置event hanlder,这里面会调用doAccept方法,在这个方法,通过accept()函数获取客户端新连接的socket,然后会异步到delegateQueue,调用代理方法socket:didAcceptNewSocket:来获取新的连接
发送数据

发送数据的对象是GCDAsyncWritePacket

发送的方法是writeData:

  • 异步到socketQueue,把GCDAsyncWritePacket对象加入到writeQueue
  • 调用maybeDequeueWrite,取出writeQueue第一个数据为currentWrite
  • 写入数据有三种方式
    • 通过write()函数正常写入
    • TLS方式,通过CFWriteStreamWrite写入
    • SSL方式,通过SSLWrite写入,在这里面如果遇到了I/O阻塞(errSSLWouldBlock),会把数据放入缓冲区进行再次写入

在iOS开发中使用socket,一般都是用第三方库GCDAsyncSocket(虽然也有原生CFSocket)。 GCDAsyncSocket 下载地址: GCDAsyncSocket

使用之前需要先在项目引入ASyncSocket库:

  1. 把ASyncSocket库源码加入项目:只需要增加RunLoop目录中的AsyncSocket.h、AsyncSocket.m、AsyncUdpSocket.h和AsyncUdpSocket.m四个文件。
  2. 在项目增加CFNetwork框架:在Framework目录右健,选择Add-->Existing Files... , 选择 CFNetwork.framework

一般来说,一个用户只需要建立一个socket长连接,所以可以用单例类方便使用。

单例方法:

// 创建单例
+ (Singleton *) sharedInstance
{
    static Singleton *sharedInstace = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstace = [[self alloc] initPrivate];
    });
    return sharedInstace;
}

// 私有创建方法,不公开
- (instancetype)initPrivate {
    if (self = [super init]) {
        _lockStr = @"1234";
    }
    return self;
}

// 废除init创建方法
- (instancetype)init {
    @throw [NSException exceptionWithName:@"初始化异常" reason:@"不允许通过init方法创建对象" userInfo:nil];
}

建立socket长连接:

#define TIME_OUT 20

// 建立socket连接
-(void)socketConnectHost{
    _socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)];
    NSLog(@"连接服务器");
    NSError *error = nil;
    [_socket connectToHost:_socketHost onPort:_socketPort withTimeout:TIME_OUT error:&error];
}

// socket成功连接回调
-(void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
    NSLog(@"成功连接到%@:%d",host,port);
    _bufferData = [[NSMutableData alloc] init]; // 存储接收数据的缓存区
    [_socket readDataWithTimeout:-1 tag:99];
}

心跳:

心跳机制是判断客户端与服务端双方是否存活。

好处是:断了之后客户端可以重新建连,而对于服务端来说清理无效连接。

心跳客户端和服务器都可以发起,一般来说客户端发,一般实现步骤如下

  • 客户端每隔一个时间发送一个包给服务器,并且设置超时时间
  • 服务器收到后回应一个包
  • 客户端如果收到包则说明正常,超时的话则说明挂了
@property (nonatomic, retain) NSTimer             *heartTimer;   // 心跳计时器

在连接成功的回调方法里,启动定时器,每隔2秒向服务器发送固定的消息来检测长连接。

// 心跳连接
-(void)longConnectToSocket{
    根据服务器要求发送固定格式的数据,假设为指令@"longConnect",但是一般不会是这么简单的指令
    NSString *longConnect = @"longConnect";
    NSData   *dataStream  = [longConnect dataUsingEncoding:NSUTF8StringEncoding];
    [_socket writeData:dataStream withTimeout:1 tag:1];
}

断开连接:

  • 主动断开:
- (void)cutOffSocket {
    [_socket disconnect];
    _socket.userData =  @(SocketOfflineByUser);
    NSLog(@"断开连接");
}
  • 被动断开:
-(void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err {
    if (err.code == 57) {
        _socket.userData = @(SocketOfflineByWifiCut); // wifi断开
    }
    else {
        _socket.userData =  @(SocketOfflineByServer);  // 服务器掉线
    }
    NSLog(@"断开连接,错误:%@",err);
}

错误码请见 sys/errno.h

发送消息:

// 发消息
- (void)sendMessage:(NSData *)data {
    [_socket writeData:data withTimeout:TIME_OUT tag:10];
}

// wirte成功
-(void)socket:(GCDAsyncSocket *)sock didWriteDataWithTag:(long)tag
{
    // 持续接收数据
    // 超时设置为附属,表示不会使用超时
    [_socket readDataWithTimeout:-1 tag:tag];
}

接收消息:

-(void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
    // 在这里处理消息
    [self disposeBufferData:data];

    //持续接收服务端的数据
    [sock readDataWithTimeout:-1 tag:tag];
}

TCP粘包

TCP是面向连接的传输层协议,TCP连接只能是一对一的,它提供可靠的交付服务,也就是说,通过TCP连接传送的数据,无差错、不丢失、不重复、并且按序到达,TCP提供全双工通信,TCP是面向字节流的,无消息保护边界

基于上面TCP所以会有粘包的问题,而UDP基于数据包则不会出现粘包。但是由于UDP传输不可靠,会出现丢包,无序的问题,而TCP则不会有。

TCP粘包的问题和相关处理。粘包是指发送方发送的若干包数据到接收方接收时粘成一包,TCP传输往往会出现粘包。出现粘包现象的原因是多方面的,它既可能由发送方造成,也可能由接收方造成。发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。若连续几次发送的数据都很少,通常TCP会根据优化算法把这些数据合成一包后一次发送出去,这样接收方就收到了粘包数据。接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据。

一种比较周全的对策是:接收方创建一预处理线程,对接收到的数据包进行预处理,将粘连的包分开。具体的方法就是在发送数据是在数据前加入包头,接收时首先将待处理的接收数据流(长度为m)强行转换成预定的结构数据形式,并从中取出结构数据长度字段n,而后根据n计算得到第一包数据长度。

1)若n<m,则表明数据流内容超过一段完整的数据结构,将前n长度的数据截取并进行处理,对于剩下的m-n长度数据重复上述解析和判断。 2)若n=m,则表明数据流内容恰好是一完整结构数据,直接将其存入临时缓冲区即可。 3)若n>m,则表明数据流内容尚不够构成一完整结构数据,需留待与下一包数据合并后再行处理。

下面是我和服务器约定好的包头和包的类型

// 定义包头
typedef struct tagNetPacketHead
{
    int version;                      //版本
    int eMainType;                  //包类型主协议
    int eSubType;                    //包类型子协议
    unsigned int nLen;              //包体长度
} NetPacketHead;

// 定义发包类型
typedef struct tagNetPacket
{
    NetPacketHead netPacketHead;      //包头
    unsigned char *packetBody;      //包体
} NetPacket;

收到数据时先将收到的数据放到缓存中,然后进行上述判断。

- (void)disposeBufferData:(NSData *)data {
    @synchronized (self.lockStr) {
        [_bufferData appendData:data];
        while (_bufferData.length >= 16) {
            struct tagNetPacketHead head;

            [_bufferData getBytes:&head range:NSMakeRange(0, 16)];
            while (_bufferData.length >= 16 && !(head.version == 1 && head.eMainType > -10 && head.eMainType < 1000 && head.eSubType > - 10 && head.eSubType < 1000)) {
                int a = (int)_bufferData.length - 1;
                _bufferData = [_bufferData subdataWithRange:NSMakeRange(1, a)].mutableCopy;
                if (_bufferData.length >= 16) {
                    [_bufferData getBytes:&head range:NSMakeRange(0, 16)];
                }
            }
            
            BOOL isIn = !(head.nLen > (_bufferData.length - 16));
            if (isIn && _bufferData.length >= 16) {
                NSMutableData *pendingData = [NSMutableData data];
                if (head.eSubType == -1) {
                    pendingData = [_bufferData subdataWithRange:NSMakeRange(4, 4)].mutableCopy;
                    [pendingData appendData:[_bufferData subdataWithRange:NSMakeRange(16, head.nLen)]];
                }
                else {
                    pendingData = [_bufferData subdataWithRange:NSMakeRange(4, 8)].mutableCopy;
                    NSLog(@"%d", head.nLen);
                    [pendingData appendData:[_bufferData subdataWithRange:NSMakeRange(16, head.nLen)]];
                }

                [DisposeManager disposeData:pendingData num:head.eMainType];
                int totalLen = _bufferData.length;
                _bufferData = [_bufferData subdataWithRange:NSMakeRange(16 + head.nLen, totalLen - 16 - head.nLen)].mutableCopy;
            }
        }
    }
}

注意:在这里加入线程锁@synchronized (self.lockStr),防止缓冲区同时被多个线程访问发生缓冲区数据混乱。

发送数据时则需要将数据按照约定的结构进行处理,在前边加上包头。

- (NSMutableData *)linkDataWithVersion:(NSData *)versionData mainType:(int)mainType subType:(int)subType packetBody:(NSData *)packetBody{
    if (!versionData) {
        int version = 1;
        versionData = [NSMutableData dataWithBytes:&version length:sizeof(version)];
    }
    NSMutableData *mainTypeData = [NSMutableData dataWithBytes:&mainType length:sizeof(mainType)];

    NSMutableData *subTypeData = [NSMutableData dataWithBytes:&subType length:sizeof(subType)];

    unsigned int len;
    if (packetBody) {
        len = packetBody.length;
    }
    else {
        len = 0;
    }

    NSMutableData *lenData = [NSMutableData dataWithBytes:&len length:sizeof(len)];

    NSMutableData *sendData = [[NSMutableData alloc] init];
    [sendData appendData:versionData];
    [sendData appendData:mainTypeData];
    [sendData appendData:subTypeData];
    [sendData appendData:lenData];
    [sendData appendData:packetBody];

    return sendData.mutableCopy;
}

注:这里的mainTypeData和subTypeData只是和本工程相关的主协议和子协议,并不具备普遍性。
注:version和服务器端约定好是1。

客户端负载均衡

负载均衡(Load Balance)一般指服务端负载均衡,意思是将负载(工作任务,访问请求)进行平衡、分摊到多个操作单元(服务器,组件)上进行执行。对于服务端负载均衡一般都会维护一个服务器清单,当客户端请求到来时负载均衡服务器会从清单中选出一台处理请求。

而客户端负载均衡最大的区别在于服务器清单是在客户端维护。

实现步骤:

  • 获取服务器列表并且缓存在本地
  • 对每个服务器进行连接并且发送测速包
  • 当全部拿到测速结果(一次来回的时间等)后,找到最优的服务器进行连接