iOS即時通訊進階 – CoacoaAsyncSocket原始碼解析

發表於2017-01-22
112702646-53f19ad8391e4a98
前言:

CoacoaAsyncSocket是谷歌的開發者,基於BSD-Socket寫的一個IM框架,它給Mac和iOS提供了易於使用的、強大的非同步套接字型檔,向上封裝出簡單易用OC介面。省去了我們面向Socket以及資料流Stream等繁瑣複雜的程式設計。
本文為一個系列,旨在讓大家瞭解CoacoaAsyncSocket是如何基於底層進行封裝、工作的。

注:文中涉及程式碼比較多,建議大家結合原始碼一起閱讀比較容易能加深理解。這裡有樓主標註好註釋的原始碼,有需要的可以作為參照:CoacoaAsyncSocket原始碼註釋

如果對該框架用法不熟悉的話,可以參考樓主之前這篇文章:iOS即時通訊,從入門到“放棄”?,或者自行查閱。

正文:
首先我們來看看框架的結構圖:
  • 122702646-d0a8cb442e961943

整個庫就這麼兩個類,一個基於TCP,一個基於UDP。其中基於TCP的GCDAsyncSocket,大概8000多行程式碼。而GCDAsyncUdpSocket稍微少一點,也有5000多行。
所以單純從程式碼量上來看,這個庫還是做了很多事的。

順便提一下,之前這個框架還有一個runloop版的,不過因為功能重疊和其它種種原因,後續版本便廢棄了,現在僅有GCD版本。

本系列我們將重點來講GCDAsyncSocket這個類。

我們先來看看這個類的屬性:

這個裡定義了一些屬性,可以先簡單看看註釋,這裡我們僅僅先暫時列出來,給大家混個眼熟。
在接下來的程式碼中,會大量穿插著這些屬性的使用。所以大家不用覺得困惑,具體作用,我們後面會一一講清楚的。

接著我們來看看本文方法一–初始化方法:

詳細的細節可以看看註釋,這裡初始化了一些屬性:

1.代理、以及代理queue的賦值。

2.本機socket的初始化:包括下面3種

其中值得一提的是第三種:UnixSocket,這個是用於Unix Domin Socket通訊用的。
那麼什麼是Unix Domain Socket呢?
原來它是在socket的框架上發展出一種IPC(程式間通訊)機制,雖然網路socket也可用於同一臺主機的程式間通訊(通過loopback地址127.0.0.1),但是UNIX Domain Socket用於IPC 更有效率 :

  • 不需要經過網路協議棧
  • 不需要打包拆包、計算校驗和、維護序號和應答等,只是將應用層資料從一個程式拷貝到另一個程式。這是因為,IPC機制本質上是可靠的通訊,而網路協議是為不可靠的通訊設計的。UNIX Domain Socket也提供面向流和麵向資料包兩種API介面,類似於TCP和UDP,但是面向訊息的UNIX Domain Socket也是可靠的,訊息既不會丟失也不會順序錯亂。

基本上它是當今應用於IPC最主流的方式。至於它到底和普通的socket通訊實現起來有什麼區別,彆著急,我們接著往下看。

3.生成了一個socketQueue,這個queue是序列的,接下來我們看程式碼就會知道它貫穿於這個類的所有地方。所有對socket以及一些內部資料的相關操作,都需要在這個序列queue中進行。這樣使得整個類沒有加一個鎖,就保證了整個類的執行緒安全。

4.建立了兩個讀寫佇列(本質陣列),接下來我們所有的讀寫任務,都會先追加在這個佇列最後,然後每次取出佇列中最前面的任務,進行處理。

5.建立了一個全域性的資料緩衝區:preBuffer,我們所操作的資料,大部分都是要先存入這個preBuffer中,然後再從preBuffer取出進行處理的。

6.初始化了一個交替延時變數:alternateAddressDelay,這個變數先簡單的理解下:就是進行另一個服務端地址請求的延時。後面我們一講到,大家就明白了。

初始化方法就到此為止了。

132702646-f4c2854b83972659

接著我們有socket了,我們如果是客戶端,就需要去connect伺服器。

又或者我們是服務端的話,就需要去bind埠,並且accept,等待客戶端的連線。(基本上也沒有用iOS來做服務端的吧…)

這裡我們先作為客戶端來看看connect
  • 142702646-13ebbf0af3a0f12b
    connect.png

其中和connect相關的方法就這麼多,我們一般這麼來連線到服務端:

也就是我們在截圖中選中的方法,那我們就從這個方法作為起點,開始講起吧。

本文方法二–connect總方法

這個方法非常長,它主要做了以下幾件事:

  • 首先我們需要說一下的是,整個類大量的會出現LogTrace()類似這樣的巨集,我們點進去發現它的本質只是一個{},什麼事都沒做。

    原來這些巨集是為了追蹤當前執行的流程用的,它被定義在一個大的#if #else中:

    而此時因為GCDAsyncSocketLoggingEnabled預設為0,所以僅僅是一個{}。當標記為1時,這些巨集就可以用來輸出我們當前的業務流程,極大的方便了我們的除錯過程。

  • 接著我們回到正題上,我們定義了一個Block,所有的連線操作都被包裹在這個Block中。我們做了如下判斷:

    保證這個連線操作一定是在我們的socketQueue中,而且還是以序列同步的形式去執行,規避了執行緒安全的問題。

  • 接著把Block中連線過程產生的錯誤進行賦值,並且把連線的結果返回出去

接著來看這個方法宣告的Block內部,也就是進行連線的真正主題操作,這個連線過程將會呼叫許多函式,一環扣一環,我會盡可能用最清晰、詳盡的語言來描述…

1.這個Block首先做了一些錯誤的判斷,並呼叫了一些錯誤生成的方法。類似:

2.接著做了一個前置的錯誤檢查:

這個檢查方法,如果沒通過返回NO。並且如果interface有值,則會將本機的IPV4 IPV6的 address設定上。即我們之前提到的這兩個屬性:

我們來看看這個前置檢查方法:

本文方法三–前置檢查方法

又是非常長的一個方法,但是這個方法還是非常好讀的。

  • 主要是對連線前的一個屬性引數的判斷,如果不齊全的話,則填充錯誤指標,並且返回NO。
  • 在這裡如果我們interface這個引數不為空話,我們會額外多執行一些操作。
    首先來講講這個引數是什麼,簡單來講,這個就是我們設定的本機IP+埠號。照理來說我們是不需要去設定這個引數的,預設的為localhost(127.0.0.1)本機地址。而埠號會在本機中取一個空閒可用的埠。
    而我們一旦設定了這個引數,就會強制本地IP和埠為我們指定的。其實這樣設定反而不好,其實大家也能想明白,這裡埠號如果我們寫死,萬一被其他程式給佔用了。那麼肯定是無法連線成功的。
    所以就有了我們做IM的時候,一般是不會去指定客戶端bind某一個埠。而是用系統自動去選擇。
  • 我們最後清空了當前讀寫queue中,所有的任務。

至於有interface,我們所做的額外操作是什麼呢,我們接下來看看這個方法:

本文方法四–本地地址繫結方法

這個方法中,主要是大量的socket相關的函式的呼叫,會顯得比較難讀一點,其實簡單來講就做了這麼一件事:
interface變成進行socket操作所需要的地址結構體,然後把地址結構體包裹在NSMutableData中。

這裡,為了讓大家能更容易理解,我把這個方法涉及到的socket相關函式以及巨集(按照呼叫順序)都列出來:

還有一些用到的作為引數的結構體:

這一段內容算是比較枯澀了,但是也是瞭解socket程式設計必經之路。

這裡提到了網路位元組序和主機位元組序。我們建立socket之前,必須把port和host這些引數轉化為網路位元組序。那麼為什麼要這麼做呢?

不同的CPU有不同的位元組序型別 這些位元組序是指整數在記憶體中儲存的順序 這個叫做主機序
最常見的有兩種
1. Little endian:將低序位元組儲存在起始地址
2. Big endian:將高序位元組儲存在起始地址

這樣如果我們到網路中,就無法得知互相的位元組序是什麼了,所以我們就必須統一一套排序,這樣網路位元組序就有它存在的必要了。

網路位元組順序是TCP/IP中規定好的一種資料表示格式,它與具體的CPU型別、作業系統等無關。從而可以保證資料在不同主機之間傳輸時能夠被正確解釋。網路位元組順序採用big endian排序方式。

大家感興趣可以到這篇文章中去看看:網路位元組序與主機位元組序

除此之外比較重要的就是這幾個地址結構體了。它定義了我們當前socket的地址資訊。包括IP、Port、長度、協議族等等。當然socket中標識為地址的結構體不止這3種,等我們後續程式碼來補充。

大家瞭解了我們上述說的知識點,這個方法也就不難度了。這個方法主要是做了本機IPV4IPV6地址的建立和繫結。當然這裡分了幾種情況:

  1. interface為空的,我們作為客戶端不會出現這種情況。注意之前我們是這個引數不為空才會調入這個方法的。
    而這個一般是用於做服務端監聽用的,這裡的處理是給本機地址繫結0地址(任意地址)。那麼這裡這麼做作用是什麼呢?引用一個應用場景來說明:

    如果你的伺服器有多個網路卡(每個網路卡上有不同的IP地址),而你的服務(不管是在udp埠上偵聽,還是在tcp埠上偵聽),出於某種原因:可能是你的伺服器作業系統可能隨時增減IP地址,也有可能是為了省去確定伺服器上有什麼網路埠(網路卡)的麻煩 —— 可以要在呼叫bind()的時候,告訴作業系統:“我需要在 yyyy 埠上偵聽,所有傳送到伺服器的這個埠,不管是哪個網路卡/哪個IP地址接收到的資料,都是我處理的。”這時候,伺服器程式則在0.0.0.0這個地址上進行偵聽。

  2. 如果interfacelocalhost或者loopback則把IP設定為127.0.0.1,這裡localhost我們大家都知道。那麼什麼是loopback呢?
    loopback地址叫做迴環地址,他不是一個物理介面上的地址,他是一個虛擬的一個地址,只要路由器在工作,這個地址就存在.它是路由器的唯一標識。
    更詳細的內容可以看看百科:loopback
  3. 如果是一個其他的地址,我們會去使用getifaddrs()函式得到本機地址。然後去對比本機名或者本機IP。有一個能相同,我們就認為該地址有效,就進行IPV4和IPV6繫結。否則什麼都不做。

至此這個本機地址繫結我們就做完了,我們前面也說過,一般我們作為客戶端,是不需要做這一步的。如果我們不繫結,系統會自己繫結本機IP,並且選擇一個空閒可用的埠。所以這個方法是iOS用來作為服務端呼叫的。

方法三–前置檢查、方法四–本機地址繫結都說完了,我們繼續接著之前的方法二往下看:

之前講到第3點了:
3.這裡把flag標記為kSocketStarted:

原始碼中大量的運用了3個位運算子:分別是或(|)、與(&)、取反(~)、運算子。 運用這個標記的好處也很明顯,可以很簡單的標記當前的狀態,並且因為flags所指向的列舉值是用左位移的方式:

所以flags可以通過|的方式複合橫跨多個狀態,並且運算也非常輕量級,好處很多,所有的狀態標記的意義可以在註釋中清晰的看出,這裡把狀態標記為socket已經開始連線了。

4.然後我們呼叫了一個全域性queue,非同步的呼叫連線,這裡又做了兩件事:

  • 第一步是拿到我們需要連線的服務端server的地址陣列:
  • 第二步是做一些錯誤判斷,並且把地址資訊賦值到address4address6中去,然後非同步呼叫回socketQueue去用另一個方法去發起連線:

    在這個方法中我們可以看到作者這裡把建立server地址這些費時的邏輯操作放在了非同步執行緒中併發進行。然後得到資料之後又回到了我們的socketQueue發起下一步的連線。

然後這裡又是兩個很大塊的分支,首先我們來看看server地址的獲取:

本文方法五–建立服務端server地址資料:

這個方法根據host進行了劃分:

  1. 如果hostlocalhost或者loopback,則按照我們之前繫結本機地址那一套生成地址的方式,去生成IPV4和IPV6的地址,並且用NSData包裹住這個地址結構體,裝在NSMutableArray中。
  2. 不是本機地址,那麼我們就需要根據host和port去建立地址了,這裡用到的是這麼一個函式:

    這個函式主要的作用是:根據hostname(IP)service(port),去獲取地址資訊,並且把地址資訊傳遞到result中。
    而hints這個引數可以是一個空指標,也可以是一個指向某個addrinfo結構體的指標,如果填了,其實它就是一個配置引數,返回的地址資訊會和這個配置引數的內容有關,如下例:

    舉例來說:指定的服務既可支援TCP也可支援UDP,所以呼叫者可以把hints結構中的ai_socktype成員設定成SOCK_DGRAM使得返回的僅僅是適用於資料包套介面的資訊。

    這裡我們可以看到result和hints這兩個引數指標指向的都是一個addrinfo的結構體,這是我們繼上面以來看到的第4種地址結構體了。它的定義如下:

    我們可以看到它其中包括了一個IPV4的結構體地址ai_addr,還有一個指向下一個同型別資料節點的指標ai_next
    其他引數和之前的地址結構體一些引數作用類似,大家可以對著註釋很好理解,或者仍有疑惑可以看看這篇:
    socket程式設計之addrinfo結構體與getaddrinfo函式
    這裡講講ai_next這個指標,因為我們是去獲取server端的地址,所以很可能有不止一個地址,比如IPV4、IPV6,又或者我們之前所說的一個伺服器有多個網路卡,這時候可能就會有多個地址。這些地址就會用ai_next指標串聯起來,形成一個單連結串列。

    然後我們拿到這個地址連結串列,去遍歷它,對應取出IPV4、IPV6的地址,封裝成NSData並裝到陣列中去。

  3. 如果中間有錯誤,賦值錯誤,返回地址陣列,理清楚這幾個結構體與函式,這個方法還是相當容易讀的,具體的細節可以看看註釋。

接著我們回到本文方法二,就要用這個地址陣列去做連線了。

這裡呼叫了我們本文方法六–開始連線的方法1

這個方法也比較簡單,基本上就是做了一些錯誤的判斷。比如:

  1. 判斷在不在這個socket佇列。
  2. 判斷傳過來的aStateIndex和屬性stateIndex是不是同一個值。說到這個值,不得不提的是大神用的框架,在容錯處理上,做的真不是一般的嚴謹。從這個stateIndex上就能略見一二。
    這個aStateIndex是我們之前呼叫方法,用屬性傳過來的,所以按道理說,是肯定一樣的。但是就怕在呼叫過程中,這個值發生了改變,這時候整個socket配置也就完全不一樣了,有可能我們已經置空地址、銷燬socket、斷開連線等等…等我們後面再來看這個屬性stateIndex在什麼地方會發生改變。
  3. 判斷config中是需要哪種配置,它的引數對應了一個列舉:

    前3個大家很好理解,無非就是用IPV4還是IPV6。
    而第4個官方註釋意思是,我們即使關閉讀的流,也會保持Socket開啟。至於具體是什麼意思,我們先不在這裡討論,等後文再說。
這裡呼叫了我們本文方法七–開始連線的方法2

這個方法也僅僅是連線中過渡的一個方法,做的事也非常簡單:

  1. 就是拿到IPV4和IPV6地址,先去建立對應的socket,注意這個socket是本機客戶端的,和server端沒有關係。這裡服務端的IPV4和IPV6地址僅僅是用來判斷是否需要去建立對應的本機Socket。這裡去建立socket會帶上我們之前生成的本地地址資訊connectInterface4或者connectInterface6
  2. 根據我們的config配置,得到主選連線和備選連線。 然後先去連線主選連線地址,在用我們一開始初始化中設定的屬性alternateAddressDelay,就是這個備選連線延時的屬性,去延時連線備選地址(當然如果主選地址在此時已經連線成功,會再次連線導致socket錯誤,並且關閉)。

這兩步分別呼叫了各自的方法去實現,接下來我們先來看建立本機Socket的方法:

本文方法八–建立Socket:

這個方法做了這麼幾件事:

  1. 建立了一個socket:

    其實這個函式在之前那篇IM文章中也講過了,大家參考參考註釋看看就可以了,這裡如果返回值為-1,說明建立失敗。
  2. 去繫結我們之前建立的本地地址,它呼叫了另外一個方法來實現。
  3. 最後我們呼叫瞭如下函式:

    那麼這個函式是做什麼用的呢?簡單來說,它就是給我們的socket加一些額外的設定項,來配置socket的一些行為。它還有許多的用法,具體可以參考這篇文章:setsockopt函式

    而這裡的目的是為了來避免網路錯誤而出現的程式退出的情況,呼叫了這行函式,網路錯誤後,系統不再傳送程式退出的訊號。
    關於這個程式退出的錯誤可以參考這篇文章:Mac OSX下SO_NOSIGPIPE的怪異表現

未完總結:

connect篇還沒有完結,奈何篇幅問題,只能斷在這裡。下一個方法將是socket本地繫結的方法。再下面就是我們最終的連線方法了,歷經九九八十一難,馬上就要取到真經了…(然而這僅僅是一個開始…)
下一篇將會承接這一篇的內容繼續講,包括最終連線、連線完成後的source和流的處理。
我們還會去講講iOS作為服務端的accpet建立連線的流程。
除此之外還有 unix domin socket(程式間通訊)的連線。

最近總感覺很浮躁,貼一句一直都很喜歡的話:
上善若水。水善利萬物而不爭

  • 152702646-66af41f3d8977986

相關文章