GCDAsyncSocket 的封装与使用

文章:iOS Socket重构设计GithubGCDAsyncSocketManager

 

CocoaAsyncSocket 第三方库的用途:

CocoaAsyncSocket provides easy-to-use and powerful asynchronous socket libraries for Mac and iOS.

CocoaAsyncSocketMaciOS 提供了易于使用且强大的异步通信库。

它已经支持 IPv4IPv6

简单的 Socket 通信包括了创建连接、断开连接、发送 socket 业务请求、重连这四个基本功能。

 

1、创建连接

GCDAsyncSocket 提供了四种初始化的方法

/**
 * GCDAsyncSocket uses the standard delegate paradigm,
 * but executes all delegate callbacks on a given delegate dispatch queue.
 * This allows for maximum concurrency, while at the same time providing easy thread safety.
 * 
 * You MUST set a delegate AND delegate dispatch queue before attempting to
 * use the socket, or you will get an error.
 * 
 * The socket queue is optional.
 * If you pass NULL, GCDAsyncSocket will automatically create it's own socket queue.
 * If you choose to provide a socket queue, the socket queue must not be a concurrent queue.
 * If you choose to provide a socket queue, and the socket queue has a configured target queue,
 * then please see the discussion for the method markSocketQueueTargetQueue.
 * 
 * The delegate queue and socket queue can optionally be the same.
**/
- (id)init;
- (id)initWithSocketQueue:(dispatch_queue_t)sq;
- (id)initWithDelegate:(id)aDelegate delegateQueue:(dispatch_queue_t)dq;
/**
 * @param aDelegate  socket 的代理
 * @param dq  delegate 的线程
 * @param sq  socket 的线程。可选的设置,如果传入 null,GCDAsyncSocket 内部会创建一个它自己的 socket 线程,如果你要自己提供一个 socket 线程,千万不要提供一个并发线程,在频繁 socket 通信过程中,可能会阻塞掉
 */ 
- (id)initWithDelegate:(id)aDelegate delegateQueue:(dispatch_queue_t)dq socketQueue:(dispatch_queue_t)sq;

@property (atomic, weak, readwrite) id delegate; 
#if OS_OBJECT_USE_OBJC 
@property (atomic, strong, readwrite) dispatch_queue_t delegateQueue; 
#else 
@property (atomic, assign, readwrite) dispatch_queue_t delegateQueue; 
#endif

必须要需要设置 socket 的代理以及代理的线程,否则接收不到 socket 的回调。比如:

self.socket = [[GCDAsyncSocket alloc] initWithDelegate:delegate 
                                         delegateQueue:dispatch_get_main_queue()];

在设置代理之后,你需要尝试连接到相应的地址来确定你的 socket 是否能连通了。

- (BOOL)connectToHost:(NSString *)host
               onPort:(uint16_t)port
          withTimeout:(NSTimeInterval)timeout
                error:(NSError **)errPtr;

host 是主机地址,port 是端口号。

如果建连成功之后,会收到 socket 成功的回调,在成功里面可以做需要做的一些事情,比如:做了心跳的处理

- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port;

如果建连失败了,会收到失败的回调,在失败里面做重连的操作。

- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)err;

 

2、重连

重连操作其实比较简单,只需要再调用一次建连请求,重连规则是重连次数为 5 次,每次的时间间隔为 2n 次方,超过次数之后,就不再去重连了。

- (void)socketDidDisconnect:(GCDAsyncSocket*)sock withError:(NSError*)err 
{
    self.status= -1;

    if(self.reconnection_time >= 0 && self.reconnection_time <= kMaxReconnection_time) {

	[self.timer invalidate];
	self.timer = nil;

	int time = pow(2, self.reconnection_time);

	self.timer = [NSTimer scheduledTimerWithTimeInterval:time 
                                                      target:selfselector:@selector(reconnection) 
                                                    userInfo:nil 
                                                     repeats:NO];
	self.reconnection_time++;

	NSLog(@"socket did reconnection, after %ds try again", time);	
    }
    else {
	self.reconnection_time = 0;

	NSLog(@"socketDidDisconnect:%p withError: %@", sock, err);
    }
}

这里用 status 来标记 socket 的连接状态。

 

3socket 通信

需要和后端开发人员商定好 socket 协议格式,比如:

[NSString stringWithFormat:@"{\"version\":%d,\"reqType\":%d,\"body\":\"%@\"}\r\n", PROTOCOL_VERSION, reqType, reqBody];

为什么后面需要加上 \r\n 呢?

这个 \r\nsocket 消息的边界符,是为了防止发生消息黏连。没有 \r\n,可能由于某种原因,后端会收到两条 socket 请求,但是后端不知道怎么拆分这两个请求。同理,在收到 socket 请求回调时,也会根据这个边界符去拆分。

那为什么要用 \r\n 呢?

GCDAsyncSocket 不支持自定义边界符,它提供了四种边界符供你使用 \r\n\r\n、空字符串。

在拼装好 socket 请求之后,你需要调用 GCDAsyncSocket 的写方法,来发送请求,然后在写完成之后会收到写的回调。

[self.socket writeData:requestData 
           withTimeout:-1 
                   tag:0];

timeout 是超时时间,这个根据实际的需要去设置。

/**
 * @brief  写的回调
 */
- (void)socket:(GCDAsyncSocket*)sock didWriteDataWithTag:(long)tag;

在写之后,需要再调用读方法,这样才能收到你发出请求后从服务器那边收到的数据。

[self.socket readDataToData:[GCDAsyncSocket CRLFData] 
                withTimeout:10 
                  maxLength:50000 
                        tag:0];

[GCDAsyncSocket CRLFData] 是设置边界符,maxLength 是设置你收到的请求数据内容的最大值。

在读回调里面,你可以根据不同业务来执行不同的操作

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

最后一个则是断开连接,这个只需要调用

[self.socket disconnect];

这样的话,最简单基础的 socket 通信,就已经大致能完成了。

 

4在网络环境以及其他因素下,很有可能会造成客户端或者后端没有接收到回调或者请求,那该怎么办?

需要加上消息回执的处理。客户端发出请求的时候,可以将该请求放到存到数组里面,等到后端的相应回调再移除,如果该请求超时或者在一段时间内没有收到确认返回,说明后端没有接收到客户端的请求,可以将该请求重新发送。

客户端接收请求的时候,后端将数据发给客户端,客户端需要增加回执处理,告诉后端,客户端接收到数据了,如果后端没接收到,也重新推一遍数据,客户端和后端双向保护来解决丢失问题。

 

5、问题排查

不能定位是否是后端问题还是客户端/SDK 问题时,可以用命令行抓一下 socket 包看看(Charles 只能抓 httphttps 包)

~ $ sudo tcpdump -i any -n -X port 6969
Password:
tcpdump: data link type PKTAP
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on any, link-type PKTAP (Apple DLT_PKTAP), capture size 262144 bytes
15:46:02.776970 IP 127.0.0.1.65211 > 127.0.0.1.6969: Flags [P.], seq 260276795:260276835, ack 1931027326, win 12752, options [nop,nop,TS val 844986136 ecr 844981142], length 40
	0x0000:  0200 0000 4500 005c 0000 4000 4006 0000  ....E..\..@.@...
	0x0010:  7f00 0001 7f00 0001 febb 1b39 0f83 823b  ...........9...;
	0x0020:  7319 237e 8018 31d0 fe50 0000 0101 080a  s.#~..1..P......
	0x0030:  325d 7718 325d 6396 7b22 7573 6572 5f6d  2]w.2]c.{"user_m
	0x0040:  6964 223a 302c 2272 6571 5479 7065 223a  id":0,"reqType":
	0x0050:  312c 2276 6572 7369 6f6e 223a 357d 0d0a  1,"version":5}..
15:46:02.777010 IP 127.0.0.1.65211 > 127.0.0.1.6969: Flags [P.], seq 0:40, ack 1, win 12752, options [nop,nop,TS val 844986136 ecr 844981142], length 40
	0x0000:  0200 0000 4500 005c 0000 4000 4006 0000  ....E..\..@.@...
	0x0010:  7f00 0001 7f00 0001 febb 1b39 0f83 823b  ...........9...;
	0x0020:  7319 237e 8018 31d0 fe50 0000 0101 080a  s.#~..1..P......
	0x0030:  325d 7718 325d 6396 7b22 7573 6572 5f6d  2]w.2]c.{"user_m
	0x0040:  6964 223a 302c 2272 6571 5479 7065 223a  id":0,"reqType":
	0x0050:  312c 2276 6572 7369 6f6e 223a 357d 0d0a  1,"version":5}..
15:46:02.777045 IP 127.0.0.1.6969 > 127.0.0.1.65211: Flags [.], ack 40, win 12751, options [nop,nop,TS val 844986136 ecr 844986136], length 0
	0x0000:  0200 0000 4500 0034 0000 4000 4006 0000  ....E..4..@.@...
	0x0010:  7f00 0001 7f00 0001 1b39 febb 7319 237e  .........9..s.#~
	0x0020:  0f83 8263 8010 31cf fe28 0000 0101 080a  ...c..1..(......
	0x0030:  325d 7718 325d 7718                      2]w.2]w.

6969 是端口号,根据实际的调试端口号修改。

 

0x00 拆分 SocketManager

拆分 socket 相关的、业务相关的操作。

SocketManager 里只保留服务器读写数据、断开连接、心跳、重连、GCDAsyncSocket 回调设置。

// .h
@interface GCDAsyncSocketManager : NSObject

@property (nonatomic, assign, readonly) NSInteger connectStatus;     // 连接状态:-1 未连接,0 连接中,1 已连接
@property (nonatomic, assign, readonly) NSInteger reconnectionCount; // 建连失败重连次数

@end


// .m 
static const NSInteger TIMEOUT = 30; 
static const NSInteger kBeatLimit = 3; 

@interface GCDAsyncSocketManager ()
 
@property (nonatomic, strong) GCDAsyncSocket * socket;

@property (nonatomic, assign) NSInteger connectStatus;     // 连接状态
@property (nonatomic, assign) NSInteger reconnectionCount; // 建连失败重连次数
@property (nonatomic, assign) NSInteger beatCount;         // 发送心跳次数,用于重连
@property (nonatomic, strong) NSTimer * beatTimer;         // 心跳定时器
@property (nonatomic, strong) NSTimer * reconnectTimer;    // 重连定时器
@property (nonatomic, copy) NSString * host;               // Socket 连接的host地址 
@property (nonatomic, assign) uint16_t port;               // Sokcet 连接的端口

@end
/**
 * @brief  单例
 */
+ (nullable GCDAsyncSocketManager *)sharedInstance
{
    static GCDAsyncSocketManager * instance = nil;
    static dispatch_once_t once; 
    dispatch_once(&once, ^{
        instance = [[self alloc] init];
    });
    return instance;
}

- (instancetype)init
{
    if (self = [super init]) {
        self.connectStatus = -1;
    }
    return self;
}

/**
 * @brief  连接 socket
 */
- (void)connectSocketWithDelegate:(nonnull id)delegate
{
    if (self.connectStatus != -1) {
         NSLog(@"Socket Connect: YES");
         return;
    }
 
    self.connectStatus = 0;
 
    // 创建 socket
    self.socket = [[GCDAsyncSocket alloc] initWithDelegate:delegate delegateQueue:dispatch_get_main_queue()]; 

    NSError * error = nil;

    // 连接服务器
    if (![self.socket connectToHost:self.host onPort:self.port withTimeout:TIMEOUT error:&error]) {
        
        self.connectStatus = -1;
        NSLog(@"connect error: --- %@", error); 
    }
}

/**
 * @brief  socket 连接成功后发送心跳
 */
- (void)socketDidConnectBeginSendBeat:(nonnull NSString *)beatBody
{
    self.connectStatus = 1;
    self.reconnectionCount = 0;

    if (!self.beatTimer) {
        self.beatTimer = [NSTimer scheduledTimerWithTimeInterval:5.0 
                                                          target:self
                                                        selector:@selector(sendBeat:)
                                                        userInfo:beatBody
                                                         repeats:YES];
        [[NSRunLoop mainRunLoop] addTimer:self.beatTimer forMode:NSRunLoopCommonModes];
    }
}

/**
 *  socket 连接失败后重连的操作
 */
- (void)socketDidDisconectBeginSendReconnect:(nonnull NSString *)reconnectBody
{
    self.connectStatus = -1;

    if (self.reconnectionCount >= 0 && self.reconnectionCount <= kBeatLimit) {

        NSTimeInterval time = pow(2, self.reconnectionCount);

        if (!self.reconnectTimer) {

            self.reconnectTimer = [NSTimer scheduledTimerWithTimeInterval:time 
                                                                   target:self 
                                                                 selector:@selector(reconnection:) 
                                                                 userInfo:reconnectBody 
                                                                  repeats:NO]; 
            [[NSRunLoop mainRunLoop] addTimer:self.reconnectTimer forMode:NSRunLoopCommonModes];
        }
        self.reconnectionCount++;
    }
    else {
        [self.reconnectTimer invalidate];
        self.reconnectTimer = nil;
        self.reconnectionCount = 0;
    }
}

/**
 * @brief  向服务器发送数据
 */
- (void)socketWriteData:(nonnull NSString *)data
{
    NSData * requestData = [data dataUsingEncoding:NSUTF8StringEncoding];
   
    [self.socket writeData:requestData withTimeout:-1 tag:0]; 

    [self socketBeginReadData];
}

/**
 * @brief  socket 读取数据
 */
- (void)socketBeginReadData
{
    [self.socket readDataToData:[GCDAsyncSocket CRLFData] withTimeout:10 maxLength:0 tag:0]; 
}

/**
 *  socket 主动断开连接
 */
- (void)disconnectSocket
{
    self.reconnectionCount = -1;
 
    [self.socket disconnect];
    [self.beatTimer invalidate];
    self.beatTimer = nil;
}

/**
 *  重设心跳次数
 */
- (void)resetBeatCount
{
    self.beatCount = 0; 
}

/**
 *  设置连接的 host 和 port
 */
- (void)changeHost:(nullable NSString *)host port:(NSInteger)port
{
    self.host = host;
    self.port = port;
}

- (void)sendBeat:(NSTimer *)timer
{
    if (self.beatCount >= kBeatLimit) {
        [self disconnectSocket];
        return; 
    }
    else {
        self.beatCount++;
    }

    if (timer != nil) {
       [self socketWriteData:timer.userInfo];
    }
}

/**
 * @brief  重连
 */
- (void)reconnection:(NSTimer *)timer
{
    NSError * error = nil; 

    if (![self.socket connectToHost:self.host onPort:self.port withTimeout:TIMEOUT error:&error]) { 
        self.connectStatus = -1;
    }
}

 

0x01 业务接口改为通用接口

将不同的业务请求以枚举的方式列出来,方便外部调用的时候查看,并且最好在枚举后面加上注释,例如:

/**
 * 业务类型
 */
typedef NS_ENUM(NSInteger, GACRequestType) {
    GACRequestType_Beat = 1,                       //心跳
    GACRequestType_GetConversationsList,           //获取会话列表
    GACRequestType_ConnectionAuthAppraisal = 7,    //连接鉴权
};

这样就可以将业务接口用一个通用的接口替换掉,根据不同的 type 处理不同的业务,body 请求体和 callback 回调。

/**
 *  @brief 向服务器发送数据
 *
 *  @param type    请求类型
 *  @param body    请求体
 */
- (void)socketWriteDataWithRequestType:(GACRequestType)type
                           requestBody:(nonnull NSDictionary *)body
                            completion:(nullable SocketDidReadBlock)callback;

使用示例:

NSDictionary * requestBody = @{ @"limit": @(10), @"offset": @(0) };

[[FIMCommunicationManager sharedInstance] socketWriteDataWithRequestType:FIMRequestType_GetConversationsList
                                                             requestBody:requestBody
                                                              completion:^(NSError * error, id data) {
                       // do something

                   }];

 

0x02 使用 Blcok

之前会对不同的业务请求,设定相应的 delegate 回调,但是数量一多,使用起来很槽糕,所以参考 AFNetworking 的做法,发起请求时将 block 与一个唯一标识进行绑定,同时将这个唯一标识放到请求里面发给服务器(服务器对该标识不做任何处理),等到 GCDAsyncSocket 回调回来时,通过服务器返回的这个标识,找到对应的 block 回调出去。

这样对业务方来说,socket 接口用起来其实和 HTTP 请求接口是一模一样的,将请求的上下文也关联了起来。

16

具体实现:

/**
 * @brief  发起请求
 */
- (void)socketWriteDataWithRequestType:(GACRequestType)type
                           requestBody:(nonnull NSDictionary *)body
                            completion:(nullable SocketDidReadBlock)callback
{
    if (self.socketManager.connectStatus == -1) {
        NSLog(@"socket 未连通");
        if (callback) {
            callback([GACErrorManager errorWithErrorCode:2003], nil);
        }
        return;
    }
    
    // 生成唯一标识
    NSString * blockRequestID = [self createRequestID];
    if (callback) {
        // 将 block 与标识绑定,存在一个全局变量里
        [self.requestsMap setObject:callback forKey:blockRequestID];
    }
    
    GACSocketModel * socketModel = [[GACSocketModel alloc] init];
    socketModel.version = PROTOCOL_VERSION;
    socketModel.reqType = type;
    socketModel.reqId   = blockRequestID;
    socketModel.requestChannel = self.currentCommunicationChannel;
    socketModel.body    = body;
    
    [self.socketManager socketWriteData:[socketModel socketModelToJSONString]];
}

/**
 * @brief  接收到数据回调
 */
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag
{
    // 分界符号处理
    NSString * jsonString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    jsonString = [jsonString stringByReplacingOccurrencesOfString:@"\r\n" withString:@""];
     
    NSData * jsonData = [jsonString dataUsingEncoding:NSUTF8StringEncoding];
        
    NSError * jsonError;
    // 解析 json 数据
    NSDictionary * json = [NSJSONSerialization JSONObjectWithData:jsonData 
                                                          options:kNilOptions 
                                                            error:&jsonError];
    NSLog(@"socket - receive data %@", json);
        
    if (jsonError) {
        // 重新读取数据
        [self.socketManager socketBeginReadData];
        NSLog(@"json 解析错误: --- error %@", jsonError);
        return;
    }
        
    NSInteger requestType = [json[@"reqType"] integerValue];
    NSInteger errorCode   = [json[@"status"] integerValue];
    NSDictionary * body   = @{};
    NSString * requestID  = json[@"reqId"];
    NSString * requestChannel = nil;
    if ([[json allKeys] containsObject:@"requestChannel"]) {
        requestChannel = json[@"requestChannel"];
    }
     
    // 获取唯一标识   
    SocketDidReadBlock didReadBlock = self.requestsMap[requestID];
        
    if (errorCode != 0) {
        NSError *error = [GACErrorManager errorWithErrorCode:errorCode];

        if (requestType == GACRequestType_ConnectionAuthAppraisal
            && [self.socketDelegate respondsToSelector:@selector(connectionAuthAppraisalFailedWithErorr:)]) {
                [self.socketDelegate connectionAuthAppraisalFailedWithErorr:[GACErrorManager errorWithErrorCode:1005]];
        }
        if (didReadBlock) {
            didReadBlock(error, body);
        }
        return;
    }
        
    switch (requestType) {
        case GACRequestType_ConnectionAuthAppraisal: {
            [self didConnectionAuthAppraisal];
                
            NSDictionary * systemTimeDic = [body mutableCopy];
            [self differenceOfLocalTimeAndServerTime:[systemTimeDic[@"system_time"] longLongValue]];
        } 
            break;
            
        case GACRequestType_Beat:
        {
            [self.socketManager resetBeatCount];
        }
            break;
           
        case GACRequestType_GetConversationsList:
        {
            if (didReadBlock) {
                didReadBlock(nil, body);
            }
        }
            break;
            
        default: {
            if ([self.socketDelegate respondsToSelector:@selector(socketReadedData:forType:)]) {
                [self.socketDelegate socketReadedData:body forType:requestType];
            }
        }
            break;
    }

    [self.socketManager socketBeginReadData];
}
 
0x03 使用模拟服务器时间,来解决缓存消息保序问题

socket 模块里面,基于 FMDB 实现了一套缓存机制。但是聊天页面对数据库读写操作的场景非常复杂,而且对发送失败的消息也进行了缓存,如果使用 msgID 对消息进行保序,你要考虑发送成功和失败消息的排序,以及重发消息之后的排序等场景,这样实现起来比较困难。

所以我们采用消息的创建时间来进行保序。这样不管消息是怎么操作的,从数据库里面读出来的数据,我们只需要根据创建时间来排下序返回给业务层,如果业务层对数据进行修改的时候,我们更新消息的创建时间,这样下次取出来的顺序和 UI 展示的顺序也还是一样的。

这个创建时间是由服务器生成的,而且消息发送成功之后,服务器也不会返回给我们这条消息的创建时间,而且失败的消息服务器那边是不会存的,所以就需要我们本地模拟服务器来生成这个时间。

因为考虑到本地时间和服务器时间存在偏差,所以我们在 socket 建立连接成功之后,返回服务器时间给我们,拿到服务器时间之后和手机的本地时间做个比较,记录下这个偏差值,然后业务层在调用发送消息的接口时,socket 内部模拟出服务器创建时间赋值给该消息,然后存到数据库里面,这样就可以基本保证数据库存储消息的顺序和服务器的顺序是一致的。

17

 

0x04 监听网络状态来改变 socket 连接状态

socket 连接状态也做了微调,通过测试微信的连接,发现以下两点:

①、网络断开后,socket 直接断开,显示“未连接”;

②、有网但是 socket 连接不上时,socket 会一直重连,重连 n 次不成功后,休眠几秒,再重连,如此循环。

AFNetWorking 库里面监测网络状态类 AFNetworkReachabilityManager:在无网时,判断如果 socket 正在连接或者已连接时,主动调用 disconnect 断开连接;如果有网,判断如果 socket 未连接,主动建立连接,建立连接不成功时,走重连的流程,只是我们依旧保持了重连 n 次后,n 次失败后不再重连了,这个是与微信不同的地方。

 

0x05 使用 FIMSocketModel

因为请求的数据结构基本一样,所以定义了 FIMSocketModel 类来方便对数据的转化,定义几个必传的字段,以及可能请求不同所需的一些非必传字段,由于之前我们 body 体里面的内容是做了 2JSON 转化处理的,所以业务层传入 body 内容时叫苦连天,FIMSocketModel 也增加了 - socketModelToJSONString 方法,方便 Socket 内部转化成 JSON 处理,这样业务层只需要传一个字典进来,Socket 内部就会处理好一切,使用起来就方便了。

- (NSString *)socketModelToJSONString
{
    NSAssert(self.body != nil, @"Argument must be non-nil");
    if (![self.body isKindOfClass:[NSDictionary class]]) {
        return nil;
    }
    // 实际指向的内容为字符串
    self.body = [self dictionnaryObjectToString:self.body];
    NSString * jsonString = [self toJSONString];
    jsonString = [jsonString stringByAppendingString:@"\r\n"];
    return jsonString;
}

/**
 * NSDictionary -> NSString
 */
- (NSString *)dictionnaryObjectToString:(NSDictionary *)object
{
    NSError * error = nil;
    NSData * stringData = [NSJSONSerialization dataWithJSONObject:object 
                                                          options:NSJSONWritingPrettyPrinted
                                                            error:&error];
    if (error) {
        return nil;
    }
    
    NSString * jsonString = [[NSString alloc] initWithData:stringData encoding:NSUTF8StringEncoding];
    // 字典对象用系统 JSON 序列化之后得到的 data,经过转 UTF-8 后的 jsonString 里面会包含 "\n" 及 " ",需要替换掉
    jsonString = [jsonString stringByReplacingOccurrencesOfString:@"\n" withString:@""];
    jsonString = [jsonString stringByReplacingOccurrencesOfString:@" " withString:@""];
    return jsonString;
}

数据传输层用 protobuf 更方便,比 json 好。

You may also like...