一步一步構建你的網路層-TCP篇

黑花白花發表於2018-02-22

目錄

  • TCP概述
  • 建立通訊連線
  • 定義通訊協議
  • 實現通訊協議
  • 發起資料請求
  • 處理請求響應
  • 處理後臺推送
  • 請求超時和取消
  • 心跳
  • 檔案下載/上傳?
  • WebSocket
TCP概述

TCP是一種面向連線的、可靠的、基於位元組流的傳輸層通訊協議,由IETF的RFC793定義. 在因特網協議族中,TCP屬於傳輸層, 位於網路層之上,應用層之下.

需要注意的是, TCP只是協議宣告, 僅對外宣告協議提供的功能, 但本身並不進行任何實現. 因此, 在介紹通訊協議時, 通常我們還會提及另一個術語: Socket. Socket並不是一種協議, 而是一組介面(即API). 協議的實現方通過Socket對外提供具體的功能呼叫. TCP協議的實現方提供的介面就是TCPSocket, UDP協議的實現方提供的介面就是UDPSocket...

通常, 協議的使用方並不直接面對協議的實現方, 而是通過對應的Socket使用協議提供的功能. 因此, 即使以後協議的底層實現進行了任何改動, 但由於對外的介面Socket不變, 使用方也不需要做出任何變更.

TCP協議基於IP協議, 而IP協議屬於不可靠協議, 要在一個不可靠協議的的基礎上實現一個可靠的資料傳輸協議是困難且複雜的, TCP的定義者也並不指望所有程式設計師都能自行實現一遍TCP協議. 所以, 與其說本文是在介紹TCP程式設計, 倒不如說是介紹TCPSocket程式設計.

建立通訊連線

通過Socket建立TCP連線是非常簡單的, 連線方(客戶端)只需要提供被連線方(服務端)的IP地址和埠號去呼叫連線介面即可, 被連線方接受連線的話, 介面會返回成功, 否則返回失敗, 至於底層的握手細節, 雙方完全不用關心. 但考慮到網路波動, 前後臺切換, 伺服器重啟等等可能導致的連線主動/被動斷開的情況, 客戶端這邊我會加上必要的重連處理. 主要程式碼如下:

//HHTCPSocket.h

@class HHTCPSocket;
@protocol HHTCPSocketDelegate <NSObject>

@optional
- (void)socket:(HHTCPSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port; //連線成功

- (void)socketCanNotConnectToService:(HHTCPSocket *)sock; //重連失敗
- (void)socketDidDisconnect:(HHTCPSocket *)sock error:(NSError *)error; //連線失敗並開始重連

@end

@interface HHTCPSocket : NSObject

@property (nonatomic, weak) id<HHTCPSocketDelegate> delegate;
@property (nonatomic, assign) NSUInteger maxRetryTime; //最大重連次數

- (instancetype)initWithService:(HHTCPSocketService *)service; //service提供ip地址和埠號

- (void)close;
- (void)connect; //連線
- (void)reconnect; //重連
- (BOOL)isConnected;

@end
複製程式碼
//HHTCPSocket.m

@implementation HHTCPSocket

- (instancetype)initWithService:(HHTCPSocketService *)service {
    if (self = [super init]) {
        self.service = service ?: [HHTCPSocketService defaultService];
        
        //1. 初始化Socket
        const char *delegateQueueLabel = [[NSString stringWithFormat:@"%p_socketDelegateQueue", self] cStringUsingEncoding:NSUTF8StringEncoding];
        self.reconnectTime = self.maxRetryTime;
        self.delegateQueue = dispatch_queue_create(delegateQueueLabel, DISPATCH_QUEUE_SERIAL);
        self.socket = [[GCDAsyncSocket alloc] initWithDelegate:self delegateQueue:self.delegateQueue];
        
        //2. 初始化Socket連線執行緒
        self.machPort = [NSMachPort port];
        self.keepRuning = YES;
        self.socket.IPv4PreferredOverIPv6 = NO; //支援ipv6
        [NSThread detachNewThreadSelector:@selector(configSocketThread) toTarget:self withObject:nil];
        
        //3. 處理網路波動/前後臺切換
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceivedNetworkChangedNotification:) name:kRealReachabilityChangedNotification object:nil];
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceivedAppBecomeActiveNotification:) name:UIApplicationDidBecomeActiveNotification object:nil];
    }
    return self;
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

#pragma mark - Interface

- (void)connect {
    if (self.isConnecting || !self.isNetworkReachable) { return; }
    self.isConnecting = YES;
    
    [self disconnect];
    
    //去Socket連線執行緒進行連線 避免阻塞UI
    BOOL isFirstTimeConnect = (self.reconnectTime == self.maxRetryTime);
    int64_t delayTime = isFirstTimeConnect ? 0 : (arc4random() % 3) + 1;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delayTime * NSEC_PER_SEC)), dispatch_get_global_queue(2, 0), ^{
        [self performSelector:@selector(connectOnSocketThread) onThread:self.socketThread withObject:nil waitUntilDone:YES];
    });
}

- (void)reconnect {
    
    self.reconnectTime = self.maxRetryTime;
    [self connect];
}

- (void)disconnect {
    if (!self.socket.isConnected) { return; }
    
    [self.socket setDelegate:nil delegateQueue:nil];
    [self.socket disconnect];
}

- (BOOL)isConnected {
    return self.socket.isConnected;
}

#pragma mark - GCDAsyncSocketDelegate

- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
    //連線成功 通知代理方
    if ([self.delegate respondsToSelector:@selector(socket:didConnectToHost:port:)]) {
        [self.delegate socket:self didConnectToHost:host port:port];
    }
    
    self.reconnectTime = self.maxRetryTime;
}

- (void)socketDidDisconnect:(GCDAsyncSocket *)sock withError:(NSError *)error {
    
    if ([self.delegate respondsToSelector:@selector(socketDidDisconnect:error:)]) {
        [self.delegate socketDidDisconnect:self error:error];
    }
    [self tryToReconnect];//連線失敗 嘗試重連
}

#pragma mark - Action

- (void)configSocketThread {
    
    if (self.socketThread == nil) {
        self.socketThread = [NSThread currentThread];
        [[NSRunLoop currentRunLoop] addPort:self.machPort forMode:NSDefaultRunLoopMode];
    }
    while (self.keepRuning) {
        [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]];
    }
    
    [[NSRunLoop currentRunLoop] removePort:self.machPort forMode:NSDefaultRunLoopMode];
    [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantPast]];
    [self.socketThread cancel];
    self.socket = nil;
    self.machPort = nil;
    self.socketThread = nil;
    self.delegateQueue = nil;
}

- (void)connectOnSocketThread {//實際的呼叫連線操作在這裡
    
    [self.socket setDelegate:self delegateQueue:self.delegateQueue];
    [self.socket connectToHost:self.service.host onPort:self.service.port error:nil];
    self.isConnecting = NO;
}

#pragma mark - Notification

- (void)didReceivedNetworkChangedNotification:(NSNotification *)notif {
    [self reconnectIfNeed];
}

- (void)didReceivedAppBecomeActiveNotification:(NSNotification *)notif {
    [self reconnectIfNeed];
}

#pragma mark - Utils

- (void)tryToReconnect {
    if (self.isConnecting || !self.isNetworkReachable) { return; }
    
    self.reconnectTime -= 1;
    if (self.reconnectTime >= 0) {
        [self connect];
    } else if ([self.delegate respondsToSelector:@selector(socketCanNotConnectToService:)]) {
        [self.delegate socketCanNotConnectToService:self];
    }
}

- (NSUInteger)maxRetryTime {
    return _maxRetryTime > 0 ? _maxRetryTime : 5;
}

@end
複製程式碼

這邊因為需要新增重連操作, 所以我在GCDAsyncSocket的基礎上又封裝了一下, 但總體程式碼不多, 應該比較好理解. 這裡需要注意的是GCDAsyncSocket的連線介面(connectToHost: onPort: error:)是同步呼叫的, 慢網情況下可能會阻塞執行緒一段時間, 所以這裡我單開了一個執行緒來做連線操作.

連線建立以後, 就可以讀寫資料了, 寫資料的介面如下:

- (void)writeData:(NSData *)data {
    if (!self.isConnected || data.length == 0) { return; }
    
    [self.socket writeData:data withTimeout:-1 tag:socketTag];
}
複製程式碼

至於讀資料, 這裡我們並不走介面, 而是通過回撥方法將讀到的資料以引數的形式將資料給到呼叫方. 這是因為連線的另一端時時刻刻都有可能傳送資料過來, 所以通常在連線建立後接收方都會進入一個死迴圈反覆讀取資料, 處理資料, 讀取資料... 虛擬碼大概像這樣:

 //連線成功...
 while (1) {
        
        Error *error;
        Data *readData = [socket readToLength:1024 error:&error];//同步 讀不到資料就阻塞
        if (error) { return; }
        
        [self handleData:readData];//同步非同步皆可 多為非同步
}
複製程式碼

具體到我們的程式碼中, 則是這個樣子:

// HHTCPSocket.h

@protocol HHTCPSocketDelegate <NSObject>
//...其他回撥方法
- (void)socket:(HHTCPSocket *)sock didReadData:(NSData *)data;//讀取到資料回撥方法
@end
複製程式碼
// HHTCPSocket.m

- (void)socket:(GCDAsyncSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
    //Socket連線成功 開始讀資料
    [self.socket readDataWithTimeout:-1 tag:socketTag];
}

- (void)socket:(GCDAsyncSocket *)sock didWriteDataWithTag:(long)tag {
    //Socket寫資料成功 繼續讀取資料
    [self.socket readDataWithTimeout:-1 tag:socketTag];
}

- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData *)data withTag:(long)tag {
    
    //從Socket中讀到資料 交由呼叫方處理
    if ([self.delegate respondsToSelector:@selector(socket:didReadData:)]) {
        [self.delegate socket:self didReadData:data];
    }
    [self.socket readDataWithTimeout:-1 tag:socketTag];//繼續讀取資料
}
複製程式碼

現在我們已經可以通過Socket建立一條會自動重連的TCP連線, 然後還可以通過Socket從連線中讀寫資料, 接下來要做的就是定義一套自己的通訊協議了.

定義通訊協議
  • 為什麼需要定義通訊協議

TCP協議定義了連線雙方以位元組流而不是報文段的方式進行資料傳輸, 這意味著任何應用層報文(image/text/html...)想要通過TCP進行傳輸都必須先轉化成二進位制資料. 另外, TCP實現出於傳輸效率考慮, 往往會在連線兩端各自開闢一個傳送資料緩衝區和一個接收資料緩衝區. 因此, 有時應用層通過Socket向連線中寫入資料時, 資料其實並沒有立即被髮送, 而是被放入緩衝區等待合適的時機才會真正的傳送. 理想情況下, TCP進行傳輸資料的流程可能像這樣:

一步一步構建你的網路層-TCP篇

但實際情況中, 因為Nagle演算法/網路擁堵/擁塞控制/接收方讀取太慢等等各種原因, 資料很有可能會在傳送緩衝區/接收緩衝區被累積. 所以, 上面的流程更可能是這樣:

一步一步構建你的網路層-TCP篇
或者這樣:

一步一步構建你的網路層-TCP篇
上面的圖都假設應用層報文不到一個MSS(一個MSS一般為1460位元組, 這對大部分非檔案請求來說都足夠了), 當報文超過一個MSS時, TCP底層實現會對報文進行拆分後多次傳輸, 這會稍微複雜些(不想畫圖了), 但最後導致的問題是一致的, 解決方案也是一致的.

從上面的圖容易看出, 無論資料在傳送緩衝區還是接收緩衝區被累積, 對於接收方程式來說都是一樣的: 多個應用層報文不分彼此粘作一串導致資料無法還原(粘包).

得益於TCP協議是可靠的傳輸協議(可靠意味著TCP實現會保證資料不會丟包, 也不會亂序), 粘包的問題很好處理. 我們只需要在傳送方給每段資料都附上一份描述資訊(描述資訊主要包括資料的長度, 解析格式等等), 接收方就可以根據描述資訊從一串資料流中分割出單獨的每段應用層報文了.

被傳輸資料和資料的描述一起構成了一段應用層報文, 這裡我們稱實際想傳輸的資料為報文有效載荷, 而資料的描述資訊為報文頭部. 此時, 資料的傳輸流程就成了這樣:

一步一步構建你的網路層-TCP篇

  • 定義一個簡單的通訊協議

自定義通訊協議時, 往往和專案業務直接掛鉤, 所以這塊其實沒什麼好寫的. 但為了繼續接下來的討論, 這裡我會給到一個非常簡單的Demo版協議, 它長這樣:

一步一步構建你的網路層-TCP篇

因為客戶端和服務端都可以傳送和接收資料, 為了方便描述, 這裡我們對客戶端發出的報文統一稱為Request, 服務端發出的報文統一稱為Response.

這裡需要注意的是, 這裡的Request和Response並不總是一一對應, 比如客戶端單向的心跳請求報文服務端是不會響應的, 而服務端主動發出的推送報文也不是客戶端請求的.

Request由4個部分組成:

  1. url: 類似HTTP中的統一資源定位符, 32位無符號整數(4個位元組). 用於標識客戶端請求的服務端資源或對資源進行的操作. 由服務端定義, 客戶端使用.

  2. content(可選): 請求攜帶的資料, 0~N位元組的二進位制資料. 用於攜帶請求傳輸的內容, 傳輸的內容目前是請求引數, 也可能什麼都沒有. 解析格式固定為JSON.

  3. serNum: 請求序列號, 32位無符號整數(4個位元組). 用於標示請求本身, 每個請求對應一個唯一的序列號, 即使兩個請求的url和content都相同. 由客戶端生成並傳輸, 服務端解析並回傳. 客戶端通過回傳的序列號和請求序列號之間的對應關係進行響應資料分發.

  4. contentLen: 請求攜帶資料長度, 32位無符號整數(4個位元組). 用於標示請求攜帶的資料的長度. 服務端通過contentLen將粘包的資料進行切割後一一解析並處理.

Response由5個部分組成:

  1. url: 同Request.

  2. respCode: 類似HTTP狀態碼, 32位無符號整數(4個位元組).

  3. content(可選): 響應攜帶的資料, 0~N位元組的二進位制資料. 攜帶的資料可能是某個Request的響應資料, 也可能是服務端主動發出的推送資料, 或者, 什麼都沒有. 解析格式固定為JSON.

  4. serNum: 該Response所對應的Request序列號, 32位無符號整數(4個位元組). 若Response並沒有對應的Request(比如推送), Response.serNum==Response.url.

  5. contentLen: Response攜帶的資料長度, 32位無符號整數(4個位元組). 用於標示Response攜帶的資料的長度. 客戶端通過contentLen將粘包的資料進行切割後一一解析並處理.

因為只是Demo用, 這個協議會比較隨意. 但在實際開發中, 我們應該儘量參考那些成熟的應用層協議(HTTP/FTP...). 比如考慮到後續的業務變更, 應該加上Version欄位. 加上ContentType欄位以傳輸其他型別的資料, 壓縮欄位位元組數以節省流量...等等.

實現通訊協議

有了協議以後, 就可以寫程式碼進行實現了. Request部分主要程式碼如下:

//HHTCPSocketRequest.h

/** URL型別肯定都是後臺定義的 直接copy過來即可 命名用後臺的 方便除錯時比對 */
typedef enum : NSUInteger {
    TCP_heatbeat = 0x00000001,
    TCP_notification_xxx = 0x00000002,
    TCP_notification_yyy = 0x00000003,
    TCP_notification_zzz = 0x00000004,
    
    /* ========== */
    TCP_max_notification = 0x00000400,
    /* ========== */
    
    TCP_login = 0x00000401,
    TCP_weibo_list_public = 0x00000402,
    TCP_weibo_list_followed = 0x00000403,
    TCP_weibo_like = 0x00000404
} HHTCPSocketRequestURL;

+ (instancetype)requestWithURL:(HHTCPSocketRequestURL)url parameters:(NSDictionary *)parameters header:(NSDictionary *)header;
複製程式碼
//HHTCPSocketRequest.m

+ (instancetype)requestWithURL:(HHTCPSocketRequestURL)url parameters:(NSDictionary *)parameters header:(NSDictionary *)header {

    NSData *content = [parameters yy_modelToJSONData];
    uint32_t requestIdentifier = [self currentRequestIdentifier];
    
    HHTCPSocketRequest *request = [HHTCPSocketRequest new];
    request.requestIdentifier = @(requestIdentifier);
    [request.formattedData appendData:[HHDataFormatter msgTypeDataFromInteger:url]];/** 請求URL */
    [request.formattedData appendData:[HHDataFormatter msgSerialNumberDataFromInteger:requestIdentifier]];/** 請求序列號 */
    [request.formattedData appendData:[HHDataFormatter msgContentLengthDataFromInteger:(uint32_t)content.length]];/** 請求內容長度 */
    
    if (content != nil) { [request.formattedData appendData:content]; }/** 請求內容 */
    return request;
}

+ (uint32_t)currentRequestIdentifier {
    
    static uint32_t currentRequestIdentifier;
    static dispatch_semaphore_t lock;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        currentRequestIdentifier = TCP_max_notification;
        lock = dispatch_semaphore_create(1);
    });
    
    dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
    if (currentRequestIdentifier + 1 == 0xffffffff) {
        currentRequestIdentifier = TCP_max_notification;
    }
    currentRequestIdentifier += 1;
    dispatch_semaphore_signal(lock);
    
    return currentRequestIdentifier;
}

複製程式碼

HHTCPSocketRequest主要做兩件事: 1.為每個Request生成唯一序列號; 2. 根據協議定義將應用層資料轉化為相應的二進位制資料.

應用層資料和二進位制資料間的轉化由HHDataFormatter完成, 它負責統一資料格式化介面和大小端問題 (關於大小端).

接下來是Response部分的程式碼:

//HHTCPSocketResponse.h

@interface HHTCPSocketResponse : NSObject

+ (instancetype)responseWithData:(NSData *)data;

- (HHTCPSocketRequestURL)url;

- (NSData *)content;
- (uint32_t)serNum;
- (uint32_t)statusCode;
@end
複製程式碼
//HHTCPSocketResponse.m

+ (instancetype)responseWithData:(NSData *)data {
    if (data.length < [HHTCPSocketResponseParser responseHeaderLength]) {
        return nil;
    }
    
    HHTCPSocketResponse *response = [HHTCPSocketResponse new];
    response.data = data;
    return response;
}

- (HHTCPSocketRequestURL)url {
    if (_url == 0) {
        _url = [HHTCPSocketResponseParser responseURLFromData:self.data];
    }
    return _url;
}

- (uint32_t)serNum {
    if (_serNum == 0) {
        _serNum = [HHTCPSocketResponseParser responseSerialNumberFromData:self.data];
    }
    return _serNum;
}

- (uint32_t)statusCode {
    if (_statusCode == 0) {
        _statusCode = [HHTCPSocketResponseParser responseCodeFromData:self.data];
    }
    return _statusCode;
}

- (NSData *)content {
    return [HHTCPSocketResponseParser responseContentFromData:self.data];
}

@end
複製程式碼

HHTCPSocketResponse比較簡單, 它只做一件事: 根據協議定義將服務端返回的二進位制資料解析為應用層資料.

最後, 為了方便管理, 我們再抽象出一個Task. Task將負責請求狀態, 請求超時, 請求回撥等等的管理. 這部分和協議無關, 但很有必要. Task部分的程式碼如下:

//HHTCPSocketTask.h

typedef enum : NSUInteger {
    HHTCPSocketTaskStateSuspended = 0,
    HHTCPSocketTaskStateRunning = 1,
    HHTCPSocketTaskStateCanceled = 2,
    HHTCPSocketTaskStateCompleted = 3
} HHTCPSocketTaskState;

@interface HHTCPSocketTask : NSObject

- (void)cancel;
- (void)resume;

- (HHTCPSocketTaskState)state;
- (NSNumber *)taskIdentifier;

@end
複製程式碼
//HHTCPSocketTask.m

//儲存Request和completionHandler Request用於將呼叫方資料寫入Socket completionHandler用於將Response交付給呼叫方
+ (instancetype)taskWithRequest:(HHTCPSocketRequest *)request completionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    HHTCPSocketTask *task = [HHTCPSocketTask new];
    task.request = request;
    task.completionHandler = completionHandler;
    task.state = HHTCPSocketTaskStateSuspended;
    ...其他 略
    return task;
}

//處理服務端返回的Response Socket讀取到相應的Response報文資料後會呼叫此介面
- (void)completeWithResponse:(HHTCPSocketResponse *)response error:(NSError *)error {
    if (![self canResponse]) { return; }
    
    NSDictionary *result;
    if (error == nil) {
    
        if (response == nil) {
            error = [self taskErrorWithResponeCode:HHTCPSocketResponseCodeUnkonwn];
        } else {
            
            error = [self taskErrorWithResponeCode:response.statusCode];
            result = [NSJSONSerialization JSONObjectWithData:response.content options:0 error:nil];
        }
    }
    
    [self completeWithResult:result error:error];
}

//將處理後的資料交付給呼叫方
- (void)completeWithResult:(id)result error:(NSError *)error {
    
    ...其他 略
    dispatch_async(dispatch_get_main_queue(), ^{
        
        !self.completionHandler ?: self.completionHandler(error, result);
        self.completionHandler = nil;
    });
}
複製程式碼

現在我們已經有了TCP連線, Request, Response和Task, 接下來要做的就是把這一切串起來. 具體來說, 我們需要一個管理方建立並管理TCP連線, 提供介面讓呼叫方通過Request向連線中寫入資料, 監聽連線中讀取到的粘包資料並將資料拆分成單個Response返回給呼叫方.

TCP連線部分比較簡單, 這裡我們直接跳過, 從發起資料請求部分開始.

發起資料請求

站在呼叫方的角度, 發起一個TCP請求與發起一個HTTP請求並沒有什麼區別. 呼叫方通過Request提供URL和相應引數, 然後通過completionHandler回撥處理請求對應的響應資料, 就像這樣:

// SomeViewController.m

- (void)fetchData {
    
    HHTCPSocketRequest *request = [HHTCPSocketRequest requestWithURL:aTCPUrl parameters:someParams header:someHeader];
    HHTCPSocketTask *task = [[HHTCPSocketClient sharedInstance] dataTaskWithRequest:request completionHandler:^(NSError *error, id result) {
        if (error) {
            //handle error
        } else {
            //handle result
        }
    }
    [task resume];
}
複製程式碼

站在協議實現方的角度, 發起網路請求做的事情會多一些. 我們需要將呼叫方提供的Request和completionHandler打包成一個Task並儲存起來, 當呼叫方呼叫Task.resume時, 我們再將Request.data寫入Socket. 這部分的主要程式碼如下:

//HHTCPSocketClient.m

@interface HHTCPSocketClient()<HHTCPSocketDelegate>

@property (nonatomic, strong) HHTCPSocket *socket;

//任務派發表 以序列號為鍵儲存所有已發出但還未收到響應的Request 待收到響應後再根據序列號一一分發
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, HHTCPSocketTask *> *dispatchTable;

...其他邏輯 略
@end

@implementation HHTCPSocketClient

...其他邏輯 略

#pragma mark - Interface(Public)

//新建資料請求任務 呼叫方通過此介面定義Request的收到響應後的處理邏輯
- (HHTCPSocketTask *)dataTaskWithRequest:(HHTCPSocketRequest *)request completionHandler:(HHNetworkTaskCompletionHander)completionHandler {
    
    __block NSNumber *taskIdentifier;
    //1. 根據Request新建Task
    HHTCPSocketTask *task = [HHTCPSocketTask taskWithRequest:request completionHandler:^(NSError *error, id result) {
        
        //4. Request已收到響應 從派發表中刪除
        dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
        [self.dispatchTable removeObjectForKey:taskIdentifier];
        dispatch_semaphore_signal(lock);
        
        !completionHandler ?: completionHandler(error, result);
    }];
    //2. 設定Task.client為HHTCPSocketClient 後續會通過Task.client向Socket中寫入資料
    task.client = self;
    taskIdentifier = task.taskIdentifier;
    
    //3. 將Task儲存到派發表中
    dispatch_semaphore_wait(lock, DISPATCH_TIME_FOREVER);
    [self.dispatchTable setObject:task forKey:taskIdentifier];
    dispatch_semaphore_signal(lock);
    
    return task;
}

- (NSNumber *)dispatchTask:(HHTCPSocketTask *)task {
    if (task == nil) { return @-1; }
    
    [task resume];// 通過task.resume介面發起請求 task.resume會呼叫task.client.resumeTask方法 task.client就是HHTCPSocketClient
    return task.taskIdentifier;
}

#pragma mark - Interface(Friend)

//最終向Socket中寫入Request.data的地方 此介面只提供給HHTCPSocketTask使用 對外不可見
- (void)resumeTask:(HHTCPSocketTask *)task {
 
    // 向Socket中寫入Request格式化好的資料
    if (self.socket.isConnected) {
        [self.socket writeData:task.request.requestData];
    } else {
     
        NSError *error;
        if (self.isNetworkReachable) {
            error = HHError(HHNetworkErrorNotice, HHNetworkTaskErrorTimeOut);
        } else {
            error = HHError(HHNetworkErrorNotice, HHNetworkTaskErrorCannotConnectedToInternet);
        }
        [task completeWithResponseData:nil error:error];
    }
}

@end
複製程式碼
//HHTCPSocketTask.m

@interface HHTCPSocketTask ()

- (void)setClient:(id)client;//此介面僅提供給上面的HHTCPSocketClient使用 對外不可見

@end

//對外介面 呼叫方通過通過此介面發起Request
- (void)resume {
    ...其他邏輯 略
    
    //通知client將task.request的資料寫入Socket
    [self.client resumeTask:self];
}
複製程式碼

簡單描述一下程式碼流程:

  1. 呼叫方提供Request和completionHandler回撥從HHTCPSocketClient獲得一個打包好的Task(通過dataTaskWithRequest:completionHandler:介面), HHTCPSocketClient內部會以(Request.serNum: Task)的形式將其儲存在dispatchTable中.

  2. 呼叫方通過Task.resume發起TCP請求, 待收到服務端響應後HHTCPSocketClient會根據Response.serNum從dispatchTable取出Task然後執行呼叫方提供的completionHandler回撥.(這裡為了和系統的NSURLSessionTask保持一致的介面, 我給TCPClient和TCPTask加了一些輔助方法, 程式碼上繞了一個圈, 實際上, Task.resume就是Socket.writeData:Task.Request.Data).

處理請求響應

正常情況下, 請求發出後, 很快就就會收到服務端的響應二進位制資料, 我們要做的就是, 從這些二進位制資料中切割出單個Response報文, 然後一一進行分發. 程式碼如下:

//HHTCPSocketClient.m

@interface HHTCPSocketClient()<HHTCPSocketDelegate>

//儲存所有收到的服務端資料 等待解析
@property (nonatomic, strong) NSMutableData *buffer;
...其他邏輯 略
@end

#pragma mark - HHTCPSocketDelegate

//從Socket從讀取到資料
- (void)socket:(HHTCPSocket *)sock didReadData:(NSData *)data {
    [self.buffer appendData:data]; //1. 儲存讀取到的二進位制資料
    
    [self readBuffer];//2. 根據協議解析二進位制資料
}

#pragma mark - Parse

//遞迴擷取Response報文 因為讀取到的資料可能已經"粘包" 所以需要遞迴
- (void)readBuffer {
    if (self.isReading) { return; }
    
    self.isReading = YES;
    NSData *responseData = [self getParsedResponseData];//1. 從已讀取到的二進位制中擷取單個Response報文資料
    [self dispatchResponse:responseData];//2. 將Response報文派發給對應的Task
    self.isReading = NO;
    
    if (responseData.length == 0) { return; }
    [self readBuffer]; //3. 遞迴解析
}

//根據定義的協議從buffer中擷取出單個Response報文
- (NSData *)getParsedResponseData {
    
    NSData *totalReceivedData = self.buffer;
    //1. 每個Response報文必有的16個位元組(url+serNum+respCode+contentLen)
    uint32_t responseHeaderLength = [HHTCPSocketResponseParser responseHeaderLength];
    if (totalReceivedData.length < responseHeaderLength) { return nil; }
    
    //2. 根據定義的協議讀取出Response.content的長度
    NSData *responseData;
    uint32_t responseContentLength = [HHTCPSocketResponseParser responseContentLengthFromData:totalReceivedData];
    //3. Response.content的長度加上必有的16個位元組即為整個Response報文的長度
    uint32_t responseLength = responseHeaderLength + responseContentLength;
    if (totalReceivedData.length < responseLength) { return nil; }
    
    //4. 根據上面解析出的responseLength擷取出單個Response報文
    responseData = [totalReceivedData subdataWithRange:NSMakeRange(0, responseLength)];
    self.buffer = [[totalReceivedData subdataWithRange:NSMakeRange(responseLength, totalReceivedData.length - responseLength)] mutableCopy];
    return responseData;
}

//將Response報文解析Response 然後交由對應的Task進行派發
- (void)dispatchResponse:(NSData *)responseData {
    HHTCPSocketResponse *response = [HHTCPSocketResponse responseWithData:responseData];
    if (response == nil) { return; }
    
    if (response.url > TCP_max_notification) {/** 請求響應 */
        
        HHTCPSocketTask *task = self.dispatchTable[@(response.serNum)];
        [task completeWithResponse:response error:nil];
    } else {/** 推送或心跳 略 */
        ...
    }
}

複製程式碼

簡單描述下程式碼流程:

  1. TCPClient監聽Socket讀取資料回撥方法, 將讀取到的服務端二進位制資料新增到buffer中.

  2. 根據定義的協議從buffer頭部開始, 不停地擷取出單個Response報文, 直到buffer資料取無可取.

  3. 從2中擷取到的Response報文中解析出Response.serNum, 根據serNum從dispatchTable中取出對應的Task(Response.serNum == Request.serNum), 將Response交付給Task. 至此, TCPClient的工作完成.

  4. Task拿到Response後通過completionHandler交付給呼叫方. 至此, 一次TCPTask完成.

這裡需要注意的是, Socket的回撥方法我這邊預設都是在序列佇列中執行的, 所以對buffer的操作並不沒有加鎖, 如果是在並行佇列中執行Socket的回撥, 請記得對buffer操作加鎖.

處理後臺推送

除了Request對應的Response, 服務端有時也會主動傳送一些推送資料給客戶端, 我們也需要處理一下:

//HHTCPSocketClient.m

- (void)dispatchResponse:(NSData *)responseData {
    HHTCPSocketResponse *response = [HHTCPSocketResponse responseWithData:responseData];
    if (response == nil) { return; }
    
    if (response.url > TCP_max_notification) {/** 請求響應 略*/
        //...
    } else if (response.url == TCP_heatbeat) {/** 心跳 略 */
        //...
    } else {/** 推送 */
        [self dispatchRemoteNotification:response];
    }
}

//各種推送 自行處理
- (void)dispatchRemoteNotification:(HHTCPSocketResponse *)notification {
    
    switch (notification.url) {
        case TCP_notification_xxx: ...
        case TCP_notification_yyy: ...
        case TCP_notification_zzz: ...
        default:break;
    }
}
複製程式碼
請求超時和取消

TCP協議的可靠性規定了資料會完整的, 有序的進行傳輸, 但並未規定資料傳輸的最大時長. 這意味著, 從發起Request到收到Response的時間間隔可能比我們能接受的時間間隔要長. 這裡我們也簡單處理一下, 程式碼如下:

//HHTCPSocketTask.m

#pragma mark - Interface

- (void)cancel {
    if (![self canResponse]) { return; }
    
    self.state = HHTCPSocketTaskStateCanceled;
    [self completeWithResult:nil error:[self taskErrorWithResponeCode:HHNetworkTaskErrorCanceled]];
}

- (void)resume {
    if (self.state != HHTCPSocketTaskStateSuspended) { return; }
    
    //發起Request的同時也啟動一個timer timer超時直接返回錯誤並忽略後續的Response
    self.timer = [NSTimer scheduledTimerWithTimeInterval:self.request.timeoutInterval target:self selector:@selector(requestTimeout) userInfo:nil repeats:NO];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
    
    self.state = HHTCPSocketTaskStateRunning;
    [self.client resumeTask:self];
}

#pragma mark - Action

- (void)requestTimeout {
    if (![self canResponse]) { return; }
    
    self.state = HHTCPSocketTaskStateCompleted;
    [self completeWithResult:nil error:[self taskErrorWithResponeCode:HHNetworkTaskErrorTimeOut]];
}

#pragma mark - Utils

- (BOOL)canResponse {
    return self.state <= HHTCPSocketTaskStateRunning;
}
複製程式碼

程式碼很簡單, 只是在寫入Task.Request的同時也開啟一個timer, timer超時就直接忽略Response並返回錯誤給呼叫方而已. 對於類似HTTP的GET請求而言, 忽略和取消幾乎是等價的. 但對於POST請求而言, 我們需要的可能就是直接斷開連線了, 這部分Demo中並未進行實現, 我還沒遇到類似的需求, 也沒想好該不該這樣做.

心跳

目前為止, 我們已經有了一個簡單的TCP客戶端, 它可以傳送資料請求, 接收資料響應, 還能處理服務端推送. 最後, 我們做一下收尾工作: 心跳.(關於心跳)

單向的心跳就不說了, 這裡我們給到一張Ping-Pong的簡易圖:

一步一步構建你的網路層-TCP篇

當傳送方為客戶端時, Ping-Pong通常用來驗證TCP連線的有效性. 具體來說, 如果Ping-Pong正常, 那麼證明連線有效, 資料傳輸沒有問題, 反之, 要麼連線已斷開, 要麼連線還在但伺服器已經過載無力進行恢復, 此時客戶端可以選擇斷開重連或者切換伺服器.

當傳送方為服務端時, Ping-Pong通常用來驗證資料傳輸的即時性. 具體來說, 當服務端向客戶端傳送一條即時性訊息時通常還會馬上Ping一下客戶端, 如果客戶端即時進行回應, 那麼說明Ping之前的即時性訊息已經到達, 反之, 訊息不夠即時, 服務端可能會走APNS再次傳送該訊息.

Demo中我簡單實現了一下Ping-Pong, 程式碼如下:

//HHTCPSocketHeartbeat

static NSUInteger maxMissTime = 3;
@implementation HHTCPSocketHeartbeat

+ (instancetype)heartbeatWithClient:(id)client timeoutHandler:(void (^)(void))timeoutHandler {
    
    HHTCPSocketHeartbeat *heartbeat = [HHTCPSocketHeartbeat new];
    heartbeat.client = client;
    heartbeat.missTime = -1;
    heartbeat.timeoutHandler = timeoutHandler;
    return heartbeat;
}

- (void)start {
    
    [self stop];
    self.timer = [NSTimer timerWithTimeInterval:60 target:self selector:@selector(sendHeatbeat) userInfo:nil repeats:YES];
    [[NSRunLoop mainRunLoop] addTimer:self.timer forMode:NSRunLoopCommonModes];
}

- (void)stop {
    [self.timer invalidate];
}

- (void)reset {
    self.missTime = -1;
    [self start];
}

- (void)sendHeatbeat {
    
    self.missTime += 1;
    if (self.missTime >= maxMissTime && self.timeoutHandler != nil) {//心跳超時 執行超時回撥
        self.timeoutHandler();
        self.missTime = -1;
    }
    
    HHTCPSocketRequest *request = [HHTCPSocketRequest requestWithURL:TCP_heatbeat parameters:@{@"ackNum": @(TCP_heatbeat)} header:nil];
    [self.client dispatchDataTaskWithRequest:request completionHandler:nil];
}

- (void)handleServerAckNum:(uint32_t)ackNum {
    if (ackNum == TCP_heatbeat) {//服務端返回的心跳回應Pong 不用處理
        self.missTime = -1;
        return;
    }
    
    //服務端發起的Ping 需要回應
    HHTCPSocketRequest *request = [HHTCPSocketRequest requestWithURL:TCP_heatbeat parameters:@{@"ackNum": @(ackNum)} header:nil];
    [self.client dispatchDataTaskWithRequest:request completionHandler:nil];
}

@end
複製程式碼

HHTCPSocketHeartbeat每隔一段時間就會發起一個serNum固定為1的心跳請求Ping一下服務端, 在超時時間間隔內當收到任何服務端回應, 我們認為連線有效, 心跳重置, 否則執行呼叫方設定的超時回撥. 另外, HHTCPSocketHeartbeat還負責回應服務端發起的serNum為隨機數的即時性Response(這裡的隨機數我給的是時間戳).

//HHTCPSocketClient.m

- (void)configuration {
    
    self.heatbeat = [HHTCPSocketHeartbeat heartbeatWithClient:self timeoutHandler:^{//客戶端心跳超時回撥 
        //  [self reconnect];
        SocketLog(@"heartbeat timeout");
    }];
}

#pragma mark - HHTCPSocketDelegate

- (void)socket:(HHTCPSocket *)sock didConnectToHost:(NSString *)host port:(uint16_t)port {
    [self.heatbeat reset];//連線成功 客戶端心跳啟動
}

- (void)socketDidDisconnect:(HHTCPSocket *)sock error:(NSError *)error {
    [self.heatbeat stop];//連線斷開 客戶端心跳停止
}

- (void)socket:(HHTCPSocket *)sock didReadData:(NSData *)data {
    [self.heatbeat reset];//收到服務端資料 說明連線有效 重置心跳
    //...其他 略
}

//獲取到服務端Response
- (void)dispatchResponse:(NSData *)responseData {
    HHTCPSocketResponse *response = [HHTCPSocketResponse responseWithData:responseData];
    if (response == nil) { return; }
    
    if (response.url == TCP_heatbeat) {/** 心跳 */
        [self.heatbeat handleServerAckNum:response.serNum];//回覆服務端心跳請求 如果有必要的話
    } 
}

複製程式碼

HHTCPSocketHeartbeat由TCPClient呼叫, 做的事情很簡單: 1)連線成功時啟動心跳; 2)收到服務端資料時重置心跳; 3)收到服務端Ping時進行回覆; 4)心跳超時斷開重連 5)連線斷開時停止心跳;

檔案下載/上傳?

到目前為止, 我們討論的都是類似DataTask的資料請求, 並未涉及到檔案下載/上傳請求, 事實上, 我也沒打算在通訊協議上加上這兩種請求的支援. 這部分我是這樣考慮的:

如果傳輸的檔案比較小, 那麼仿照HTTP直接給協議加上ContentType欄位, Content以特殊分隔符進行分隔即可.

如果傳輸的檔案比較大, 那麼直接在當前連線進行檔案傳輸可能會阻塞其他的資料傳輸, 這是我們不希望看到的, 所以一定是另起一條連線專用於大檔案傳輸. 考慮到檔案傳輸不太可能像普通資料傳輸那樣需要即時性和服務端推送, 為了節省服務端開銷, 檔案傳輸完成後連線也沒有必要繼續保持. 這裡的"建立連線-檔案傳輸-斷開連線"其實已經由HTTP實現得很好了, 而且功能還多, 我們沒必要再做重複工作.

基於以上考慮, 檔案傳輸這塊我更趨向於直接使用HTTP而不是自行實現.

至此, TCP部分的討論就結束了.

WebSocket

就我自己而言, 使用TCP只是看重TCP的全雙工通訊和即時性而已, 雖然TCPSocket已經大大降低了TCP的使用門檻, 但門檻依然存在, 使用者仍不可避免的需要對TCP有個大體瞭解, 還需要處理諸如"粘包""心跳"之類的細節問題. 如果你的需求只是需要全雙工通訊和即時性的資料傳輸, 並且對靈活性和流量要求不敏感的話, 那麼我更推薦你使用近乎零門檻的WebSocket.

從名字和介面來看, WebSocket有點像TCPSocket, 但它並不屬於Socket. WebSocket和HTTP一樣, 是基於TCP的應用層協議, 它在保留了TCP的全雙工通訊的同時還提供了以應用層報文為傳輸單位和Ping-Pong的功能. 對我們來說, WebSocket用起來就像自帶"粘包處理"和"心跳"功能的TCPSocket, 非常方便.

關於WebSocket的概念和使用, 這裡我不打算浪費各位的時間. 概念總會淡忘, 而使用上大體就和上面的TCPSocket一樣, 只是不用我們自己處理"粘包"和"心跳"了. Demo中我也給出了WebSocket的簡單示例, 供各位參考.

本文附帶的Demo地址

一步一步構建你的網路層-HTTP篇

相關文章