第一部分 一個(gè)普通網(wǎng)絡(luò)模塊的接法的網(wǎng)絡(luò)模塊
大部分項(xiàng)目對(duì)網(wǎng)絡(luò)的需求都比較簡(jiǎn)單。主要需要滿足登錄、購(gòu)買、顯示背包物品等低頻的協(xié)議請(qǐng)求。面對(duì)這部分需求,只要一個(gè)普通的網(wǎng)絡(luò)模塊就可以搞定。
網(wǎng)絡(luò)模塊的接法我們就先從這一部分開(kāi)始,編寫(xiě)一個(gè)普通的網(wǎng)絡(luò)模塊。
1
模塊設(shè)計(jì)
1.1
重要
網(wǎng)絡(luò)模塊的重要性,無(wú)須多言。
1.2
神秘
一個(gè)項(xiàng)目的模塊很多,但是網(wǎng)絡(luò)模塊只有一個(gè)。再結(jié)合網(wǎng)絡(luò)模塊的重要性,所以,大部分新來(lái)的同學(xué),都沒(méi)有機(jī)會(huì)做網(wǎng)絡(luò)模塊。
兩個(gè)項(xiàng)目的背包模塊幾乎無(wú)法相同,但是網(wǎng)絡(luò)模塊卻幾乎通用。繼續(xù)結(jié)合網(wǎng)絡(luò)模塊的重要性,所以一個(gè)項(xiàng)目的網(wǎng)絡(luò)模塊大概只會(huì)在——曾經(jīng)做過(guò)網(wǎng)絡(luò)模塊的同學(xué)——手里不斷重構(gòu)和完善,最后幾乎可以與業(yè)務(wù)無(wú)關(guān)。于是,大部分已經(jīng)工作一段時(shí)間的同學(xué),再也沒(méi)有機(jī)會(huì)做網(wǎng)絡(luò)模塊。
我遇到很多新老同學(xué)過(guò)來(lái)打聽(tīng)網(wǎng)絡(luò)模塊的情況。大家覺(jué)得它很神秘,躍躍欲試,卻不知從何入手。
現(xiàn)在就有一個(gè)機(jī)會(huì),我們一起來(lái)設(shè)計(jì)和實(shí)現(xiàn)一個(gè)網(wǎng)絡(luò)模塊。
1.3
簡(jiǎn)單
其實(shí)網(wǎng)絡(luò)模塊沒(méi)有什么神秘。它的一般性框架是這樣的(火影手游的PVP網(wǎng)絡(luò)模塊是專用的網(wǎng)絡(luò)模塊,詳見(jiàn)我之前的文章,以及后續(xù)的教程):
圖1 網(wǎng)絡(luò)模塊的一般性框架
看起來(lái)好像很簡(jiǎn)單,大致就分為兩大部分:連接管理器和協(xié)議管理器。而且這兩個(gè)管理器的實(shí)現(xiàn)也相當(dāng)簡(jiǎn)單。大致如下:
圖2 連接管理器與協(xié)議管理器的實(shí)現(xiàn)
ConnectManager。它維護(hù)一個(gè)Connection實(shí)例列表。這些實(shí)例根據(jù)底層通訊接口的不同,有多種類型。Connection封裝了數(shù)據(jù)的收發(fā)邏輯,它分別為Send和Receive提供Buffer。它主要實(shí)現(xiàn)以下功能:
創(chuàng)建連接。
這部分功能主要在XXConnection類實(shí)現(xiàn),不同的通訊接口,連接方式不同。比如Apollo的通訊接口,需要提供公司的一攬子參數(shù)。而B(niǎo)luetooth接口而需要提供BluetoothMacAddress。而采用底層的Socket實(shí)現(xiàn)的UDP通訊接口,則無(wú)須Connect過(guò)程,那么我們就給它虛擬一個(gè)假的Connect過(guò)程,以保持IConnection接口的統(tǒng)一性。等等等等。
收發(fā)數(shù)據(jù)。
大部分通訊接口對(duì)數(shù)據(jù)的Recv是通過(guò)輪詢實(shí)現(xiàn)的,在ConnectManager里將輪詢操作統(tǒng)一轉(zhuǎn)換為事件方式。
斷線重連。
Connection分別為Send和Recv提供了Buffer,以便支持靜默重連,使上層邏輯在大部分情況下無(wú)須關(guān)心網(wǎng)絡(luò)是否斷開(kāi),也可以發(fā)送數(shù)據(jù)。比如,網(wǎng)絡(luò)突然斷開(kāi)了,假設(shè)A模塊不負(fù)責(zé)維護(hù)在線狀態(tài),那么在它看來(lái),它依然可以正常發(fā)送數(shù)據(jù)。假設(shè)B模塊負(fù)責(zé)維護(hù)在線狀態(tài),那么它應(yīng)該監(jiān)聽(tīng)到網(wǎng)絡(luò)斷開(kāi),然后進(jìn)行重連,最后重連成功。在網(wǎng)絡(luò)重連成功后,緩存在Connection的數(shù)據(jù),就可以發(fā)送出去了。整個(gè)過(guò)程,對(duì)A模塊是不可感知的。(是不是覺(jué)得斷線靜默重連也沒(méi)有想像中那么復(fù)雜了?)
ProtocolManager。相對(duì)ConnectManager,它簡(jiǎn)單得多。它維護(hù)一個(gè)從協(xié)議ID到協(xié)議類的映射(對(duì)于C#這種具有反射機(jī)制的語(yǔ)言,可以直接映射到協(xié)議類,但是對(duì)于C++則可以用其它方法來(lái)實(shí)現(xiàn)映射)。并且定義了協(xié)議格式。
到此為止,一個(gè)幾乎通用的網(wǎng)絡(luò)模塊框架基本上搭完了。是不是很簡(jiǎn)單?
1.4
模塊糖
模塊糖,這是我杜撰的一個(gè)詞。就像語(yǔ)法糖一樣。對(duì)于不同的項(xiàng)目,可以給ConnectManager和ProtocolManager加一些糖,讓它用起來(lái)更甜。比如將SendProtocol(pid, PTLObj, connId)包裝成SendDirProtocol(pid,PTLObj)和SendZoneProtocol(pid,PTLObj)等;將CreateConnection(connId,type,ip,port)包裝成CreateDirConnection(ip,port)和CreateZoneConnection(ip,port)等。Dir和Zone在網(wǎng)絡(luò)模塊中的含義大家應(yīng)該都知道。
等等等等。
2
連接層實(shí)現(xiàn)
上面聊了一下網(wǎng)絡(luò)模塊的一般性框架。下面從具體實(shí)現(xiàn)來(lái)聊聊相關(guān)技術(shù)。掌握了這些技術(shù)點(diǎn),便可以輕松實(shí)現(xiàn)一個(gè)網(wǎng)絡(luò)模塊的連接層。
2.1
關(guān)于Socket
Socket就是常說(shuō)的套接字。說(shuō)實(shí)話我對(duì)這個(gè)翻譯是很懵逼的。Socket就是我們正常網(wǎng)絡(luò)編程中能夠接觸到的最底層的通訊接口。對(duì)于它的原理,在這篇文章中,我們只意會(huì),不言傳。
對(duì)于Socket,我們最需要關(guān)注的是它的工作方式。在客戶端Socket主要有2種工作方式:
同步方式。無(wú)論是UDP還是TCP,在用Socket進(jìn)行連接、發(fā)送、接收的時(shí)候,在未完成工作前代碼不再繼續(xù)往下執(zhí)行,處于等待狀態(tài),直到該語(yǔ)句完成對(duì)應(yīng)個(gè)工作后才繼續(xù)執(zhí)行下一條語(yǔ)句。值得注意的是,UDP和TCP對(duì)于一件工作是否完成的定義不同,以Send為例,如下圖。
對(duì)于TCP來(lái)說(shuō),未完成工作就是:
緩沖區(qū)滿了,數(shù)據(jù)無(wú)法寫(xiě)入,
或者數(shù)據(jù)寫(xiě)入了但是還沒(méi)輪到它發(fā)送,
或者數(shù)據(jù)發(fā)送了,但是未收到ACK確認(rèn)。
對(duì)于UDP來(lái)說(shuō),未完成工作就是:緩沖區(qū)滿了,數(shù)據(jù)無(wú)法寫(xiě)入。
圖3 Socket同步方式時(shí)序圖
異步方式。即不論對(duì)應(yīng)工作是否完成,都會(huì)繼續(xù)往下執(zhí)行。當(dāng)工作完成后,是通過(guò)一個(gè)回調(diào)來(lái)通知調(diào)用者(千萬(wàn)注意:這個(gè)回調(diào)是在一個(gè)Socket內(nèi)部創(chuàng)建的子線程上下文中)。參照上圖,不需要單獨(dú)用時(shí)序圖來(lái)說(shuō)明了。
在同步方式中,可以理解為有一個(gè)Loop在不停地輪詢是否完成工作,直到工作完成才結(jié)束Loop。為了避免UI以及主邏輯被卡住,一般需要將以同步方式工作的操作都放在自制的子線程中。而在異步方式中,實(shí)質(zhì)上是Socket內(nèi)部創(chuàng)建了一個(gè)子線程。
那么綜合以上情況,在實(shí)際使用中,我們應(yīng)該選擇“同步方式”還是“異步方式”呢?我做的不完全性能測(cè)試的結(jié)論是,同步方式的性能大概是異步方式的4倍。這是很容易理解的,因?yàn)檫@里所謂的異步方式其實(shí)就是Socket內(nèi)部幫我們做了一個(gè)線程,而且它為了考慮到基礎(chǔ)組件的通用性,肯定在性能方面會(huì)有所損耗。
看看下表的對(duì)比:
對(duì)比
同步方式
異步方式
性能
高
低
復(fù)雜度
自制線程
內(nèi)置線程
靈活性
高
低
所以,如果對(duì)網(wǎng)絡(luò)連接沒(méi)有特別要求的情況下,比如獨(dú)立小游戲,可優(yōu)先考慮異步方式,省心省事。但是,我更愿意使用同步方式+
自制線程
,對(duì)于系列性能更加可控。
2.2
關(guān)于多線程
當(dāng)我們不得不使用子線程時(shí),就要面對(duì)一個(gè)令很多新同學(xué)都感到陌生神秘的東西:線程。由于使用多線程的情況并不多,所以主要掌握以下幾點(diǎn)大概便可以在網(wǎng)絡(luò)編程中使用多線程了。
線程函數(shù)。
如果說(shuō)主線程是從Main函數(shù)開(kāi)始的(在Unity+C#里,你是看不到Main函數(shù)的。),那么子線程也是從一個(gè)函數(shù)開(kāi)始。為了防止主線程與子線程的代碼邏輯搞混,建議將線程函數(shù)定義在一個(gè)單獨(dú)的類里。由這個(gè)函數(shù)所調(diào)用的所有被調(diào)用函數(shù)都在這個(gè)類里。
前臺(tái)線程和后臺(tái)線程。
切記,系統(tǒng)默認(rèn)創(chuàng)建的子線程是前臺(tái)線程,它將帶來(lái)一個(gè)問(wèn)題,就是當(dāng)主線程已經(jīng)結(jié)束時(shí),程序還會(huì)運(yùn)行。如果將它設(shè)置為后臺(tái)線程,則當(dāng)主線程結(jié)束時(shí),所有后臺(tái)線程都會(huì)無(wú)異常中止。
線程同步。
當(dāng)主線程和子線程存在共用數(shù)據(jù)時(shí),為了避免多線程同時(shí)操作同一數(shù)據(jù),需要使用“鎖”。C#有多種鎖定方式,比較常用的是lock語(yǔ)句。建議不要直接lock需要操作的數(shù)據(jù),而是為這個(gè)數(shù)據(jù)定義一個(gè)對(duì)應(yīng)的object,lock這個(gè)object。因?yàn)橛行╊愋偷臄?shù)據(jù),比如int,是無(wú)法直接lock的。
異常處理。
使用Try/Catch進(jìn)行異常處理時(shí),不要在線程的創(chuàng)建處TryCatch。一旦線程創(chuàng)建成功,線程執(zhí)行過(guò)程中的異常,是無(wú)法在其它線程中被捕獲的。正確的做法是在線程函數(shù)里TryCatch。
當(dāng)然關(guān)于多線程的其它知識(shí),有很多專門的文章介紹。
2.3
關(guān)于TryCatch
對(duì)于C#來(lái)講,你使用或者不使用TryCatch,對(duì)于性能的消耗是一樣的。甚至你在離Exception最近的地方使用了TryCatch,還會(huì)提高性能。因?yàn)槿绻划?dāng)發(fā)生Exception,運(yùn)行時(shí)會(huì)依次向上遞歸尋找TryCatch代碼,最終會(huì)找到運(yùn)行時(shí)那一層去,然后成功被運(yùn)行時(shí)Catch到。與其如此,為什么不自己去Catch呢?所以,應(yīng)該積極地在適當(dāng)?shù)牡胤绞褂肨ryCatch,但是一定要在Catch后進(jìn)行處理并且輸出日志,否則就隱藏了問(wèn)題!
2.4
關(guān)于連接
Connection是對(duì)底層或者基礎(chǔ)通訊接口以及可能使用的線程相關(guān)邏輯進(jìn)行封裝。一般情況下,按照所使用的通訊接口類型進(jìn)行封裝。
如果使用Apollo的通訊組件,可以封裝成ApolloConnection。
如果使用Socket,則封裝成TCPConnection/UDPConnection。
如果使用Bluetooth,則封裝一個(gè)BluetoothConnection。
如果使用RS232串口通訊,則封裝一個(gè)RS232Connection。
這個(gè)世界上有很多種通訊方式,你都可以封裝成對(duì)應(yīng)的Connection,以便統(tǒng)一它的通訊接口。除此之外,它主要還將提供Send和Recv的數(shù)據(jù)緩存。
2.5
關(guān)于數(shù)據(jù)包/數(shù)據(jù)流
從圖1中,我們看到,一個(gè)“協(xié)議實(shí)例”將轉(zhuǎn)換為一個(gè)“協(xié)議數(shù)據(jù)包”,然后“協(xié)議數(shù)據(jù)包”將以“數(shù)據(jù)流/數(shù)據(jù)包”的形式發(fā)送出去。
在不同的傳輸協(xié)議中,數(shù)據(jù)的發(fā)送形式是不同的。在TCP傳輸中,數(shù)據(jù)是以流的形式發(fā)送。而在UDP傳輸中,數(shù)據(jù)是以包的形式發(fā)送。
它們的區(qū)別在于,一個(gè)數(shù)據(jù)包里包含一個(gè)協(xié)議的完整數(shù)據(jù)。而一段數(shù)據(jù)流里可能包含的是多個(gè)協(xié)議的數(shù)據(jù),或者一個(gè)不完整的協(xié)議數(shù)據(jù)。
2.6
關(guān)于輪詢
無(wú)論是使用同步方式還是異步方式,都會(huì)發(fā)生——主線程從子線程讀取數(shù)據(jù)——操作。有些同學(xué)喜歡采用拋事件的方式,但是,那樣只會(huì)使子線程的上下文擴(kuò)散得更廣泛更亂。如果有一天你發(fā)生在一個(gè)事件的回調(diào)函數(shù)里調(diào)用Time.realtimeSinceStartup一直莫名其妙報(bào)錯(cuò),那么,這個(gè)回調(diào)函數(shù)一定是從一個(gè)子線程里調(diào)出來(lái)的。但是,你完全懵逼。
所以,建議采用輪詢這種古老的方式,將子線程的上下文限制在一個(gè)輪小的范圍里。
除了以上主要原因外,還有一個(gè)原因:
異常隔離。防止業(yè)務(wù)層模塊異常導(dǎo)致整個(gè)Connection的異常。因?yàn)镃onnection作為基礎(chǔ)功能,還有其它模塊在使用。
3
協(xié)議層實(shí)現(xiàn)
協(xié)議層相對(duì)連接層簡(jiǎn)單得多。它的主要相關(guān)技術(shù)如下。
3.1
協(xié)議格式
最基本的協(xié)議格式如下:
協(xié)議頭
PID: 協(xié)議ID
Index: 協(xié)議發(fā)送序列號(hào)
DataBuffSize: 協(xié)議體的數(shù)據(jù)長(zhǎng)度
CheckSum: 校驗(yàn)和
協(xié)議體
DataBuff: 協(xié)議數(shù)據(jù)Buffer。
以上協(xié)議格式定義了一個(gè)協(xié)議數(shù)據(jù)包。其中DataBuff來(lái)自對(duì)協(xié)議實(shí)例的序列化。
為了實(shí)現(xiàn)對(duì)協(xié)議實(shí)例的序列化,我們可以自定義一個(gè)IProtocolBase接口,讓具體協(xié)議來(lái)實(shí)現(xiàn)這個(gè)接口。
但是,在實(shí)際應(yīng)用中,我們都是直接使用Google的ProtoBuf作為協(xié)議的基類。它已經(jīng)提供了非常高效的序列化和反序列化功能。
3.2
協(xié)議流
在章節(jié)2.4中得知,有些情況下,協(xié)議層收到來(lái)自連接層的數(shù)據(jù),并不一定是一個(gè)恰好完整的協(xié)議數(shù)據(jù)包,而有可能一段數(shù)據(jù)流。于是,為了統(tǒng)一邏輯,不管收到的是數(shù)據(jù)包,還是數(shù)據(jù)流,我都將它們統(tǒng)一為協(xié)議流。
在ProtocolManager中,需要對(duì)協(xié)議流進(jìn)行合并或分割處理。其實(shí)很簡(jiǎn)單,它的邏輯流程如下所示。(需要注意的是,如果系統(tǒng)中同時(shí)存在多個(gè)Connection,需要為每一個(gè)Connection定義一個(gè)協(xié)議流。)
圖4 協(xié)議流處理邏輯
3.3
協(xié)議分類
一般情況下,協(xié)議可以分為這幾類:
只發(fā)送,不需要監(jiān)聽(tīng)回包。用于向服務(wù)器上報(bào)數(shù)值。
無(wú)發(fā)送,只需要監(jiān)聽(tīng)回包。用于服務(wù)器Push數(shù)值,或者觸發(fā)邏輯。
一處發(fā)送,多處監(jiān)聽(tīng)回包。用于基礎(chǔ)功能協(xié)議。
一處發(fā)送,一處監(jiān)聽(tīng)回包。用于具體功能協(xié)議。
ProtocolManager應(yīng)該對(duì)上面4種協(xié)議都能提供支持。
3.4
協(xié)議ID規(guī)則
后臺(tái)喜歡把協(xié)議ID叫CMD,或者CmdID。我一般直譯為PID。PID的規(guī)則一般有兩種:
同一條協(xié)議,發(fā)包和回包時(shí),PID相同。因?yàn)榘l(fā)包和回包時(shí),雖然協(xié)議體內(nèi)容不同,但卻是一回一答,是為同一個(gè)功能服務(wù)的。
同一條協(xié)議,發(fā)包和回包時(shí),PID不同。因?yàn)榘l(fā)包和回包時(shí),協(xié)議體的內(nèi)容不同。目前比較流行這種方式。為了使編程更方便,以及代碼容易理解,一般將回包的PID定義為發(fā)包的PID+1。
4
調(diào)試
無(wú)論做什么模塊開(kāi)發(fā),都離不開(kāi)調(diào)試。而網(wǎng)絡(luò)模塊對(duì)于調(diào)試的要求更高。可以這么說(shuō),你編寫(xiě)一個(gè)網(wǎng)絡(luò)模塊可能需要2天,但是將來(lái)花在調(diào)試它的時(shí)間可能是直到項(xiàng)目結(jié)束。
所以,在你完成網(wǎng)絡(luò)模塊的代碼編寫(xiě)之后,一定不要忘記,為了能夠高效地調(diào)試,做好一切準(zhǔn)備。
4.1
網(wǎng)絡(luò)日志系統(tǒng)
我相信,你的項(xiàng)目中一定已經(jīng)有了現(xiàn)成的日志系統(tǒng)。但是那遠(yuǎn)遠(yuǎn)不夠。建議在其基礎(chǔ)上封裝一個(gè)網(wǎng)絡(luò)日志系統(tǒng),并且為它提供一個(gè)專用面板。它會(huì)比在總?cè)罩疚谋纠锟淳W(wǎng)絡(luò)日志要高效得多,性能也可控得多。它應(yīng)該提供如下功能:
單獨(dú)輸出網(wǎng)絡(luò)模塊的日志。
以16進(jìn)制顯示每一個(gè)Connection的Send和Recv緩沖區(qū)數(shù)據(jù)。這是你能夠接觸到的最底層接口的數(shù)據(jù),后臺(tái)會(huì)經(jīng)常和你Check這些數(shù)據(jù)。
列出每一條被注冊(cè)的協(xié)議。
記錄發(fā)送和接收到的每一條協(xié)議的內(nèi)容。如果該協(xié)議是注冊(cè)的,則可以反序列化為結(jié)構(gòu)性信息,如果未注冊(cè),則提示未注冊(cè),并且顯示16進(jìn)制數(shù)據(jù)。
統(tǒng)計(jì)斷線重連次數(shù),斷線時(shí)長(zhǎng),網(wǎng)絡(luò)延時(shí)等。
4.2
網(wǎng)絡(luò)狀況模擬
在研發(fā)階段,這個(gè)功能是非常有用的??梢詭椭愀咝y(cè)試網(wǎng)絡(luò)模塊在各種網(wǎng)絡(luò)情況下,是否正常工作。也可以為業(yè)務(wù)模塊提供網(wǎng)絡(luò)相關(guān)的測(cè)試手段。比如,測(cè)試在線模塊,斷線重連邏輯(再也不需要撥網(wǎng)線了)等。
4.3
抓包工具
一般使用Wireshark和Fiddler。網(wǎng)絡(luò)編程必備工具。
點(diǎn)擊一下
立即閱讀相關(guān)好文章
《貪婪洞窟》談貪婪設(shè)計(jì)丨騰訊GAD游戲創(chuàng)新大賽丨
游戲美術(shù)3D設(shè)計(jì)干貨回顧丨為VR優(yōu)化UE4渲染器丨
這么做設(shè)計(jì)才好玩丨Unity教程
MOBA類游戲核心設(shè)計(jì)分析
......
近期熱文
工作效率UP!Unity3D 手游版本構(gòu)建之路
新年送什么禮物給程序員男朋友,才能又走心又實(shí)用?
評(píng)論列表
還沒(méi)有評(píng)論,快來(lái)說(shuō)點(diǎn)什么吧~