1. 程式人生 > 實用技巧 >類 QQ IM 通訊軟體開發實戰

類 QQ IM 通訊軟體開發實戰

課程簡介

用習慣了微信的你,還記得當初的 QQ 嗎?曾幾何時,你是否也在夢想自己也能寫出一個像 QQ 一樣牛氣的即時通訊軟體?即使你不曾有過這個“野心”,你肯定也對 QQ 的實現原理感到好奇過,對吧?本達人課即將帶您一探 QQ 此類 IM 軟體背後的諸多實現細節。

此達人課涵蓋了網路程式設計、設計模式、通訊協議等基礎知識,基於套接字(Socket)技術,實現了一個基於控制檯的即時通訊軟體(IM)。能夠進行文字聊天、檔案傳送、傳送表情等。支援伺服器併發、內網穿透;當內網穿透失敗時,允許伺服器轉發訊息。

通過實現這樣一個簡單的 IM 軟體,幫助讀者消除 Socket 程式設計過程中的誤區和困惑,更加深入的理解 TCP/IP 協議原理。另外,在現在這個年頭,不把“高併發”掛在嘴上,都不好意思開口說話。高併發確實有著一定的門檻,但也並不是高不可攀,只是需要我們付出努力去學習、去實踐,要知道,經驗非常重要。我們的這個 IM 軟體涉及到內網穿透(NAT 穿透、“打洞”)、伺服器併發、心跳包檢測等,這些技術對於網路應用都十分重要,想要深入網路程式設計的同學千萬不能錯過。

本達人課共包含以下四部分:

第一部分(第01課),作為開篇,對本專案做了一個整體的介紹,並對 IM 開發需要用到的知識進行概述;

第二部分(第02課),從基本原理層面,詳細闡釋了開發一個即時通訊軟體需要理解和掌握的必備技能;

第三部分(第03-07課),從程式碼層面,給出了本專案主要部分的具體程式實現,便於讀者較好的瞭解細節;

第四部分(第08課),作為總結,闡釋了網路程式設計過程中常踩到的“坑”,希望能幫助讀者在後續的 Socket 開發生涯中,少走一些彎路。

主要涵蓋的技術點有:

  • Socket 程式設計
  • 服務端併發
  • 同步/非同步、阻塞/非阻塞等 I/O 模型
  • 內網穿透及 P2P 通訊
  • 心跳包檢測機制
  • 應用層通訊協議設計
  • TCP/IP 協議棧原理

作者介紹

汪磊,自由開發者,CSDN 部落格作者,畢業於211,九年老司機。錯上賊船已悟道,遂深耕於後端,前端略懂皮毛。豐富的專案經驗,用程式碼詮釋世界。

課程內容

導讀:功能概覽

引言

用習慣了微信的你,還記得當初的 QQ 嗎?曾幾何時,你是否也在夢想自己也能寫出一個像 QQ 一樣牛掰的即時通訊軟體?即使你不曾有過這個“野心”,你肯定也對 QQ 的實現原理感到好奇過,對吧?有人可能會說,“我從來沒有好奇過”,好吧,我承認,你的這個回答只能說明兩種可能,你是大神,或者你根本不是程式設計師!

記得當初我還是一個“懵懂少年”的時候,用 .NET 的 Remoting 技術寫了一個及其醜陋的小聊天工具,知其然不知其所以然,踩了無數的坑,到最後不了了之。現在回想起來,總結為一句話,“基礎不牢、地動山搖”。那時候,我對 TCP/IP、Socket 等一竅不通,正所謂“初生牛犢不怕虎”。

後來,一個偶然的機會,我接觸到了《HTTP 權威指南》一書,進而找到《TCP/IP 詳解卷》這本“聖經”級讀物,從而一發不可收拾,開始了對網路底層原理的探究歷程。如今,已是而立之年,歲月洗去了身上的浮躁,懂得靜下心來好好沉澱一下自己的知識體系。回首當初自己一個又一個的“作品”,儘管散發著青澀,卻記載著我的青春。

好了,瞎聊了這麼多,我們言歸正傳吧。

在網路極其發達的今天,無論是 PC 端軟體,還是移動端 App,幾乎都有聯網功能。移動端諸如微信、支付寶、美團、京東及各種手遊,PC 端諸如各種關係資料庫(MySQL、MSSQL、Oracle)、快取記憶體(Redis、Memcached)、網站及 Web 瀏覽器、QQ 及各種網遊,都以網路通訊為基礎。甚至 Windows 下的遠端桌面、網路鄰居、共享資料夾以及使用 SSH 登入 Linux,本質上也是通過 Socket 進行的,只不過設計了各自的通訊協議而已,有興趣的朋友可以通過 Wireshark 等工具親自進行抓包,看看其互動過程。再比如,就是我們平常上網用到的 Web 瀏覽器(比如 IE、360、搜狗等),只不過是利用 Socket 同 Web 伺服器通過 HTTP 協議進行了一種“請求/響應”操作,瀏覽器向伺服器發出對某個 URL 的請求,然後伺服器發回 HTML 形式的響應,瀏覽器再對 HTML 進行解析渲染。其實仔細想想,上面列舉的這些司空見慣的軟體,說到底不就是一些 Socket 操作嗎?

然而,Socket 看似簡單,但真正想把它用好卻不簡單,Socket 程式設計是出了名的“坑”多,相信有過 Socket 程式設計經歷的朋友都有此感受。Socket 究竟是什麼呢?說白了,它只不過是作業系統給開發人員提供的一個進行網路操作的介面,通過 Socket,我們可以和作業系統核心中的 TCP/IP 協議棧進行互動,從而實現網路資訊的收發。這就涉及到 TCP/IP 協議族,這可是一個極度複雜的知識汪洋,值得你深入研究。

本課以 C# 為語言平臺,闡述瞭如何實現一個基於控制檯的即時通訊軟體,也就是常說的 IM。透過即時通訊工具的表象,探究其背後的網路通訊基本原理,澄清關於 Socket 操作的一些細節和常見誤區,讓讀者對 TCP/IP 協議棧的實現原理及其應用有更為深刻的理解。

另外,現在這個年頭,不把“高併發”掛載嘴上,都不好意思開口說話。高併發確實有著一定的門檻,但也並不是高不可攀,只是需要我們付出努力去學習、去實踐,要知道,經驗非常重要。我們的這個 IM 軟體涉及到內網穿透(NAT 穿透、“打洞”)、伺服器併發、心跳包檢測等,這些技術對於網路應用都是十分重要的,想要深入網路程式設計的同學千萬不能錯過。

功能概覽

會當凌絕頂,一覽眾山小

為了專注於業務功能的實現,避免 UI 邏輯分散我們的注意力,我們的這款 IM 軟體採用 Windows 控制檯的形式。專案總體上包括伺服器和客戶端兩個相互獨立的部分,是一個典型的 C/S 結構。見下面的圖1和圖2所示。

怎麼樣,黑色背景配上綠色字型,很有科技感吧?有沒有黑客帝國的感覺?呵呵!基於控制檯實現的聊天程式在使用者體驗方面和視窗程式比起來顯得比較 Low,不過基本原理是一樣的,你完全可以寫成 WinForm 形式。

圖1 IM 伺服器

圖2 IM客戶端

伺服器端

伺服器作為各個客戶端進行通訊的樞紐及中介,主要作用包括:處理使用者登入註冊及退出等請求、維護使用者資訊、好友上線和下線通知、檢測使用者線上狀態、輔助內網穿透以實現 P2P 通訊、內網穿透失敗情況下的訊息中轉、分發表情包等。

伺服器端啟動以後,會監聽來自各個客戶端的連線請求(登入、註冊、登出、請求好友資訊、內網穿透協助等),並根據請求型別分別返回合適的響應(見圖1)。當有使用者上線或下線時,伺服器端會監聽到該動作,並通知該使用者的所有好友,以更新相應客戶端的好友列表。

伺服器的一個重要功能是,檢測使用者是否線上。有人會說了,這還不簡單,客戶端下線時向伺服器傳送一條訊息,通知伺服器“我要下線了”。沒錯,在客戶端正常退出的情況下,這種方法行之有效,但如果客戶端的下線是由於電腦宕機、斷網等突發事故造成的呢?客戶端還來不及向伺服器傳送下線通知,就已經 Game Over 了。所以,伺服器要採取合理策略,以應對客戶端異常的連線中斷。

客戶端

對於一個 IM 軟體來說,客戶端是普通使用者接觸最多的,其核心作用當然就是好友之間的聊天了,當然還包括一些輔助功能,如:使用者的註冊登入及退出、新增好友、檢視好友列表、傳送檔案等。

在我們的 IM 中,雙方只有互為好友才能聊天。客戶端 A 可以向伺服器 S 發出新增某個好友 B 的請求,伺服器負責把該請求轉達給好友 B,好友 B 同意後,二者即建立起好友關係。已登入的客戶端可以從伺服器獲取自己的好友列表,以及哪些好友線上、哪些不線上。

出於簡單考慮,本系統目前只支援文字形式的聊天會話。至於語音聊天、視訊聊天,基本原理是一樣的,有興趣的朋友可以自己加以實現。我們還實現了傳送表情的功能,當然,這裡的表情指的是字元圖案,而不是大家平時用 QQ、微信之類的視覺化表情,畢竟是控制檯程式嘛,要求不要太高!此外,還實現了表情包線上更新功能,當客戶端連線到伺服器時,伺服器會自動向客戶端推送最新的表情包,之後客戶端便可以使用最新的表情了。使用者可以查閱自己和其他好友的聊天記錄,至於聊天記錄是儲存在客戶端本地,還是儲存在伺服器,出於不同的考量,會有不同的策略。客戶端之間可以以二進位制形式互相傳輸檔案,並且提供了雜湊校驗機制,以檢查檔案傳輸過程中的是否發生錯誤。

提到 P2P,相信大家都不陌生吧?但究竟什麼是 P2P 呢?

P2P,即“點對點”,英文是“Peer to Peer”,意思是兩個節點之間直接通訊,不需要第三方充當中介進行中轉。在一個 IM 系統中,使用者之間的聊天資訊有兩種方式進行傳遞,一種是使用者 A 把資訊傳送給伺服器 S,伺服器 S 再把該資訊轉發給使用者 B(見圖3);另一種就是我們這裡所說的 P2P 方式,即使用者 A 把資訊直接傳送給使用者 B,而不用經過伺服器 S(見圖4)。

圖3 伺服器中轉

圖4 P2P

P2P 的優勢顯而易見,少一道工序、少一個步驟,效率必然比伺服器中轉要高。然而,由於 NAT 裝置的存在,好多終端都沒有合法的公網 IP,和這樣的終端進行通訊就需要“內網穿透”(就是指常說的“打洞”)。但是 NAT 技術尚未標準化,各種 NAT 裝置的實現策略也沒有統一,內網穿透不保證一定會成功,所以當內網穿透失敗時,P2P 就不能實現了,還時需要伺服器對訊息進行中轉。

下面解釋一下剛才提到的 NAT 技術。我們知道,當前32位的 IPv4 地址幾乎已經耗盡,不可能給所有終端網際網路使用者都分配一個公網 IP,而網際網路使用者的數量又在不斷增加,怎麼辦?於是就出現了所謂的 NAT 技術,簡單來說,就是用一個 NAT 裝置把一個公網 IP 提供給多個終端使用,使得多個電腦可以共用一個公網 IP 地址來上網。

NAT 裝置一般都有一個特點,就是對外隱藏內網各個終端的真實 IP,內網的機器可以主動向外網傳送資訊,但外網不能主動向內網傳送資訊。這就給我們上面提到的 P2P 通訊造成了很大的困難,因為我們不知道通訊雙方各自的真實 IP 地址;即使知道對方的 IP,也不能主動向對方發起通訊,因為對方的 NAT 裝置會拒絕。要想和 NAT 內的終端進行通訊,就要想辦法穿透 NAT 裝置的壁壘,這就是所謂的內網穿透。

剛才提到了“伺服器中轉”這個概念,我們知道,伺服器中轉給通訊引入了一道額外的步驟,本來雙方之間可以通訊的,非要另找一個人來傳話,不但有可能傳錯話,當客戶端較多時,伺服器這個中間人的工作量就會很重,容易成為效能瓶頸。當然了,內網穿透失敗,或者出於監視使用者間通訊的需要,仍然要用伺服器來中轉。

第01課:Socket 通訊基本原理

任何網路應用的實現都離不開 Socket 程式設計,當然,你也可以使用更高層次的抽象與封裝,諸如 TcpListener、TcpClient、UdpClient,以及更加抽象的 WCF、WebService、Remoting 等技術。然而,要想更為深刻的理解網路通訊的底層原理,最終還是繞不開套接字(Socket)。

只要對 Socket 程式設計稍有了解,就會知道諸如 Bind、Listen、Accept、Connect、Send、Receive 之類的操作,確實,所有的網路應用就是這些基本操作的合理使用。對於 TCP 而言,由於它是面向連線的協議,一般就需要一方充當伺服器的角色進行監聽、另一方充當客戶端發起到伺服器的主動連線。使用套接字進行 TCP 程式設計的一般用法如下。

伺服器端程式碼:

Socket tcpListenSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);var tcpLocalEnd = new IPEndPoint(IPAddress.Any, listenPort);tcpListenSock.Bind(tcpLocalEnd);tcpListenSock.Listen(10);while(true){var workerSock = tcpListenSock.Accept();byte[] buf = new byte[1000];workerSock.Receive(buf);}

程式碼段1

客戶端程式碼:

Socket serverSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);serverSock.Connect(serverEndPoint);byte[] buf = new byte[1000];serverSock.Send(buf);

程式碼段2

上面程式碼的大致流程是:伺服器監聽連線、當有客戶端連線時,伺服器接收該連線,並開始接收客戶端傳送過來的資料並對其進行處理;客戶端向伺服器發起連線請求,連線成功後,向伺服器傳送資料。

網上好多關於 Socket 程式設計的教程大都一上來就介紹上面的這種操作,導致好多初學者在頭腦裡認為 Socket 程式設計就應該是這樣的。甚至很多初學者會以為伺服器只能接收資料、客戶端只能傳送資料,客戶端要想接收伺服器傳送的資料,先要在客戶端的某個埠上監聽來自伺服器的連線(見圖1)。他們只知道TCP是全雙工的協議,卻僅僅停留在知道這個概念而已,和實際應用聯絡不起來。

圖1 錯誤的思路

程式碼段1和程式碼段2只是說明了最最基本的 Socket 程式設計方式,用術語來說就是“互動式同步阻塞 I/O”。在這種方式中,伺服器監聽本地埠,當沒有連線請求時,使用者程序會阻塞在 Accept 函式,直到有客戶端請求連線;另外,當有某個客戶端連線傳入後,伺服器使用者程序就會忙碌於接收客戶端資料的工作,如果此時有新的客戶端連線過來,伺服器就不能進行響應。這種方式之所以稱為“互動式”,就是因為類似於“客戶端問一句、伺服器答一句”的形式。

當然了,通過百度還可以找到如下程式碼示例:

while (true){    var workerSock = tcpListenSock.Accept();    var remoteEnd = workerSock.RemoteEndPoint as IPEndPoint;    ThreadPool.QueueUserWorkItem(obj =>    {       while (true)       {          byte[] buf = new byte[1000];          int r= workerSock.Receive(buf);          if (r<=)             break;       }    });}

這種方式比剛才那種“互動式同步阻塞 I/O”要好一些,藉助於執行緒池技術,在一些併發不高的簡單場合完全可以適用。該方案用一個單獨的執行緒來處理已經建立的連線,使得主執行緒能夠繼續監聽其他客戶端請求。但這種方式要求為每一個客戶端連線都開闢一個單獨的執行緒,在少量客戶端連入的時候沒有什麼問題,但如果有成千上萬的併發請求傳入時,系統就要分配成千上萬的執行緒來應對每一個連線,很顯然,這種方案不能應對高併發。在生產環境中,應對高併發絕不是隻用一臺伺服器來實現的,通常是一個伺服器叢集,採用負載均衡技術來給各個伺服器分配任務。對於每一臺伺服器,還要採用諸如非阻塞、多路複用甚至非同步 I/O 等模式,這都是較為高階的網路程式設計技術,需要在實際工作中積累經驗。

面向連線的 TCP

由於伺服器需要儲存客戶端登入、會話以及活動的各種狀態,客戶端和伺服器之間的通訊採用面向連線的 TCP 協議。另外,不像傳統的 HTTP 伺服器和瀏覽器之間採用短連線(現在的 HTTP 協議預設使用長連線),我們在這裡連線採用長連線,也就是說,一旦客戶端和伺服器建立 TCP 連線後,不會自動斷開連線,而是一直使用該連線傳輸資料,直到客戶端主動斷開連線為止。

使用者註冊、登入與登出

在伺服器已經執行的前提下,客戶端啟動後,會主動向伺服器發起 TCP 連線請求,伺服器一旦接受連線請求,二者之間就成功建立起一條 TCP 連線。客戶端使用該連線向伺服器傳送註冊、登入與登出的請求報文,伺服器同樣用這條連線向客戶端發回相應的響應報文。

伺服器監測客戶端線上狀態(心跳包)

一種常用的策略是伺服器和客戶端之間維持一個“心跳包”通訊,顧名思義,“心跳包”就是以某一頻率在伺服器和客戶端之間傳送的微型報文。就像心跳一樣,有心跳就說明客戶端和伺服器之間的連線還存在,沒有心跳就說明二者之間的連線 Over 了。這樣,即使客戶端由於停電、宕機等突發狀況,來不及向伺服器報告下線通知,伺服器也能夠檢測到該客戶端已經不線上了。

伺服器分發使用者好友地址

某個使用者成功登入後,應該能夠獲取該使用者的好友列表,並且能夠給某個好友傳送訊息,這是 IM 應該具有的基本功能。上文中提到,使用者 A 向好友 B 傳送訊息,既可以通過伺服器進行中轉,也可以用 P2P 方式進行直接通訊。無論是哪種方式,都要知道 B 的 IP 地址,那麼 B 的 IP 地址從哪裡獲得呢?我們知道,當用戶 B 登入伺服器時,會與伺服器建立一條 TCP 連線,此時伺服器肯定知道 B 的 IP。所以,伺服器需要在 B 登入時,儲存好使用者 B 的 IP 地址,並向 B 的所有好友(包括 A)分發 B 的 IP 地址資訊。

內網穿透失敗時轉發使用者之間的通訊

雖然 P2P 通訊效率較高且不會給伺服器造成太大壓力,但存在通訊失敗的可能。大家想一下我們家裡上網用到的寬頻路由器,它其實就是一個交換機和帶有 NAT 功能的路由器的集合體(見圖2),它負責把我們家裡各個終端裝置的內網IP轉換成公網IP。要想實現P2P就要穿透這些NAT裝置,就是所謂的“打洞”。雖然說用“打洞”技術可能實現內網穿透,但NAT技術還沒有標準化,不同的NAT裝置各自的具體實現機制不一樣,不能保證所有的內網都能被穿透。這種情況下,就需要藉助伺服器來轉發使用者之間的訊息。

圖2 家用寬頻路由器示意

傳送檔案

實現檔案傳送,既可以使用 TCP 協議,也可以使用 UDP 協議。有的人更偏好於 UDP,認為 UDP 協議簡單輕量、網路負載低,就連 QQ 也是採用 UDP。不可否認,UDP 以“盡最大努力傳輸”為宗旨,沒有 TCP 那樣複雜的機制。但我們也要意識到 UDP 是不可靠的,要想實現可靠的端到端傳輸,需要應用層協議來實現諸如超時重傳、流量及擁塞控制等機制,而 TCP 恰恰具備這些功能。也許有人會說,我自己實現重傳、流控等機制不就行了麼?你當然可以自己做這些,但這些功能是相當複雜的,需要你有十分豐富的網路程式設計及協議開發經驗,而且你自己寫的不一定有 TCP 高效。另外,TCP 不像你想象的那樣重量級,除非你需要實現廣播,或者對實時性有較高要求(線上播放音視訊),否則完全可以放心的使用 TCP。

無連線的 UDP

剛才說到 TCP 和 UDP 之間的抉擇問題,確實需要因地制宜。TCP 的優勢是穩定可靠,UDP 的優勢是無連線、輕量級。IM 好友之間的普通文字聊天不需要建立持久的連線,因為一個使用者在傳送一條訊息後,不知道下一條訊息會在什麼時候傳送,所以沒有必要用一條連線來為這種通訊服務。此時,就可以採用無連線的 UDP,一個使用者想說話時就傳送一條 UDP 報文,不用關心對方什麼時候回覆,甚至即使一條兩條訊息丟失也不是什麼大問題。當然了,如果你是完美主義者,你也可以在應用層加上一些簡單的丟失重傳機制。另外,由於 UDP 無連線的特性,它在實現內網穿透方面要比 TCP 方便一些。

P2P 聊天

在伺服器的幫助下,使用者 A 可以得到好友 B 的 IP 地址,從而可以用 UDP 直接向好友 B 傳送聊天報文,好友 B 在本地指定的 UDP 埠上接收相應的報文即可。當然,實現 P2P 通訊的前提是通訊雙方都有合法的網際網路 IP 地址,倘若一方或雙方位於 NAT 裝置的內網,用普通的方法就不能實現通訊了,因為雙方不知道對方的公網 IP 地址。此時,就需要內網穿透了。

內網(NAT)穿透

在前面多次提到“內網穿透”,俗稱“打洞”,這個概念聽起來是不是顯得非常“高大上”?其實,所謂的“NAT 穿透”、“內網穿透”、“打洞”都指的是一個概念,只是叫法不同而已。我們都知道,內網穿透的目的是,使得位於內網的兩個終端能夠直接進行通訊,避免伺服器作為第三方中轉。那麼內網穿透該怎麼實現呢?

其實內網穿透的基本原理並不複雜,前提是想辦法得到 P2P 雙方的公網 IP 地址,關鍵是找出內網終端經過 NAT 轉換後的通訊埠。這裡我們主要介紹的是 UDP 穿透,圖3中的 A 和 B 是兩個位於各自內網中的電腦終端,NAT_ANAT_B分別是 A 和 B 的閘道器,各自的 IP 地址及埠都已經標明。

圖7 UDP 穿透示意圖

獲取通訊雙方的公網 IP 並不困難。我們知道,網路上的兩臺電腦要想相互通訊,就必須要知道對方的 IP 地址及其埠號。由於電腦 A 和 B 都分別位於各自的內網中,它們都不具有合法的公網 IP 地址,所以二者不能直接通訊。但是,A 可以和NAT_B的外網介面通訊,B 也可以和NAT_A的外網介面通訊,而NAT_ANAT_B外網介面的 IP 地址就是 A 和 B 經過 NAT 轉換後的外網IP。也就是說,A、B 要想通訊,先要獲取對方的外網 IP 地址,具體方法是:A 和 B 都和伺服器建立 TCP 連線,這樣伺服器就知道 A 和 B 各自的公網 IP,然後伺服器把各自的公網 IP 通過 TCP 連線告訴對方即可。

難在如何獲取 NAT 後的外網埠。要想弄明白內網穿透的實現細節,就要搞明白 NAT 裝置如何把內網地址轉換成公網地址。前面我們多次提到,NAT 技術還沒有標準化,也就是說,不同廠家的 NAT 裝置,內外網地址轉換的實現方法也不一樣。在有的 NAT 裝置實現中,只要內網終端的 IP 和埠不變,不管訪問公網的哪臺伺服器,轉換成的外網埠也保持不變;而對於有的 NAT 裝置,即使是相同的內網 IP 和埠,只要訪問的外網伺服器不同,轉換成的外網埠也不同。有的 NAT 裝置允許資料包從外網自由的進入到內網,而大多數 NAT 裝置不允許不請自來的外部資料包進入。所以說,實現內網穿透的關鍵是找到內網主機被 NAT 裝置所對映成的外網埠號。

正因為不同的 NAT 裝置轉換的外網埠不一定能得到,所以內網穿透不一定能成功。大家回想一下在使用 QQ 聊天的時候,有沒有遇到過系統提示“伺服器中轉”?這就是由於內網穿透失敗,QQ 伺服器把聊天內容進行了中轉。

最容易實現穿透的是同一內網 IP 和埠被 NAT 轉換後的外網埠保持不變的 NAT 裝置。如圖3所示,A 通過NAT_A向伺服器傳送報文,經 NAT 轉換後的埠號是6001;A 通過NAT_ANAT_B傳送報文,經 NAT 轉換後的埠號也是6001。這種情況下,在通過伺服器得知對方的公網 IP 和埠以後,A 向 B 傳送一條報文的步驟如下:

  1. A 先告訴伺服器,“我要給 B 傳送一條報文”;
  2. 伺服器給 B 發一個命令,讓 B 向 A 的公網埠6001傳送一條報文Dr_BA
  3. 報文Dr_BA發出後,B 通知伺服器,“我已經向 A 發報文了”,然後伺服器把該訊息轉告給 A;
  4. A 向 B 的公網埠8001傳送一條報文Dr_AB

我們知道,NAT 裝置不允許不請自來的外部報文進入,於是報文Dr_BA會被NAT_A丟棄;但是報文Dr_BA會在NAT_B上留下一個對映記錄,就相當於在NAT_B裝置上打了一個“洞”,以後由外部發送到埠8001的報文就能夠通過這個“洞”進入NAT_B內部網路,這樣 A 到 B 的通訊就成功了。

第02課:程式骨架之服務端

我們這款 IM 包括伺服器和客戶端兩部分,其中,伺服器負責各個客戶端之間的聯絡,以及伺服器和客戶端之間的互動;客戶端就是我們終端使用者接觸到的聊天軟體。

任何複雜的軟體系統也不是一下子就憑空拔地而起的,總是由一些核心程式碼慢慢擴充而來,聊天軟體的核心程式碼很簡單,無非是伺服器監聽、客戶端連線,以及客戶端之間的通訊而已。

上文講基本原理的時候,列舉了兩段程式碼(程式碼段1和2),這兩段程式碼其實就構成了伺服器和客戶端之間通訊的核心程式碼。我們在這裡使用的是最基礎的 Windows 套接字(Socket),雖然用起來比 TCPListener、TCPClient 之類的要麻煩一些,但能夠使我們更清晰的瞭解網路程式設計的基本原理,以及獲得更高的靈活度。

Socket,直接翻譯過來是“插座”的意思,術語俗稱“套接字”。好多人對 Socket 到底是什麼並沒有一個清晰的概念,只知道它是用來操作網路通訊的一個類。其實“插座”這個叫法還是比較形象的,它給我們提供了應用程式和作業系統核心中的 TCP/IP 協議棧軟體之間的操作介面。圖1用直觀的形式說明了 Socket 在分層網路體系中的位置,由圖可見 Socket 為我們在應用層和傳輸層及網路層之間搭建起了橋樑,藉助 Socket,我們既可以操作 TCP/UDP 協議棧,又可以直接操作原始 IP 資料報。

圖1 Socket 在分層網路體系中的位置

伺服器

伺服器和客戶端之間的通訊採用 TCP 協議。伺服器負責監聽客戶端傳入的連線,以及向客戶端傳送資料,一般過程是:

1.伺服器端建立一個監聽 Socket,並且設定好地址族、套接字型別以及協議型別。由於 TCP 協議屬於基於位元流的流式協議,所以我們把該套接字設定為 IPv4 地址族、流式套接字以及 TCP 協議。

private Socket tcpListenSock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

2.把監聽 Socket 繫結到一個本地終結點,以後這個終結點收到的連線請求都由這個監聽 Socket 處理。所謂終結點指的是 IP 地址和埠號,因為要想通過網路通訊,網路層需要知道 IP 地址,傳輸層需要知道埠號。

tcpLocalEnd = new IPEndPoint(IPAddress.Any, listenPort);tcpListenSock.Bind(tcpLocalEnd);

需要注意的一點是,本地終結點中的 IP 地址我們指定為 IPAddress.Any,為的是能夠監聽本地計算機上所有網絡卡在埠 listenPort 上接收到的通訊。雖然我們的電腦一般都只有一塊網絡卡,但出於程式健壯性考慮,還是選擇 IPAddress.Any。

3.在監聽 Socket 上呼叫 Listen 函式,使其開始偵聽已繫結本地終結點上的連線。要知道,Listen 函式只有在 TCP 通訊中才需要使用,UDP 不需要,因為 UDP 不需要建立連線。

tcpListenSock.Listen(10);

在導讀中我說過,Socket 程式設計中“坑”很多,這裡就有一個“坑”,上面程式碼中 Listen 函式的引數“10”是什麼意思?是該套接字最大隻允許建立10個連線嗎?對於這個引數的意義,相信很多朋友都有誤區。

其實,這個引數的意思是,允許作業系統核心的 TCP/IP 協議棧為新傳入的連線請求排入佇列的最大個數。也就是說,如果應用程序來不及處理新傳入的連線請求,協議棧會幫我們把超出應用程序處理能力之外的連線請求進行排隊暫存,當應用程序有空時再從這個佇列中取出新連線。這個佇列的最大長度就是 Listen 函式的引數。

如果我們的應用程序正在忙於處理某一次連線,無暇顧及新傳入的連線,作業系統核心會幫我們把新傳入的連線暫時儲存到一個隊列當中,最多儲存10個新連線,當第11個新連線傳入時,若應用程式還沒有從佇列中取走連線,則第11個新連線就會被丟棄。

4.在監聽 Socket 上呼叫 Accept 函式,準備接受一個新傳入連線。

var workerSock = tcpListenSock.Accept();

如果沒有新連線傳入,Accept 函式會一直阻塞。當成功接受一個新連線後,Accept 函式返回一個新的 Socket,以後就可以用這個新的 Socket 來處理這條連線上的所有通訊了。

大家注意,“坑”又來了!這裡澄清一個容易讓人困惑的地方,就是 Accept 函式返回的新 Socket 和原來的監聽 Socket 之間是什麼關係?它們是相同的 Socket 嗎?它們是繫結到了相同的本地終結點嗎?難道多個 Socket 可以繫結到同一個埠?

其實,Accept 函式返回的新 Socket 和原來的監聽 Socket 是兩個不同的 Socket,只不過他們倆繫結到了相同的本地終結點而已。既然它們繫結到了同一個本地埠,那麼當網路上有資料來了以後,怎麼區分資料是發給 Accept 函式返回的新 Socket,還是發給監聽 Socket 呢?這是根據傳送資料的遠端終結點來決定的。

再進一步解釋之前,先鋪墊一下關於 TCP 中“連線”的概念。一條 TCP 連線是由一個五元組定義的,即:協議、傳送端 IP 地址、傳送端埠號、接收端 IP 地址、接收端埠號)。只要這五個元素中有一個不同,就代表不同的連線。

好了,繼續剛才的敘述,見圖2。每當Accept函式接受一個連線,協議棧都會把通訊雙方的五元組儲存起來。當有後續資料發來時,檢查一下遠端終結點是否存在於本地儲存的五元組記錄當中。若不存在,則說明是新傳入的連線(圖5中的連線A),需要把資料發給監聽Socket;若存在,則說明是在以前連線上傳來的資料(圖5中的連線B),需要把資料發給Accept函式返回的新Socket。

圖2 監聽 Socket 和 Accept 返回的 Socket 的關係

5.伺服器端用 Receive 函式接收資料。如果網路上沒有資料傳過來,則 Receive 函式會一直阻塞。若 Receive 函式成功返回,其返回值是此次從網路上讀取到的位元組數。

byte[] buf = new byte[1000];workerSock.Receive(buf);

Receive 函式的引數是你用來儲存接收資料的緩衝區,一般是位元組陣列。初學者經常會為如何確定這個陣列的大小感到困惑,甚至乾脆設定一個非常大的陣列。這又是一個“坑”,這樣做是不科學的,會造成記憶體空間的無謂浪費。

我們知道,TCP 協議是流式協議,報文之間不保留邊界,不像 UDP 那樣,每次接收到的資料都是一個完整的資料報。所以我們在接收資料時,只能按需讀取合適長度的資料,也就是說,接收緩衝區陣列的大小要根據你的應用層協議來確定,而不要簡單粗暴的設定一個非常大的值。比如說,按照你自己制定的應用層協議,收到的資料中前4個位元組代表傳送端的使用者名稱,那麼你的接收緩衝區陣列大小就設定為4;接下來的1個位元組代表使用者性別,那麼第二次接收的緩衝區陣列大小設定成1。

6.如果伺服器需要向客戶端發回響應,則呼叫 Send 函式向客戶端傳送資料。如果協議棧的傳送緩衝區已滿,則 Send 函式會阻塞,直到協議棧傳送緩衝區有空間。

byte[] sendBuf = new byte[100];workerSock.Send(sendBuf);

這裡有兩個比較容易造成混淆概念:應用程式緩衝區和協議棧緩衝區,是 Socket 程式設計中一個較為高大上的“坑”。在使用 Socket 進行網路程式設計(尤其是 TCP)過程中,不可避免的要接觸到緩衝區的概念。緩衝區有兩類,一類是我們的應用層程式碼在使用 Receive 或 Send 函式收發資料時,提供給 Socket 的位元組陣列;另一類是作業系統核心中 TCP/IP 協議棧軟體為在網路上收發資料及流量控制而設定的核心緩衝區。

可以看下面這張圖:

圖3 關於各種緩衝區的示意

圖3中 ABCD 各部分的含義分別是:

  • A:應用程式的傳送緩衝區。這個緩衝區是我們的應用程式程式碼在呼叫 Socket 的 Send 函式時提供的引數,即打算髮送到網路的位元組陣列。

  • B:應用程式的接收緩衝區。這個緩衝區是我們的應用程式程式碼在呼叫 Socket 的 Receive 函式時提供的引數,即用來存放從網路接收到的資料的位元組陣列空間。

  • C:協議棧的傳送緩衝區我們在呼叫 Socket 的 Send 函式並返回時,並不代表已經把資料發了出去,只是意味著把使用者資料拷貝到了作業系統核心的協議棧緩衝區中,也即是 C。之後,TCP/IP 協議棧軟體再從核心緩衝區中取出資料,併發送到網路。如果我們在呼叫 Socket 的 Send 函式時,核心緩衝區滿了,那麼 Send 函式就會阻塞,一直到核心緩衝區有空間為止。

  • D:協議棧的接收緩衝區我們在呼叫 Socket 的 Receive 函式時,並不是直接從網路上讀取資料,而是從核心的一個緩衝區中讀取資料,也即是 D。協議棧在收到網路上傳來的資料時,會先把這些資料存放在核心緩衝區中,等待應用程式程式碼來讀取。

有人會問了,如果核心接收緩衝區滿了怎麼辦?會丟失資料麼?大家別忘了我們用的 TCP 協議,它是面向連線的可靠的流協議,在核心接收緩衝區滿了的情況下,接收端會向傳送端傳送一個視窗通告,告訴傳送端,“你先別發資料了,我沒有地方存了,等我有地方了以後再告訴你!”,這就是 TCP 的流量控制機制。

如果核心接收緩衝區為空,那麼 Receive 函式會阻塞,直到緩衝區有資料為止。

這裡還有一個容易踩到的“坑”。有人會抓狂了,怎麼老有坑?沒錯,Socket 程式設計就是這樣,基本操作誰都懂,但是具體使用起來,可以說是一個“坑”接著一個“坑”。很多初學者以為伺服器只能接收來自客戶端的資料,認為伺服器要想向客戶端傳送資料,得需要客戶端也在本地監聽某個埠,等待伺服器的連線。這是一個非常常見的誤區,我見過不少新手寫出過這樣的程式碼。我們知道,TCP 是全雙工的協議,只要通訊雙方建立起連線,雙方就可以在兩個方向上相互通訊。也就是說,伺服器在接受一個客戶端的連線請求後,除了可以接收來自客戶端的資料外,也可以向客戶端傳送資料。

還有,伺服器是應該先接收資料再發送資料、還是先發送資料再接收資料?一般我們習慣於先讓伺服器接收資料,然後再發送資料作為回送給客戶端的響應。由於 TCP 是全雙工的,其實完全可以在連線建立以後就向客戶端傳送資料。

伺服器端的主要工作有:監聽客戶端連線、接收報文並根據報文型別作相應處理;儲存使用者登入狀態、使用者資訊及好友列表;向所有客戶端傳送心跳包,以檢測客戶端線上狀態;響應客戶端關於好友 IP 地址的請求,以實現 P2P 通訊;作為公網伺服器,輔助實現內網(NAT)穿透。

第03課:程式骨架之客戶端及協議設計
第04課:具體實現之報文類與 TCP 操作類
第05課:具體實現之伺服器類與客戶類
第06課:具體實現之點對點、伺服器併發與心跳包機制
第07課:Socket 程式設計中容易踩的坑

閱讀全文:http://gitbook.cn/gitchat/column/5b077eeeb9f775446da64412