作者博客:
http://blog.csdn.net/yahle
大綱:
項目的歷史背景
服務器的設計思路
服務器的技術
服務器的設計
服務器的改進
圖形引擎myhoho及UI庫的設計
客戶端與服務器的集成
網(wǎng)絡游戲一般采用C\S模式,網(wǎng)絡游戲的設計重點,我認為在于Server端,也就是我們說的服務器。在服務器端的設計,我把服務器按照功能分為2個部分,一個負責游戲世界的處理,一個服務器服務器與客戶端的通訊。在負責游戲世界的處理的服務器,我又按照功能分為地圖服務器和邏輯服務器。這樣劃分的依據(jù)是他們處理的內(nèi)容不同進行。當初的設計還考慮到系統(tǒng)的集群功能,可以把游戲的地圖移動處理和游戲的邏輯處理都分別分攤到其它服務器里面去。但是做到最后,發(fā)現(xiàn)這樣的設計也不是太好,主要是因為在處理一些游戲事件的時候需要兩個服務器之間進行協(xié)同,這樣勢必要創(chuàng)建一定的網(wǎng)絡游戲消息,在開始制作游戲的時候,因為需要系統(tǒng)的東西不是很多,所以沒有太注意,到項目的后期,想增加一個功能的時候,就發(fā)現(xiàn)在處理船只沉沒的時候,服務器需要傳遞很多同步數(shù)據(jù),而且服務器各自在設置玩家數(shù)據(jù)的時候,也有很多重復的地方。如果今后還要再加點什么其它功能,那要同步的地方就實在是太多了,所以按照功能把服務器分為2個部分的設計還是存在缺陷的,如果讓我重新再來,我會選擇單服務器的設計,當然這個服務器還是要和連接服務器進行分離,因為游戲的邏輯處理和與玩家的通訊還是很好分開的,而且分開的話,也有利于邏輯服務器的設計。
登陸(連接)服務器的設計:
在網(wǎng)絡游戲里,其中一個很大的難點就是玩家與服務器的通訊,在Windos的服務器架構下,網(wǎng)絡游戲服務器端采用的I/O模型,通常是完成端口。在項目開始時研究完成端口,感覺很難,根本看不懂,因為它在很多地方與以前寫網(wǎng)絡通訊軟件時用的方法不同。但是當我分析過3個完成端口的程序后,基本了解的它的使用方法。而且在懂以后,回過頭來看,其它完成端口的概念也不是很復雜,只要能清楚的了解幾個函數(shù)的使用方法以及基本的處理框架流程,你就會發(fā)現(xiàn)它其實非常的簡單。
完成端口的一些需要理解的地方:
1。消息隊列
2。工作線程
3。網(wǎng)絡消息返回結構體
一般我們在設計服務器端的時候,最關鍵的地方是如何分辯剛剛收到的網(wǎng)絡數(shù)據(jù)是由那個玩家發(fā)送過來的,如果是采用消息事件驅動的話,是可以得到一個socket的值,然后再用這個值與系統(tǒng)里存在的socket進行比對,這樣就可以得到是那位玩家發(fā)送過來的游戲消息。我在還沒有使用完成端口的時候,就是使用這個方法。這樣的設計有一個缺點就是每次收到數(shù)據(jù)的時候回浪費很多時間在于確定消息發(fā)送者身份上。但是在完成端口的設計里,我們可以采用一個取巧的方法進行設計。所以,這個問題很輕易的就結局了,而且系統(tǒng)開銷也不是很大,關于完成端口,可以參考一下的文章:
《關于Winsock異步I/O模型中的事件模型》
http://search.csdn.net/Expert/topic/166/166227.xml?temp=.4639093
《手把手教你玩轉SOCKET模型之重疊I/O篇》
http://blog.csdn.net/piggyxp/archive/2004/09/23/114883.aspx
《學習日記]
IOCP的學習--初步理解》
http://www.gameres.com/bbs/showthread.asp?threadid=25898
《用完成端口開發(fā)大響應規(guī)模的Winsock應用程序》
http://www.xiaozhou.net/ReadNews.asp?NewsID=901
《理解I/O Completion Port》
http://dev.gameres.com/Program/Control/IOCP.htm
幾個關鍵函數(shù)的說明:
http://msdn.microsoft.com/library/en-us/fileio/fs/postqueuedcompletionstatus.asp?frame=true
http://msdn.microsoft.com/library/en-us/fileio/fs/createiocompletionport.asp?frame=true
http://msdn.microsoft.com/library/en-us/fileio/fs/getqueuedcompletionstatus.asp?frame=true
http://msdn.microsoft.com/library/en-us/winsock/winsock/wsarecv_2.asp?frame=true
如果你能認真的搞清楚上面的東西,我估計你離理解完成端口就只有一步了。剩下的這一步就是自己編碼實現(xiàn)一個下了。有些時候,看得懂了不一定會實際應用,不實實在在的寫一點程序,驗證一下你的想法,是不會真正搞清楚原理的。
不過除非你想深入的研究網(wǎng)絡技術,否則只要知道怎么用就可以了,剩下的就是尋找一個合適的別人封裝好的類來使用。這樣可以節(jié)省你很多的事件,當然拿來的東西最好有源代碼,這樣如果發(fā)生什么問題,你也好確定是在那個地方出錯,要改或者擴充功能都會方便很多。當然,還要注意人家的版權,最好在引用別人代碼的地方加一些小小的注解,這樣用不了多少時間,而且對你,對原作者都有好處^_^。
不過在完成端口上我還是沒有成為拿來主義者,還是自己封裝了完成端口的操作,原因找到的源代碼代碼封裝的接口函數(shù)我怎么看怎么覺得別扭,所以最后還是自己封裝了一個完成端口,有興趣的可以去看我的源代碼,里面有很詳細的注解。而且就我看來,要拿我封裝的完成端口類使用起來還是很簡單的。使用的時候,只要繼承我的CIOCP,然后,根據(jù)需要覆蓋3個虛函數(shù)(OnAccept,OnRead,OnClose)就可以了,最多是在連接函數(shù)里,需要用一個函數(shù)去設置一下完成端口信息。當然,我封裝的類稍微簡單了一些,如果要拿來響應大規(guī)模連接,還是存在很多的問題,但是如果只是針對少量連接,還是可以應付的。
對于客戶端的I/O模型,我就沒有那么用心的去尋找什么好的解決方案,采用了一個最簡單的,最原始的阻塞線程的方法做。原理很簡單:創(chuàng)建一個sockt,把socket設置為阻塞,連接服務器成功后,啟動一個線程,在線程里面用recv()等待服務器發(fā)過來的消息。在我的代碼里,也是把阻塞線程的方法封裝成一個類,在使用的時候,先繼承TClientSocket,然后覆蓋(重載)里面的OnRead()函數(shù),并在里面寫入一些處理收到數(shù)據(jù)后的操作代碼。在用的時候,只要connect成功,系統(tǒng)就會自動啟動一個接收線程,一旦有數(shù)據(jù)就觸發(fā)剛才覆蓋的OnRead函數(shù)。這個類我也不是完全直接寫的,在里面使用了別人的一些代碼,主要是讓每個類都能把線程封裝起來,這樣在創(chuàng)建不同的類的實體的時候,每個類的實體自己都會有一個單獨的數(shù)據(jù)接收線程。
當然除了阻塞線程的方法,比較常用的還有就是用消息事件的方法收取數(shù)據(jù)了。我剛開始的時候,也是采用這個方法(以前用過^_^),但是后來發(fā)現(xiàn)不太好封裝,最后采用阻塞線程的方法,這樣做還有一個好處可以讓我的代碼看起來更加舒服一些。不過就我分析《航海世紀》客戶端采用的是消息事件的I/O模型。其它的網(wǎng)絡游戲就不太清楚了,我想也應該是采用消息事件方式的吧。。
我記得在gameres上看到過某人寫的一篇關于完成端口的筆記,他在篇末結束的時候,提出一個思考題:我們在學習完成端口的時候,都知道它是用于server端的操作,而且很多文章也是這樣寫的,但是不知道有沒有考慮過,用完成端口做客戶端來使用?
其實這個問題很好回答,答案是OK。拿IOCP做客戶端也是可行的,就以封裝的IOCP為例,只要在繼承原來的CIOCP類的基礎上,再寫一個Connect(char * ip, int port)的函數(shù),就可以實現(xiàn)客戶端的要求了。
- bool CIOCPClient::Connect(char *ip, int port)
- {
-
-
- if (!bInit)
-
- if (!Init())
-
- return false;
-
-
- SOCKET m_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
-
- if (m_socket == SOCKET_ERROR)
- return false;
-
-
-
- sockaddr_in ClientAddr;
-
- ClientAddr.sin_family = AF_INET;
-
- ClientAddr.sin_port = htons(port);
-
- ClientAddr.sin_addr.s_addr = inet_addr(ip);
-
- bind(m_socket, (SOCKADDR *)&ClientAddr, sizeof(ClientAddr));
-
- if (connect(m_socket, (SOCKADDR *)&ClientAddr, sizeof(ClientAddr)) == SOCKET_ERROR)
-
- return false;
- this->m_workThread = true;
-
-
-
- g_hwThread = CreateThread(NULL, 0, WorkThread, (LPVOID)this, 0, &m_wthreadID);
- this->SetIoCompletionPort(m_socket, NULL);
- return true;
-
- }
前面一段是用來連接服務器,所有的客戶端程序都是要這樣做的,當連接成功后,m_socket就是我們想要的用于與服務器端通訊的socket,然后,我們啟動工作線程,并使用SetIoCompletionPort來設置完成端口監(jiān)聽的socket。只要在原來的基礎上增加一個函數(shù),就可以把用于服務器的ICOP變成用于客戶端的IOCP。
在收到網(wǎng)絡數(shù)據(jù)以后,下一步就是根據(jù)需要,把收到的網(wǎng)絡數(shù)據(jù)包轉變?yōu)橛螒蛳?shù)據(jù)包。在轉換之前,首先是要從收到的網(wǎng)絡數(shù)據(jù)里面提取出有效的消息。這里為什么說是要提取有效部分?其主要原因是,我們創(chuàng)建的游戲消息數(shù)據(jù),在進行網(wǎng)絡傳輸?shù)臅r候,不是以消息的長度來傳的,而是根據(jù)系統(tǒng)在接收到發(fā)送數(shù)據(jù)請求的時候,根據(jù)實際情況來發(fā)送的。例如我這里有一條很長的游戲消息,有3k,但是系統(tǒng)一次只能發(fā)送1k的數(shù)據(jù),所以,我們的游戲消息,只能把我們的游戲消息分為3個包,分3次發(fā)送,這樣在我們接收消息的時候,就會觸發(fā)3次OnRead,而這3次OnRead收到的數(shù)據(jù)都不是一次完整的游戲消息。所以,我們在收到網(wǎng)絡數(shù)據(jù)后,要先和上一次收到的網(wǎng)絡數(shù)據(jù)進行合并,然后再在里面提取出有效的游戲消息,并在提取后,把已經(jīng)提取的部分刪除。我在這里把這一步操作封裝到一個類里CBuftoMsg。這里順便說明一下:一條游戲消息的網(wǎng)絡數(shù)據(jù)包是以0x00EEEE(16進制)為結束標記(《航海世紀》的做法)。
我在這里把 CBuftoMsg 的代碼貼出來,主要是因為,我在寫本文的時候,發(fā)現(xiàn)一個驚天動地的bug,有興趣的讀者可以自己去找一下。不過一開始寫代碼的時候,還不是這樣的,當初的代碼bug比這個還要多,問題還要嚴重,嚴重到經(jīng)常讓服務器程序莫名其妙的崩潰,而且這個問題,一直到5月份,系統(tǒng)在進行集成測試的時候才發(fā)現(xiàn)并解決(還沒有徹底解決,至少目前我還發(fā)現(xiàn)了bug,),以前一直都沒有怎么注意到這個問題,而且我們還把因為這個bug造成的問題,歸結到線程的互斥上去^_^!
我的登陸服務器,除了基本的處理網(wǎng)絡數(shù)據(jù)包以外,還負責玩家系統(tǒng)的登陸驗證,這部分東西不是很復雜,在我的程序里,只是簡單的從ini文件里讀取玩家的信息而已,有興趣的自己去看我的代碼(不過這部分遠還沒有真正的完善,存在很多問題)。
除了登陸驗證以外,在登陸程序還負責進行消息轉發(fā),就是把客戶端的消息分別發(fā)送到不同的服務器。如果當初設計的是一個邏輯服務器,這個功能就可以簡單很多,只要發(fā)送到一個服務器里就可以了。現(xiàn)在的要發(fā)到2個服務器,所以還需要對收到的游戲消息進行分類。為了方便,我對原來定義消息的ID進行了分類,所以,在GameMessageID.h文件里定義的游戲消息對應的ID編號不是順序編排的。不過也因為這樣,在現(xiàn)在看來,這樣的設計,有一些不太好。在整個系統(tǒng)里,存在有4個主體,他們之間互相發(fā)送,就用了12組的數(shù)據(jù),為了方便計算,我把一個變量的范圍分為16個不同的區(qū)域,這樣每個區(qū)域只有16個值可以用(我這里是用char類型256/16=16)。在加上用另外一個變量表示邏輯上上的分類(目前按照功能分了12組,有登陸、貿(mào)易、銀行、船廠等)這樣對于貿(mào)易這個類型的游戲消息,從客戶端發(fā)送到邏輯服務器上,只能有16中可能性,如果要發(fā)送更多消息,可能要增加另外一個邏輯分類:貿(mào)易2^_^!當初這樣的設計只是想簡化一下系統(tǒng)的處理過程,不過卻造成了系統(tǒng)的擴充困難,要解決也不是沒有辦法,把類型分類的變量由char類型,改為int類型,這樣對一個變量分區(qū),在范圍上會款很多,而且不會造成邏輯分類上的困擾,但是,這樣存在一個弊端就是就是每條網(wǎng)絡消息數(shù)據(jù)包的長度增加了一點點。不要小看這一個字節(jié)的變量,現(xiàn)在設計的一條游戲消息頭的長度是10個字節(jié),如果把char改為int,無形中就增加了3個字節(jié),在和原來的比較,這樣每條消息在消息頭部分,就多出23%,也就是我們100M的網(wǎng)絡現(xiàn)在只能利用77%而已。
^_^呵呵看出什么問題沒有?
沒有,那我告訴你,有一個概念被偷換了,消息頭的數(shù)據(jù)不等于整條游戲的消息數(shù)據(jù),所以,消息頭部分雖然多出了23%,但是整條游戲消息并不會增加這么多,最多增加17%,最少應該不會操作5%。平均起來,應該在10%左右(游戲消息里,很多消息的實際部分可能就一個int變量而已)。不過,就算是10%,也占用了帶寬。
^_^呵呵還看出什么問題沒有?
^_^先去讀一下我的代碼,再回頭看看,上面的論述還有什么問題。
實際上,每條游戲消息由:消息頭、消息實體、結束標記組成,其中固定的是消息頭和結束標記,所以,實際上一條實際上游戲消息的數(shù)據(jù)包,最多比原來的多15%,平均起來,應該是8%~10%的增量而異。
好了,不在這個計算細節(jié)上扣太多精力了。要解決這個問題,要么是增加網(wǎng)絡數(shù)據(jù)的發(fā)送量,要么,就是調(diào)整游戲結構,例如,把兩個功能服務器合并為一個服務器,這樣服務器的對象實體就由原來的4個分為3個,兩兩間的通訊,就由原來的12路縮減為6路,只要分8個區(qū)域就ok了。這樣每個邏輯分類就有32條游戲消息可以使用。當然,如果進一步合并服務器,把服務器端都合并到一個程序,那就不用分類了^_^!
在登陸服務器目錄下,還有一組mynet.h/mynet.cpp的文件,是我當初為服務器端設計的函數(shù),封裝的是消息事件網(wǎng)絡響應模型。只不過封裝得不是怎么好,被拋棄不用了,有興趣的可以去看看,反正我是不推薦看的。只不過是在這里說明一下整個工程目錄的結構而已。