原文地址:https://blog.csdn.net/erlib/article/details/8936990
登錄服的設(shè)計 -- 功能需求
正如我們在前面曾討論過的,登錄服要實現(xiàn)的功能相當(dāng)簡單,就是帳號驗證。為了便于描述,我們暫不引入那些討論過的優(yōu)化手段,先以最簡單的方式實現(xiàn),另外也將基本以mangos的代碼作為參考來進行描述。
想象一下帳號驗證的實現(xiàn)方法,最容易的那就是把用戶輸入的明文用帳號和密碼直接發(fā)給登錄服,服務(wù)器根據(jù)帳號從數(shù)據(jù)庫中取出密碼,與用戶輸入的密碼相比較。
這個方法存在的安全隱患實在太大,明文的密碼傳輸太容易被截獲了。那我們試著在傳輸之前先加一下密,為了服務(wù)器能進行密碼比較,我們應(yīng)該采用一個可逆的加密算法,在服務(wù)器端把這個加密后的字串還原為原始的明文密碼,然后與數(shù)據(jù)庫密碼進行比較。既然是一個可逆的過程,那外掛制作者總有辦法知道我們的加密過程,所以,這個方法仍不夠安全。
哦,如果我們只是希望密碼不可能被還原出來,那還不容易嗎,使用一個不可逆的散列算法就行了。用戶在登錄時發(fā)送給服務(wù)器的是明文的帳號和經(jīng)散列后的不可逆密碼串,服務(wù)器取出密碼后也用同樣的算法進行散列后再進行比較。比如,我們就用使用最廣泛的md5算法吧。噢,不要管那個王小云的什么論文,如果我真有那么好的運氣,早中500w了,還用在這考慮該死的服務(wù)器設(shè)計嗎?
似乎是一個很完美的方案,外掛制作者再也偷不到我們的密碼了。慢著,外掛偷密碼的目的是什么?是為了能用我們的帳號進游戲!如果我們總是用一種固定的算法來對密碼做散列,那外掛只需要記住這個散列后的字串就行了,用這個做密碼就可以成功登錄。
嗯,這個問題好解決,我們不要用固定的算法進行散列就是了。只是,問題在于服務(wù)器與客戶端采用的散列算法得出的字串必須是相同的,或者是可驗證其是否匹配的。很幸運的是,偉大的數(shù)學(xué)字們早就為我們準(zhǔn)備好了很多優(yōu)秀的這類算法,而且經(jīng)理論和實踐都證明他們也確實是足夠安全的。
這其中之一是一個叫做SRP的算法,全稱叫做Secure Remote Password,即安全遠程密碼。wow使用的是第6版,也就是SRP6算法。有關(guān)其中的數(shù)學(xué)證明,如果有人能向我解釋清楚,并能讓我真正弄明白的話,我將非常感激。不過其代碼實現(xiàn)步驟倒是并不復(fù)雜,mangos中的代碼也還算清晰,我們也不再贅述。
登錄服除了帳號驗證外還得提供另一項功能,就是在玩家的帳號驗證成功后返回給他一個服務(wù)器列表讓他去選擇。這個列表的狀態(tài)要定時刷新,可能有新的游戲世界開放了,也可能有些游戲世界非常不幸地停止運轉(zhuǎn)了,這些狀態(tài)的變化都要盡可能及時地讓玩家知道。不管發(fā)生了什么事,用戶都有權(quán)利知道,特別是對于付過費的用戶來說,我們不該藏著掖著,不是嗎?
這個游戲世界列表的功能將由大區(qū)服來提供,具體的結(jié)構(gòu)我們在之前也描述過,這里暫不做討論。登錄服將從大區(qū)服上獲取到的游戲世界列表發(fā)給已驗證通過的客戶端即可。好了,登錄服要實現(xiàn)的功能就這些,很簡單,是吧。
確實是太簡單了,不過簡單的結(jié)構(gòu)正好更適合我們來看一看游戲服務(wù)器內(nèi)部的模塊結(jié)構(gòu),以及一些服務(wù)器共有組件的實現(xiàn)方法。這就留作下一篇吧。
服務(wù)器公共組件實現(xiàn) -- mangos的游戲主循環(huán)
當(dāng)閱讀一項工程的源碼時,我們大概會選擇從main函數(shù)開始,而當(dāng)開始一項新的工程時,第一個寫下的函數(shù)大多也是main。那我們就先來看看,游戲服務(wù)器代碼實現(xiàn)中,main函數(shù)都做了些什么。
由于我在讀技術(shù)文章時最不喜看到的就是大段大段的代碼,特別是那些直接Ctrl+C再Ctrl+V后未做任何修改的代碼,用句時髦的話說,一點技術(shù)含量都沒有!所以在我們今后所要討論的內(nèi)容中,盡量會避免出現(xiàn)直接的代碼,在有些地方確實需要代碼來表述時,也將會選擇使用偽碼。
先從mangos的登錄服代碼開始。mangos的登錄服是一個單線程的結(jié)構(gòu),雖然在數(shù)據(jù)庫連接中可以開啟一個獨立的線程,但這個線程也只是對無返回結(jié)果的執(zhí)行類SQL做緩沖,而對需要有返回結(jié)果的查詢類SQL還是在主邏輯線程中阻塞調(diào)用的。
登錄服中唯一的這一個線程,也就是主循環(huán)線程對監(jiān)聽的socket做select操作,為每個連接進來的客戶端讀取其上的數(shù)據(jù)并立即進行處理,直到服務(wù)器收到SIGABRT或SIGBREAK信號時結(jié)束。
所以,mangos登錄服主循環(huán)的邏輯,也包括后面游戲服的邏輯,主循環(huán)的關(guān)鍵代碼其實是在SocketHandler中,也就是那個Select函數(shù)中。檢查所有的連接,對新到來的連接調(diào)用OnAccept方法,有數(shù)據(jù)到來的連接則調(diào)用OnRead方法,然后socket處理器自己定義對接收到的數(shù)據(jù)如何處理。
很簡單的結(jié)構(gòu),也比較容易理解。
只是,在對性能要求比較高的服務(wù)器上,select一般不會是最好的選擇。如果我們使用windows平臺,那IOCP將是首選;如果是linux,epool將是不二選擇。我們也不打算討論基于IOCP或是基于epool的服務(wù)器實現(xiàn),如果僅僅只是要實現(xiàn)服務(wù)器功能,很簡單的幾個API調(diào)用即可,而且網(wǎng)上已有很多好的教程;如果是要做一個成熟的網(wǎng)絡(luò)服務(wù)器產(chǎn)品,不是我?guī)灼唵蔚募夹g(shù)介紹文章所能達到。
另外,在服務(wù)器實現(xiàn)上,網(wǎng)絡(luò)IO與邏輯處理一般會放在不同的線程中,以免耗時較長的IO過程阻塞住了需要立即反應(yīng)的游戲邏輯。
數(shù)據(jù)庫的處理也類似,會使用異步的方式,也是避免耗時的查詢過程將游戲服務(wù)器主循環(huán)阻塞住。想象一下,因某個玩家上線而發(fā)起的一次數(shù)據(jù)庫查詢操作導(dǎo)致服務(wù)器內(nèi)所有在線玩家都卡住不動將是多么恐怖的一件事!
另外還有一些如事件、腳本、消息隊列、狀態(tài)機、日志和異常處理等公共組件,我們也會在接下來的時間里進行探討。
服務(wù)器公共組件實現(xiàn) -- 繼續(xù)來說主循環(huán)
前面我們只簡單了解了下mangos登錄服的程序結(jié)構(gòu),也發(fā)現(xiàn)了一些不足之處,現(xiàn)在我們就來看看如何提供一個更好的方案。
正如我們曾討論過的,為了游戲主邏輯循環(huán)的流暢運行,所有比較耗時的IO操作都會分享到單獨的線程中去做,如網(wǎng)絡(luò)IO,數(shù)據(jù)庫IO和日志IO等。當(dāng)然,也有把這些分享到單獨的進程中去做的。
另外對于大多數(shù)服務(wù)器程序來說,在運行時都是作為精靈進程或服務(wù)進程的,所以我們并不需要服務(wù)器能夠處理控制臺用戶輸入,我們所要處理的數(shù)據(jù)來源都來自網(wǎng)絡(luò)。
這樣,主邏輯循環(huán)所要做的就是不停要取消息包來處理,當(dāng)然這些消息包不僅有來自客戶端的玩家操作數(shù)據(jù)包,也有來自GM服務(wù)器的管理命令,還包括來自數(shù)據(jù)庫查詢線程的返回結(jié)果消息包。這個循環(huán)將一直持續(xù),直到收到一個通知服務(wù)器關(guān)閉的消息包。
主邏輯循環(huán)的結(jié)構(gòu)還是很簡單的,復(fù)雜的部分都在如何處理這些消息包的邏輯上。我們可以用一段簡單的偽碼來描述這個循環(huán)過程:
while (Message* msg = getMessage())
{
if (msg為服務(wù)器關(guān)閉消息)
break;
處理msg消息;
}
這里就有一個問題需要探討了,在getMessage()的時候,我們應(yīng)該去哪里取消息?前面我們考慮過,至少會有三個消息來源,而我們還討論過,這些消息源的IO操作都是在獨立的線程中進行的,我們這里的主線程不應(yīng)該直接去那幾處消息源進行阻塞式的IO操作。
很簡單,讓那些獨立的IO線程在接收完數(shù)據(jù)后自己送過來就是了。好比是,我這里提供了一個倉庫,有很多的供貨商,他們有貨要給我的時候只需要交到倉庫,然后我再到倉庫去取就是了,這個倉庫也就是消息隊列。消息隊列是一個普通的隊列實現(xiàn),當(dāng)然必須要提供多線程互斥訪問的安全性支持,其基本的接口定義大概類似這樣:
IMessageQueue
{
void putMessage(Message*);
Message* getMessage();
}
網(wǎng)絡(luò)IO,數(shù)據(jù)庫IO線程把整理好的消息包都加入到主邏輯循環(huán)線程的這個消息隊列中便返回。有關(guān)消息隊列的實現(xiàn)和線程間消息的傳遞在ACE中有比較完全的代碼實現(xiàn)及描述,還有一些使用示例,是個很好的參考。
這樣的話,我們的主循環(huán)就很清晰了,從主線程的消息隊列中取消息,處理消息,再取下一條消息......
服務(wù)器公共組件實現(xiàn) -- 消息隊列
既然說到了消息隊列,那我們繼續(xù)來稍微多聊一點吧。
我們所能想到的最簡單的消息隊列可能就是使用stl的list來實現(xiàn)了,即消息隊列內(nèi)部維護一個list和一個互斥鎖,putMessage時將message加入到隊列尾,getMessage時從隊列頭取一個message返回,同時在getMessage和putMessage之前都要求先獲取鎖資源。
實現(xiàn)雖然簡單,但功能是絕對滿足需求的,只是性能上可能稍稍有些不盡如人意。其最大的問題在頻繁的鎖競爭上。
對于如何減少鎖競爭次數(shù)的優(yōu)化方案,Ghost Cheng提出了一種。提供一個隊列容器,里面有多個隊列,每個隊列都可固定存放一定數(shù)量的消息。網(wǎng)絡(luò)IO線程要給邏輯線程投遞消息時,會從隊列容器中取一個空隊列來使用,直到將該隊列填滿后再放回容器中換另一個空隊列。而邏輯線程取消息時是從隊列容器中取一個有消息的隊列來讀取,處理完后清空隊列再放回到容器中。
這樣便使得只有在對隊列容器進行操作時才需要加鎖,而IO線程和邏輯線程在操作自己當(dāng)前使用的隊列時都不需要加鎖,所以鎖競爭的機會大大減少了。
這里為每個隊列設(shè)了個最大消息數(shù),看來好像是打算只有當(dāng)IO線程寫滿隊列時才會將其放回到容器中換另一個隊列。那這樣有時也會出現(xiàn)IO線程未寫滿一個隊列,而邏輯線程又沒有數(shù)據(jù)可處理的情況,特別是當(dāng)數(shù)據(jù)量很少時可能會很容易出現(xiàn)。Ghost Cheng在他的描述中沒有講到如何解決這種問題,但我們可以先來看看另一個方案。
這個方案與上一個方案基本類似,只是不再提供隊列容器,因為在這個方案中只使用了兩個隊列,arthur在他的一封郵件中描述了這個方案的實現(xiàn)及部分代碼。兩個隊列,一個給邏輯線程讀,一個給IO線程用來寫,當(dāng)邏輯線程讀完隊列后會將自己的隊列與IO線程的隊列相調(diào)換。所以,這種方案下加鎖的次數(shù)會比較多一些,IO線程每次寫隊列時都要加鎖,邏輯線程在調(diào)換隊列時也需要加鎖,但邏輯線程在讀隊列時是不需要加鎖的。
雖然看起來鎖的調(diào)用次數(shù)是比前一種方案要多很多,但實際上大部分鎖調(diào)用都是不會引起阻塞的,只有在邏輯線程調(diào)換隊列的那一瞬間可能會使得某個線程阻塞一下。另外對于鎖調(diào)用過程本身來說,其開銷是完全可以忽略的,我們所不能忍受的僅僅是因為鎖調(diào)用而引起的阻塞而已。
兩種方案都是很優(yōu)秀的優(yōu)化方案,但也都是有其適用范圍的。Ghost Cheng的方案因為提供了多個隊列,可以使得多個IO線程可以總工程師的,互不干擾的使用自己的隊列,只是還有一個遺留問題我們還不了解其解決方法。arthur的方案很好的解決了上一個方案遺留的問題,但因為只有一個寫隊列,所以當(dāng)想要提供多個IO線程時,線程間互斥地寫入數(shù)據(jù)可能會增大競爭的機會,當(dāng)然,如果只有一個IO線程那將是非常完美的。
服務(wù)器公共組件實現(xiàn) -- 環(huán)形緩沖區(qū)
消息隊列鎖調(diào)用太頻繁的問題算是解決了,另一個讓人有些苦惱的大概是這太多的內(nèi)存分配和釋放操作了。頻繁的內(nèi)存分配不但增加了系統(tǒng)開銷,更使得內(nèi)存碎片不斷增多,非常不利于我們的服務(wù)器長期穩(wěn)定運行。也許我們可以使用內(nèi)存池,比如SGI STL中附帶的小內(nèi)存分配器。但是對于這種按照嚴(yán)格的先進先出順序處理的,塊大小并不算小的,而且塊大小也并不統(tǒng)一的內(nèi)存分配情況來說,更多使用的是一種叫做環(huán)形緩沖區(qū)的方案,mangos的網(wǎng)絡(luò)代碼中也有這么一個東西,其原理也是比較簡單的。
就好比兩個人圍著一張圓形的桌子在追逐,跑的人被網(wǎng)絡(luò)IO線程所控制,當(dāng)寫入數(shù)據(jù)時,這個人就往前跑;追的人就是邏輯線程,會一直往前追直到追上跑的人。如果追上了怎么辦?那就是沒有數(shù)據(jù)可讀了,先等會兒唄,等跑的人向前跑幾步了再追,總不能讓游戲沒得玩了吧。那要是追的人跑的太慢,跑的人轉(zhuǎn)了一圈過來反追上追的人了呢?那您也先歇會兒吧。要是一直這么反著追,估計您就只能換一個跑的更快的追逐者了,要不這游戲還真沒法玩下去。
前面我們特別強調(diào)了,按照嚴(yán)格的先進先出順序進行處理,這是環(huán)形緩沖區(qū)的使用必須遵守的一項要求。也就是,大家都得遵守規(guī)定,追的人不能從桌子上跨過去,跑的人當(dāng)然也不允許反過來跑。至于為什么,不需要多做解釋了吧。
環(huán)形緩沖區(qū)是一項很好的技術(shù),不用頻繁的分配內(nèi)存,而且在大多數(shù)情況下,內(nèi)存的反復(fù)使用也使得我們能用更少的內(nèi)存塊做更多的事。
在網(wǎng)絡(luò)IO線程中,我們會為每一個連接都準(zhǔn)備一個環(huán)形緩沖區(qū),用于臨時存放接收到的數(shù)據(jù),以應(yīng)付半包及粘包的情況。在解包及解密完成后,我們會將這個數(shù)據(jù)包復(fù)制到邏輯線程消息隊列中,如果我們只使用一個隊列,那這里也將會是個環(huán)形緩沖區(qū),IO線程往里寫,邏輯線程在后面讀,互相追逐??梢俏覀兪褂昧饲懊娼榻B的優(yōu)化方案后,可能這里便不再需要環(huán)形緩沖區(qū)了,至少我們并不再需要他們是環(huán)形的了。因為我們對同一個隊列不再會出現(xiàn)同時讀和寫的情況,每個隊列在寫滿后交給邏輯線程去讀,邏輯線程讀完后清空隊列再交給IO線程去寫,一段固定大小的緩沖區(qū)即可。沒關(guān)系,這么好的技術(shù),在別的地方一定也會用到的。
服務(wù)器公共組件實現(xiàn) -- 發(fā)包的方式
前面一直都在說接收數(shù)據(jù)時的處理方法,我們應(yīng)該用專門的IO線程,接收到完整的消息包后加入到主線程的消息隊列,但是主線程如何發(fā)送數(shù)據(jù)還沒有探討過。
一般來說最直接的方法就是邏輯線程什么時候想發(fā)數(shù)據(jù)了就直接調(diào)用相關(guān)的socket API發(fā)送,這要求服務(wù)器的玩家對象中保存其連接的socket句柄。但是直接send調(diào)用有時候有會存在一些問題,比如遇到系統(tǒng)的發(fā)送緩沖區(qū)滿而阻塞住的情況,或者只發(fā)送了一部分數(shù)據(jù)的情況也時有發(fā)生。我們可以將要發(fā)送的數(shù)據(jù)先緩存一下,這樣遇到未發(fā)送完的,在邏輯線程的下一次處理時可以接著再發(fā)送。
考慮數(shù)據(jù)緩存的話,那這里這可以有兩種實現(xiàn)方式了,一是為每個玩家準(zhǔn)備一個緩沖區(qū),另外就是只有一個全局的緩沖區(qū),要發(fā)送的數(shù)據(jù)加入到全局緩沖區(qū)的時候同時要指明這個數(shù)據(jù)是發(fā)到哪個socket的。如果使用全局緩沖區(qū)的話,那我們可以再進一步,使用一個獨立的線程來處理數(shù)據(jù)發(fā)送,類似于邏輯線程對數(shù)據(jù)的處理方式,這個獨立發(fā)送線程也維護一個消息隊列,邏輯線程要發(fā)數(shù)據(jù)時也只是把數(shù)據(jù)加入到這個隊列中,發(fā)送線程循環(huán)取包來執(zhí)行send調(diào)用,這時的阻塞也就不會對邏輯線程有任何影響了。
采用第二種方式還可以附帶一個優(yōu)化方案。一般對于廣播消息而言,發(fā)送給周圍玩家的數(shù)據(jù)都是完全相同的,我們?nèi)绻捎媒o每個玩家一個緩沖隊列的方式,這個數(shù)據(jù)包將需要拷貝多份,而采用一個全局發(fā)送隊列時,我們只需要把這個消息入隊一次,同時指明該消息包是要發(fā)送給哪些socket的即可。有關(guān)該優(yōu)化的說明在云風(fēng)描述其連接服務(wù)器實現(xiàn)的blog文章中也有講到,有興趣的可以去閱讀一下。
服務(wù)器公共組件實現(xiàn) -- 狀態(tài)機
有關(guān)State模式的設(shè)計意圖及實現(xiàn)就不從設(shè)計模式中摘抄了,我們只來看看游戲服務(wù)器編程中如何使用State設(shè)計模式。
首先還是從mangos的代碼開始看起,我們注意到登錄服在處理客戶端發(fā)來的消息時用到了這樣一個結(jié)構(gòu)體:
struct AuthHandler
{
eAuthCmd cmd;
uint32 status;
bool (AuthSocket::*handler)(void);
};
該結(jié)構(gòu)體定義了每個消息碼的處理函數(shù)及需要的狀態(tài)標(biāo)識,只有當(dāng)前狀態(tài)滿足要求時才會調(diào)用指定的處理函數(shù),否則這個消息碼的出現(xiàn)是不合法的。這個status狀態(tài)標(biāo)識的定義是一個宏,有兩種有效的標(biāo)識,STATUS_CONNECTED和STATUS_AUTHED,也就是未認證通過和已認證通過。而這個狀態(tài)標(biāo)識的改變是在運行時進行的,確切的說是在收到某個消息并正確處理完后改變的。
我們再來看看設(shè)計模式中對State模式的說明,其中關(guān)于State模式適用情況里有一條,當(dāng)操作中含有龐大的多分支的條件語句,且這些分支依賴于該對象的狀態(tài),這個狀態(tài)通常用一個或多個枚舉變量表示。
描述的情況與我們這里所要處理的情況是如此的相似,也許我們可以試一試。那再看看State模式提供的解決方案是怎樣的,State模式將每一個條件分支放入一個獨立的類中。
由于這里的兩個狀態(tài)標(biāo)識只區(qū)分出了兩種狀態(tài),所以,我們僅需要兩個獨立的類,用以表示兩種狀態(tài)即可。然后,按照State模式的描述,我們還需要一個Context類,也就是狀態(tài)機管理類,用以管理當(dāng)前的狀態(tài)類。稍作整理,大概的代碼會類似這樣:
狀態(tài)基類接口:
StateBase
{
void Enter() = 0;
void Leave() = 0;
void Process(Message* msg) = 0;
};
狀態(tài)機基類接口:
MachineBase
{
void ChangeState(StateBase* state) = 0;
StateBase* m_curState;
};
我們的邏輯處理類會從MachineBase派生,當(dāng)取出數(shù)據(jù)包后交給當(dāng)前狀態(tài)處理,前面描述的兩個狀態(tài)類從StateBase派生,每個狀態(tài)類只處理該狀態(tài)標(biāo)識下需要處理的消息。當(dāng)要進行狀態(tài)轉(zhuǎn)換時,調(diào)用MachineBase的ChangeState()方法,顯示地告訴狀態(tài)機管理類自己要轉(zhuǎn)到哪一個狀態(tài)。所以,狀態(tài)類內(nèi)部需要保存狀態(tài)機管理類的指針,這個可以在狀態(tài)類初始化時傳入。具體的實現(xiàn)細節(jié)就不做過多描述了。
使用狀態(tài)機雖然避免了復(fù)雜的判斷語句,但也引入了新的麻煩。當(dāng)我們在進行狀態(tài)轉(zhuǎn)換時,可能會需要將一些現(xiàn)場數(shù)據(jù)從老狀態(tài)對象轉(zhuǎn)移到新狀態(tài)對象,這需要在定義接口時做一下考慮。如果不希望執(zhí)行拷貝,那么這里公有的現(xiàn)場數(shù)據(jù)也可放到狀態(tài)機類中,只是這樣在使用時可能就不那么優(yōu)雅了。 <
BR>
正如同在設(shè)計模式中所描述的,所有的模式都是已有問題的另一種解決方案,也就是說這并不是唯一的解決方案。放到我們今天討論的State模式中,就拿登錄服所處理的兩個狀態(tài)來說,也許用mangos所采用的遍歷處理函數(shù)的方法可能更簡單,但當(dāng)系統(tǒng)中的狀態(tài)數(shù)量增多,狀態(tài)標(biāo)識也變多的時候,State模式就顯得尤其重要了。
比如在游戲服務(wù)器上玩家的狀態(tài)管理,還有在實現(xiàn)NPC人工智能時的各種狀態(tài)管理,這些就留作以后的專題吧。
服務(wù)器公共組件 -- 事件與信號
關(guān)于這一節(jié),這幾天已經(jīng)打了好幾遍草稿,總覺得說不清楚,也不好組織這些內(nèi)容,但是打鐵要趁熱,為避免熱情消退,先整理一點東西放這,好繼續(xù)下面的主題,以后如果有機會再回來完善吧。本節(jié)內(nèi)容欠考慮,希望大家多給點意見。
有些類似于QT中的event與signal,我將一些動作請求消息定義為事件,而將狀態(tài)改變消息定義為信號。比如在QT應(yīng)用程序中,用戶的一次鼠標(biāo)點擊會產(chǎn)生一個鼠標(biāo)點擊事件加入到事件隊列中,當(dāng)處理此事件時可能會導(dǎo)致某個按鈕控件產(chǎn)生一個clicked()信號。
對應(yīng)到我們的服務(wù)器上的一個例子,玩家登錄時會發(fā)給服務(wù)器一個請求登錄的數(shù)據(jù)包,服務(wù)器可將其當(dāng)作一個用戶登錄事件,該事件處理完后可能會產(chǎn)生一個用戶已登錄信號。
這樣,與QT類似,對于事件我們可以重定義其處理方法,甚至過濾掉某些事件使其不被處理,但對于信號我們只是收到了一個通知,有些類似于Observe模式中的觀察者,當(dāng)收到更新通知時,我們只能更新自己的狀態(tài),對剛剛發(fā)生的事件我不已不能做任何影響。
仔細來看,事件與信號其實并無多大差別,從我們對其需求上來說,都只要能注冊事件或信號響應(yīng)函數(shù),在事件或信號產(chǎn)生時能夠被通知到即可。但有一項區(qū)別在于,事件處理函數(shù)的返回值是有意義的,我們要根據(jù)這個返回值來確定是否還要繼續(xù)事件的處理,比如在QT中,事件處理函數(shù)如果返回true,則這個事件處理已完成,QApplication會接著處理下一個事件,而如果返回false,那么事件分派函數(shù)會繼續(xù)向上尋找下一個可以處理該事件的注冊方法。信號處理函數(shù)的返回值對信號分派器來說是無意義的。
簡單點說,就是我們可以為事件定義過濾器,使得事件可以被過濾。這一功能需求在游戲服務(wù)器上是到處存在的。
關(guān)于事件和信號機制的實現(xiàn),網(wǎng)絡(luò)上的開源訓(xùn)也比較多,比如FastDelegate,sigslot,boost::signal等,其中sigslot還被Google采用,在libjingle的代碼中我們可以看到他是如何被使用的。
在實現(xiàn)事件和信號機制時或許可以考慮用同一套實現(xiàn),在前面我們就分析過,兩者唯一的區(qū)別僅在于返回值的處理上。
另外還有一個需要我們關(guān)注的問題是事件和信號處理時的優(yōu)先級問題。在QT中,事件因為都是與窗口相關(guān)的,所以事件回調(diào)時都是從當(dāng)前窗口開始,一級一級向上派發(fā),直到有一個窗口返回true,截斷了事件的處理為止。對于信號的處理則比較簡單,默認是沒有順序的,如果需要明確的順序,可以在信號注冊時顯示地指明槽的位置。
在我們的需求中,因為沒有窗口的概念,事件的處理也與信號類似,對注冊過的處理器要按某個順序依次回調(diào),所以優(yōu)先級的設(shè)置功能是需要的。
最后需要我們考慮的是事件和信號的處理方式。在QT中,事件使用了一個事件隊列來維護,如果事件的處理中又產(chǎn)生了新的事件,那么新的事件會加入到隊列尾,直到當(dāng)前事件處理完畢后,QApplication再去隊列頭取下一個事件來處理。而信號的處理方式有些不同,信號處理是立即回調(diào)的,也就是一個信號產(chǎn)生后,他上面所注冊的所有槽都會立即被回調(diào)。這樣就會產(chǎn)生一個遞歸調(diào)用的問題,比如某個信號處理器中又產(chǎn)生了一個信號,會使得信號的處理像一棵樹一樣的展開。我們需要注意的一個很重要的問題是會不會引起循環(huán)調(diào)用。
關(guān)于事件機制的考慮其實還很多,但都是一些不成熟的想法。在上面的文字中就同時出現(xiàn)了消息、事件和信號三個相近的概念,而在實際處理中,經(jīng)常發(fā)現(xiàn)三者不知道如何界定的情況,實際的情況比我在這里描述的要混亂的多。
這里也就當(dāng)是挖下一個坑,希望能夠有所交流。
再談登錄服的實現(xiàn)
離我們的登錄服實現(xiàn)已經(jīng)太遠了,先拉回來一下。
關(guān)于登錄服、大區(qū)服及游戲世界服的結(jié)構(gòu)之前已做過探討,這里再把各自的職責(zé)和關(guān)系列一下。
GateWay/WorldServer GateWay/WodlServer LoginServer LoginServer DNSServer WorldServerMgr
| | | | |
---------------------------------------------------------------------------------------------
| | |
internet
|
clients
其中DNSServer負責(zé)帶負載均衡的域名解析服務(wù),返回LoginServer的IP地址給客戶端。WorldServerMgr維護當(dāng)前大區(qū)內(nèi)的世界服列表,LoginServer會從這里取世界列表發(fā)給客戶端。LoginServer處理玩家的登錄及世界服選擇請求。GateWay/WorldServer為各個獨立的世界服或者通過網(wǎng)關(guān)連接到后面的世界服。
在mangos的代碼中,我們注意到登錄服是從數(shù)據(jù)庫中取的世界列表,而在wow官方服務(wù)器中,我們卻會注意到,這個世界服列表并不是一開始就固定,而是動態(tài)生成的。當(dāng)每周一次的維護完成之后,我們可以很明顯的看到這個列表生成的過程。剛開始時,世界列表是空的,慢慢的,世界服會一個個加入進來,而這里如果有世界服當(dāng)機,他會顯示為離線,不會從列表中刪除。但是當(dāng)下一次服務(wù)器再維護后,所有的世界服都不存在了,全部重新開始添加。
從上面的過程描述中,我們很容易想到利用一個臨時的列表來保存世界服信息,這也是我們增加WorldServerMgr服務(wù)器的目的所在。GateWay/WorldServer在啟動時會自動向WorldServerMgr注冊自己,這樣就把自己所代表的游戲世界添加到世界列表中了。類似的,如果DNSServer也可以讓LoginServer自己去注冊,這樣在臨時LoginServer時就不需要去改動DNSServer的配置文件了。
WorldServerMgr內(nèi)部的實現(xiàn)很簡單,監(jiān)聽一個固定的端口,接受來自WorldServer的主動連接,并檢測其狀態(tài)。這里可以用一個心跳包來實現(xiàn)其狀態(tài)的檢測,如果WorldServer的連接斷開或者在規(guī)定時間內(nèi)未收到心跳包,則將其狀態(tài)更新為離線。另外WorldServerMgr還處理來自LoginServer的列表請求。由于世界列表并不常變化,所以LoginServer沒有必要每次發(fā)送世界列表時都到WorldServerMgr上去取,LoginServer完全可以自己維護一個列表,當(dāng)WorldServerMgr上的列表發(fā)生變化時,WorldServerMgr會主動通知所有的LoginServer也更新一下自己的列表。這個或許就可以用前面描述過的事件方式,或者就是觀察者模式了。
WorldServerMgr實現(xiàn)所要考慮的內(nèi)容就這些,我們再來看看LoginServer,這才是我們今天要重點討論的對象。
前面探討一些服務(wù)器公共組件,那我們這里也應(yīng)該試用一下,不能只是停留在理論上。先從狀態(tài)機開始,前面也說過了,登錄服上的連接會有兩種狀態(tài),一是帳號密碼驗證狀態(tài),一是服務(wù)器列表選擇狀態(tài),其實還有另外一個狀態(tài)我們未曾討論過,因為它與我們的登錄過程并無多大關(guān)系,這就是升級包發(fā)送狀態(tài)。三個狀態(tài)的轉(zhuǎn)換流程大致為:
LogonState -- 驗證成功 -- 版本檢查 -- 版本低于最新值 -- 轉(zhuǎn)到UpdateState
|
-- 版本等于最新值 -- 轉(zhuǎn)到WorldState
這個版本檢查的和決定下一個狀態(tài)的過程是在LogonState中進行的,下一個狀態(tài)的選擇是由當(dāng)前狀態(tài)來決定。密碼驗證的過程使用了SRP6協(xié)議,具體過程就不多做描述,每個游戲使用的方式也都不大一樣。而版本檢查的過程就更無值得探討的東西,一個if-else即可。
升級狀態(tài)其實就是文件傳輸過程,文件發(fā)送完畢后通知客戶端開始執(zhí)行升級文件并關(guān)閉連接。世界選擇狀態(tài)則提供了一個列表給客戶端,其中包括了所有游戲世界網(wǎng)關(guān)服務(wù)器的IP、PORT和當(dāng)前負載情況。如果客戶端一直連接著,則該狀態(tài)會以每5秒一次的頻率不停刷新列表給客戶端,當(dāng)然是否值得這樣做還是有待商榷。
整個過程似乎都沒有值得探討的內(nèi)容,但是,還沒有完。當(dāng)客戶端選擇了一個世界之后該怎么辦?wow的做法是,當(dāng)客戶端選擇一個游戲世界時,客戶端
會主動去連接該世界服的IP和PORT,然后進入這個游戲世界。與此同時,與登錄服的連接還沒有斷開,直到客戶端確實連接上了選定的世界服并且走完了排隊過程為止。這是一個很必要的設(shè)計,保證了我們在因意外情況連接不上世界服或者發(fā)現(xiàn)世界服正在排隊而想換另外一個試試時不會需要重新進行密碼驗證。
但是我們所要關(guān)注的還不是這些,而是客戶端去連接游戲世界的網(wǎng)關(guān)服時服務(wù)器該如何識別我們。打個比方,有個不自覺的玩家不遵守游戲規(guī)則,沒有去驗證帳號密碼就直接跑去連接世界服了,就如同一個不自覺的乘客沒有換登機牌就直接跑到登機口一樣。這時,乘務(wù)員會客氣地告訴你要先換登機牌,那登機牌又從哪來?檢票口換的,人家會先驗明你的身份,確認后才會發(fā)給你登機牌。一樣的處理過程,我們的登錄服在驗明客戶端身份后,也會發(fā)給客戶端一個登機牌,這個登機牌還有一個學(xué)名,叫做session key。
客戶端拿著這個session key去世界服網(wǎng)關(guān)處就可正確登錄了嗎?似乎還是有個疑問,他怎么知道我這個key是不是造假的?沒辦法,中國的假貨太多,我們不得不到處都考慮假貨的問題。方法很簡單,去找給他登機牌的那個檢票員問一下,這張牌是不是他發(fā)的不就得了??墒?,那么多的LoginServer,要一個個問下來,這效率也太低了,后面排的長隊一定會開始叫喚了。那么,LoginServer將這個key存到數(shù)據(jù)庫中,讓網(wǎng)關(guān)服自己去數(shù)據(jù)庫驗證?似乎也是個可行的方案。
如果覺得這樣給數(shù)據(jù)庫帶來了太大的壓力的話,也可以考慮類似WorldServerMgr的做法,用一個臨時的列表來保存,甚至可以將這個列表就保存到WorldServerMgr上,他正好是全區(qū)唯一的。這兩種方案的本質(zhì)并無差別,只是看你愿意將負載放在哪里。而不管在哪里,這個查詢的壓力都是有點大的,想想,全區(qū)所有玩家呢。所以,我們也可以試著考慮一種新的方案,一種不需要去全區(qū)唯一一個入口查詢的方案。
那我們將這些session key分開存儲不就得了。一個可行的方案是,讓任意時刻只有一個地方保存一個客戶端的session key,這個地方可能是客戶端當(dāng)前正連接著的服務(wù)器,也可以是它正要去連接的服務(wù)器。讓我們來詳細描述一下這個過程,客戶端在LoginServer上驗證通過時,LoginServer為其生成了本次會話的session key,但只是保存在當(dāng)前的LoginServer上,不會存數(shù)據(jù)庫,也不會發(fā)送給WorldServerMgr。如果客戶端這時想要去某個游戲世界,那么他必須先通知當(dāng)前連接的LoginServer要去的服務(wù)器地址,LoginServer將session key安全轉(zhuǎn)移給目標(biāo)服務(wù)器,轉(zhuǎn)移的意思是要確保目標(biāo)服務(wù)器收到了session key,本地保存的要刪除掉。轉(zhuǎn)移成功后LoginServer通知客戶端再去連接目標(biāo)服務(wù)器,這時目標(biāo)服務(wù)器在驗證session key合法性的時候就不需要去別處查詢了,只在本地保存的session key列表中查詢即可。
當(dāng)然了,為了session key的安全,所有的服務(wù)器在收到一個新的session key后都會為其設(shè)一個有效期,在有效期過后還沒來認證的,則該session key會被自動刪除。同時,所有服務(wù)器上的session key在連接關(guān)閉后一定會被刪除,保證一個session key真正只為一次連接會話服務(wù)。
但是,很顯然的,wow并沒有采用這種方案,因為客戶端在選擇世界服時并沒有向服務(wù)器發(fā)送要求確認的消息。wow中的session key應(yīng)該是保存在一個類似于WorldServerMgr的地方,或者如mangos一樣,就是保存在了數(shù)據(jù)庫中。不管是怎樣一種方式,了解了其過程,代碼實現(xiàn)都是比較簡單的,我們就不再贅述了。