黑花白花的簡書
c: Hello, i'm black-flower, is anybody there?
s: Yes, i'm white-flower, do you still at there? Mr.black-flower.
c: Yes, yes, i'm here! Now, Mr.white-flower, let's dancing!
s: Uh......mdzz!
.................. Lost Connection ..................
前言
本文基於CocoaAsyncSocket從TCP連接的建立到請求結果的處理為你概述如何構建一個方便易用的iOS網絡層, 全文約8千字, 預計花費閱讀時間20 - 30分鐘.
目錄
建立一個可靠的網絡連接
1.連接的定義
2.連接的建立與關閉
3.自動重連處理
自定義網絡任務
1.自定義網絡協議
2.根據協議制定請求
3.化請求為任務
網絡任務的派發
1.任務的派發
2.任務的取消
3.多服務器的切換
合理的使用請求派發器
一.建立一個可靠的網絡連接
1.連接的定義
在介紹網絡連接前, 我們先描述一下打電話的過程. 我們在打電話之前一定是要先接通到對方, 然後才開始聊天, 沒有誰對著電話先來兩段單口, 再去撥號的. 接通以後, 我們通過聽筒收聽對方的語音, 通過話筒發送自己的語音. 另外, 在通話過程中如果出現信號波動導致連接斷開, 我們通常會馬上回撥過去以繼續通話. 最後, 在通話結束時, 我們或者對方就會主動斷開此次通話連接.
在一定程度上, 網絡通信和打電話是差不多的. 在通信過程中, 撥號就對應著建立網絡連接, 回撥和掛斷對應著重連和斷開連接, 聽筒是接收數據, 而話筒則是發送數據. 那麼根據以上描述, 我們的網絡連接定義如下:
2.連接的建立與關閉
接口定義見名知意就不解釋了, 我們看看具體實現:
內部我們使用GCDAsyncSocket去完成實際的連接過程, 建立一個GCDAsyncSocket需要一個delegate和delegateQueue, 我們把delegate設置為自己, delegateQueue設置為入參或者自建隊列. 另外GCDAsyncSocket基於runloop處理數據讀取, 我們不希望這個讀取過程影響到UI的流暢度, 所以分配一個socketThread去處理這些事情.
有了socket以後我們就可以進行連接了:
在連接前我們先斷開之前的連接. 連接時需要一個服務器地址和端口號, 這部分和上一篇的HTTP連接是一樣的, 不再贅述. 另外, GCDAsyncSocket的連接方法是一個同步執行過程, 所以我們把連接也放到之前建立的socketThread中去. 最後, 如果連接失敗的話我們會調用reconnect進行重連, 連接成功就開始讀取數據並將數據發送到外部. 至於代碼裡面的delayTime會在下文進行介紹.
3.自動重連處理
在介紹自動重連之前,我們先介紹一下心跳保活機制,一般的心跳分兩種,單向的ping機制和雙向的ping-pong機制。
具體的, ping機制指發送方定時向另一方發送一個心跳包, 接收方接收到後就知道對方在線, 同時回復一個心跳包, 發送方收到回復後也可以確定對方在線, 否則說明對方不在線, 那麼發送方就會主動斷開連接.一般這個發送方是客戶端。
單向心跳的問題在於, 接收方是處於被動地位, 在收到心跳稍後到下一次心跳到達之前的中間間隔時間它並不能確定對方是否還在線, 典型的情況就是電梯隧道之類的場景, 連接雖在但信號太弱, 不足以進行網絡通信. 由此引出ping-pong機制, 服務端在發出消息後會要求接收方回復一個心跳, 如果在規定時間內沒有收到這個回復, 那麼認為消息發送失敗, 斷開這個無效連接等待客戶端重連, 如果是重要的消息則考慮轉由APNS推送到客戶端. 同樣的, 如果是客戶端發送請求後, 規定時間內未收到服務器的回復, 那麼就是一個超時錯誤返回給調用頁面, 多次超時也需要主動斷開連接然後重連。
回到重連的問題上, 作為接收方的客戶端如果被服務器主動斷掉, 這說明網絡可能有問題, 稍後重連就是. 但如果是客戶端主動斷開連接除了網絡問題外還有可能是服務器此時已經過載或者掛掉, 無力回復心跳. 那麼此時如果所有的客戶端斷掉後馬上同時進行連接, 那麼剛剛恢復的服務器面對這幾十萬同時到來的連接馬上又會被搞垮, 惡性循環. 這也是為什麼在connect方法裡面對於連接的delayTime會有一個隨機數的原因.
二. 自定義網絡任務
上面的流程走完以後我們就能得到一個自管理的的socket連接, 因此, 我們不用關注各種連接邏輯的處理, 而是可以專注於網絡數據的收發.
數據在TCP中是以流的形式進行傳遞的, 多個數據包首尾相連不分彼此在同一個流中進行傳輸, 這樣的數據流即使到了接收端也並不能被正確解析(也就是粘包), 因此, 在數據打包發送之前, 我們需要給數據包拼裝上標記, 以區分各個數據包的邊界, 這個標記就是數據包的包頭. 基於TCP的HTTP就是在每次請求之前自動加上了這些數據頭, 所以通常我們只需要提供請求數據不需要關心數據頭, 因為這些在HTTP中已經處理好了. 但是當我們直面TCP時, 這些都需要我們自己去處理.
1.自定義網絡協議
根據上面的描述, 定義網絡請求會分為兩個部分, 請求頭和請求體, 一般請求體拼裝在請求頭後面, 請求體就是請求對應的參數/數據, 而請求頭就是此次數據包的描述, 必要的字段包括: 此次請求的操作(messageType, 類比HTTP的URL), 請求序列號(messageSerialNum, 請求唯一標識), 請求體長度(messageContentLength, 防粘包). 其他的字段多是跟公司業務直接掛鉤, 比如用於數據校驗的checkSum或者請求尾, HTTP中常用於自動登錄的sessionId, 標識資源改變狀態的ETag和Last-Modified等等...
另外請求頭和請求體的拼裝也有兩種, 直接拼裝和分隔符拼裝.
直接拼裝的格式如下:
分隔符拼裝的格式如下(分隔符 ==
):
兩者的共同點: 兩種拼裝方式的請求頭的長度和頭裡面各個字段的位置和長度都是定值, 這樣收到數據的一方才能進行解析。
兩者的不同點: 直接拼裝的方式是先取到請求頭, 再根據請求頭的Length字段去截取後面的請求體, 最後通過checkSum或者請求尾校驗數據, 有問題就拋棄, 這個過程要求必須能正確拿到請求頭的Length字段同時請求體也完整的收到(即不能丟包), 不然後續的解析全都會出問題, 只能重連. 而分隔符拼裝的方式通常是先讀第一個 拿到請求頭, 再讀一個 拿到請求體, 兩者通過比對有問題就丟掉, 沒問題就處理, 即使出現丟包, 只要不是 丟掉, 後續的解析都不會有問題, 不用重連.
我司采用的是直接拼裝的方式, 至於為什麼, 我來公司前他們就這樣干了. 不過好在TCP本身是可靠連接, 有各種機制保障數據完整到達, 所以丟包的概率很小, 倒是沒出過什麼問題.
2.根據協議制定請求
根據以上描述, 我們需要自定義一個網絡請求類, 提供給它相應的請求頭和請求體, 輸出排列好的請求數據包. 以數據請求為例:
因為請求類型(即URL)非常重要, 所以單獨拎出來作為一個參數提醒非傳不可, 而請求頭中其他的非必要字段都聲明在HHSocketRequestHeader中以header入參, 至於請求體, 我司用的是Google的ProtocolBuffers, 網上資料很多, 不做贅述。
這裡根據數據請求的操作類型定義了三個接口, 因為心跳請求是不攜帶任何信息的, 所以只需要一個序列號, 只有實際的數據請求才會三個參數都需要, 至於取消請求會在下文介紹. 來看看具體實現:
我們參照NSURLSessionTask給每個請求一個遞增的identifier做請求序列號, 這個Identifier標志所有從客戶端發出的請求, 而像心跳和在線推送這些請求不在此列, 而是定義在HHSocketRequestType枚舉中, 這樣, 當解析出請求序列號時我們就可以根據序列號的定義規則判斷此時是數據請求的Response還是心跳抑或是服務器推送了, 這裡我們預留50個序列號方便以後拓展。
HHDataFormatter是一個int/data互轉的工具類, 內部走的都是同一個實現, 外部給出多個接口提高可讀性和拓展性, 另外注意一下int轉data有個大小端問題(即網絡字節序和主機字節序), 其他就沒什麼好說的了.
3.化請求的為任務
以上的請求只是對一次網絡操作的描述, 它只知道自己要做什麼操作, 但是不知道什麼時候會被發起, 什麼時候被取消, 操作完成後又該做什麼. 那麼對於請求請求的具體管理, 我們定義一個HHSocketTask:
HHSocketTask作為一次網絡任務的抽象, 內部負責管理任務執行的狀態, 任務執行結果的回調, 外部暴露任務派發和取消的接口. 現在就等著誰來調用這些接口了.
三. 網絡任務的派發
現在我們有了HHSocketRequest和HHSocketTask, 接下來的套路和HTTP篇的套路類似, 我們需要一個派發器來派發任務, 在任務派發前保持這個執行中的任務以處理任務需要取消的情況, 在派發後則刪除這個任務. 照例, 定義一個單例:
這裡的設計其實有點問題, 按理一個socket連接就應該對應一個數據接收端和數據派發表, 但是我司只有一個用於數據請求的連接(估計大部分小公司都是這樣), 所以我就偷個懶定義成屬性了. 各個屬性見名知意, 不做贅述. 下面看看接口的實現:
這部分代碼和HTTP篇基本一致, 只多了task.client = self這一行, 這個會在下文介紹, 任務的派發都是直接調用task.resume方法. 接下來看看resume的實現:
1.任務的派發
這裡稍微有點繞, 我們一步一步來看:
Task.resume
resume內部首先判斷任務是否可用(即是否在未派發前被外部取消), 可用的話設置任務狀態為派發中, 然後調用self.client.resumeTask, 這個self.client是在上文中通過HHSocketClient.dataTaskWithRequest派發task時我們賦值的(就是那多出來的一行task.client = self).
HHSocketClient.resumeTask
resumeTask方法判斷此時Socket的連接狀態, 若連接可用就將序列化好的數據包寫入到連接中來執行實際的請求派發, 請求派發成功後服務器會返回響應數據, 返回的響應數據會在socket:didReadData:裡面進行接收, 若連接不可用時直接執行Task.completeWithResponseData:error方法.
HHSocketClient.socket:didReadData:
在接收到服務器返回的數據後, 先將心跳的計時重置, 然後追加data到self.readData中, 接下來調用getParsedResponseData從self.readData中獲取解析出的數據包(此數據包包括數據頭和數據體), 如果有解析到完整的數據包, 先判斷此次返回的數據包序列號是否在我們的dispatchTable中(即判斷是否是請求響應數據), 如果是請求響應數據那麼調用Task.completeWithResponseData:error, 否則的話就是服務器發過來的推送或者心跳, 做相應處理即可.
HHSocketClient.getParsedResponseData
這個方法根據數據拼裝規則獲取服務器響應數據頭中的msgLength, 根據這個msgLength截取出完整的數據包返回.
Task.completeWithResponseData:error
這個方法是請求的最終歸宿, 無論是Socket連接不可用還是服務器響應數據都會到這裡來處理, 具體的, 該方法先判斷任務狀態, 如果是未處理的任務(即任務未被取消), 通過HHSocketResponseFormatter解析數據包頭和數據包體, 解析出來的數據包頭包括一個responseCode, 這個字段表示我們發出的請求服務器是否能處理, 正常情況是200, 否則就是taskErrorWithResponeCode:中的錯誤碼, responseCode正確後我們再通過adler32判斷返回數據的完整性, 最後去執行Task.completeWithResult:error
Task.completeWithResult:error
此方法會根據入參執行Task初始化時的completionHander, completionHandler的執行呢會先經過HHSocketClient, HHSocketClient在這裡將task從派發表中移除, 然後才會執行實際傳入的handler(即調用方實際想要執行的代碼), 並在completionHandler執行完成後將其置nil, 破除循環引用. 另外這裡有個self.timer.invalidate會在下文介紹
總結一下整個請求過程中的數據流動:
發起請求: 調用方傳參->HHSocketRequest將參數序列化->HHSocketTask將request.data發給HHSocketClient->HHSocketClient通過HHSocket發起實際請求
收到請求響應: HHSocket收到數據回調HHSocketClient-> HHSocketClient判斷響應數據類型及完整性並將完整數據包傳給Task->Task根據數據包進行拆包解析出result和error執行completionHandler->competitionHandler使派發器移除Task引用->調用方收到請求的result和error
看過我上一篇文章的朋友應該會發現, 這個Socket派發器和HTTP的派發器不同.
第一是保持Task的時機提前了, 並不是在實際派發的dispatchTask:去保持, 而是一開始Task的建立就保持, 這是因為並不是所有的Task都一定會走dispatchTask:方法, 如果將保持的時機放到此處, 那麼那些不走dispatchTask:的方法就不會被保持, 響應數據返回時就會被誤認為已經處理過的請求, 這是不對的.
第二是通用錯誤的處理被放在了Task中, 而不是派發器裡, 這是因為HTTP派發器派發的系統的Task, 系統的Task.completionHandler直接返回的是(data,response,error), 也就是說數據在返回前的第一道處理是系統, 然後才是我們, 我們對他的處理結果不滿意, 才有了二次處理. 但Socket的派發器派發的Task的第一道處理者就是我們自己, 又因為Task本來就要對數據拆分解析, 自然就隨便把錯誤格式化咯. 另外最重要的一點是, 派發器本身不知道什麼是正確的Task返回什麼又是錯誤的返回, 它只負責Task的派發和取消, 如果不是因為懶, 甚至返回數據的完整性檢查也不該在這裡做, 所謂職責分離就是如此, 只做自己知道的事, 不做自己不應該知道的事情.
最後一點, 上面有些方法沒有在相應類的.h文件中聲明, 因為OC沒有友元函數這個概念, 所以像Task.setClient, HHSocket.resumeTask之類的給特定類使用的友元接口我都定義在相應的xxx+friend.h文件中了
2.任務的取消
自定義的任務和HTTP的任務不同, HTTP一次任務就是一個連接(當然在HTTP1.1和HTPP2中多個任務也能共用一個連接了), 任務的取消直接斷掉連接就是了. 但是我們的任務都是跑在在一個連接中的, 如果直接斷掉副作用太大. 所以我在HHSocketRequest添加了一個生成取消請求的接口, 如果某個API對應的操作很重要, 那麼在收到HHNetworkTaskErrorCanceled這個errorCode的時候這個API就需要生成相應的取消操作告訴服務器取消上次的操作, 反之, 如果這個API僅僅是好友列表, 用戶信息之類的展示接口那就什麼都不用做, 當響應數據返回並走到Task.completeWithResponseData:error:的方法時會直接忽略這個返回結果.
自定義任務的超時處理也很簡單(也就是ping-pong), 在任務發起後開啟一個timer, 規定時間內返回就取消timer, 否則timer到點後就自動取消任務並返回一個HHNetworkTaskErrorTimeOut錯誤碼.
3.多服務器的切換
這塊的處理和HTTP篇的處理一樣, 只是多了一個多次連接無果後也進行服務器切換.
四.合理的使用請求派發器
因為我們的Task初始化方法變了, 對應的APIConfiguration自然也就跟著變化一下, 除此之外像APIManager, TaskGroup, APIRecorder一切使用邏輯和規范都和HTTP篇一樣, 不做贅述.
本文中很多邏輯都在HTTP篇描述過, 如果有沒看過的朋友可以先看看HTTP篇再看本文, 會輕松很多. [HTTP篇鏈接]
總結
HHSocket: 負責網絡通信前的連接操作, 連接發生在子線程中保證UI的流程, 內部實現自動重連操作, 對外暴露切換服務器的接口.
HHSocketRequest: 網絡請求的描述類, 對外暴露生成網絡請求的接口, 內部管理本地請求和服務器請求的序列號, 生成的網絡請求會輸出一個格式化好的請求數據包.
HHDataFormatter: 數據序列化的實現類, 定義各個接口保證可讀性和拓展性.
HHSocketTask: 網絡任務的描述類, 內部負責請求狀態的管理, 請求結果的回調和格式化, 對外暴露任務的派發和取消接口.
HHSocketClient: 網絡請求的派發器, 這裡會記錄每一個服役中的任務, 並在必要的時候切換服務器.
寫在最後
TCP本身是個極大的話題, 上面洋洋灑灑寫了這麼多其實只說到了些基本皮毛. 作為客戶端的我們本就比服務端要輕松百倍, 又有Socket作為TCP的接口為我們提供了很方便的方式建立自己的TCP應用, 在此基礎上還有開源社區的強大工具提供支持, 小子我才有可能實現一套自己的TCP框架, 當然, 現在的框架還很簡易, 但聊勝於無, 也算是一次青春的嘗試. 總之, 我會繼續努力的(認真臉)!
能力一般, 水平有限, 希望此文可以幫到那些剛接觸iOS長連接編程無從下手的碼友們...
[本文附帶的demo地址]