1. 程式人生 > >百萬使用者級遊戲伺服器架構設計(二)

百萬使用者級遊戲伺服器架構設計(二)

登入服的設計 -- 功能需求

  正如我們在前面曾討論過的,登入服要實現的功能相當簡單,就是帳號驗證。為了便於描述,我們暫不引入那些討論過的優化手段,先以最簡單的方式實現,另外也將基本以mangos的程式碼作為參考來進行描述。

  想象一下帳號驗證的實現方法,最容易的那就是把使用者輸入的明文用帳號和密碼直接發給登入服,伺服器根據帳號從資料庫中取出密碼,與使用者輸入的密碼相比較。

  這個方法存在的安全隱患實在太大,明文的密碼傳輸太容易被截獲了。那我們試著在傳輸之前先加一下密,為了伺服器能進行密碼比較,我們應該採用一個可逆的加密演算法,在伺服器端把這個加密後的字串還原為原始的明文密碼,然後與資料庫密碼進行比較。既然是一個可逆的過程,那外掛製作者總有辦法知道我們的加密過程,所以,這個方法仍不夠安全。

  哦,如果我們只是希望密碼不可能被還原出來,那還不容易嗎,使用一個不可逆的雜湊演算法就行了。使用者在登入時傳送給伺服器的是明文的帳號和經雜湊後的不可逆密碼串,伺服器取出密碼後也用同樣的演算法進行雜湊後再進行比較。比如,我們就用使用最廣泛的md5演算法吧。噢,不要管那個王小云的什麼論文,如果我真有那麼好的運氣,早中500w了,還用在這考慮該死的伺服器設計嗎?

  似乎是一個很完美的方案,外掛製作者再也偷不到我們的密碼了。慢著,外掛偷密碼的目的是什麼?是為了能用我們的帳號進遊戲!如果我們總是用一種固定的演算法來對密碼做雜湊,那外掛只需要記住這個雜湊後的字串就行了,用這個做密碼就可以成功登入。

  嗯,這個問題好解決,我們不要用固定的演算法進行雜湊就是了。只是,問題在於伺服器與客戶端採用的雜湊演算法得出的字串必須是相同的,或者是可驗證其是否匹配的。很幸運的是,偉大的數學字們早就為我們準備好了很多優秀的這類演算法,而且經理論和實踐都證明他們也確實是足夠安全的。

  這其中之一是一個叫做SRP的演算法,全稱叫做Secure Remote Password,即安全遠端密碼。wow使用的是第6版,也就是SRP6演算法。有關其中的數學證明,如果有人能向我解釋清楚,並能讓我真正弄明白的話,我將非常感激。不過其程式碼實現步驟倒是並不複雜,mangos中的程式碼也還算清晰,我們也不再贅述。

  登入服除了帳號驗證外還得提供另一項功能,就是在玩家的帳號驗證成功後返回給他一個伺服器列表讓他去選擇。這個列表的狀態要定時重新整理,可能有新的遊戲世界開放了,也可能有些遊戲世界非常不幸地停止運轉了,這些狀態的變化都要儘可能及時地讓玩家知道。不管發生了什麼事,使用者都有權利知道,特別是對於付過費的使用者來說,我們不該藏著掖著,不是嗎?

  這個遊戲世界列表的功能將由大區服來提供,具體的結構我們在之前也描述過,這裡暫不做討論。登入服將從大區服上獲取到的遊戲世界列表發給已驗證通過的客戶端即可。好了,登入服要實現的功能就這些,很簡單,是吧。

  確實是太簡單了,不過簡單的結構正好更適合我們來看一看遊戲伺服器內部的模組結構,以及一些伺服器共有元件的實現方法。這就留作下一篇吧。

伺服器公共元件實現 -- mangos的遊戲主迴圈

  當閱讀一項工程的原始碼時,我們大概會選擇從main函式開始,而當開始一項新的工程時,第一個寫下的函式大多也是main。那我們就先來看看,遊戲伺服器程式碼實現中,main函式都做了些什麼。

  由於我在讀技術文章時最不喜看到的就是大段大段的程式碼,特別是那些直接Ctrl+C再Ctrl+V後未做任何修改的程式碼,用句時髦的話說,一點技術含量都沒有!所以在我們今後所要討論的內容中,儘量會避免出現直接的程式碼,在有些地方確實需要程式碼來表述時,也將會選擇使用偽碼。

  先從mangos的登入服程式碼開始。mangos的登入服是一個單執行緒的結構,雖然在資料庫連線中可以開啟一個獨立的執行緒,但這個執行緒也只是對無返回結果的執行類SQL做緩衝,而對需要有返回結果的查詢類SQL還是在主邏輯執行緒中阻塞呼叫的。

  登入服中唯一的這一個執行緒,也就是主迴圈執行緒對監聽的socket做select操作,為每個連線進來的客戶端讀取其上的資料並立即進行處理,直到伺服器收到SIGABRT或SIGBREAK訊號時結束。

  所以,mangos登入服主迴圈的邏輯,也包括後面遊戲服的邏輯,主迴圈的關鍵程式碼其實是在SocketHandler中,也就是那個Select函式中。檢查所有的連線,對新到來的連線呼叫OnAccept方法,有資料到來的連線則呼叫OnRead方法,然後socket處理器自己定義對接收到的資料如何處理。

  很簡單的結構,也比較容易理解。


  只是,在對效能要求比較高的伺服器上,select一般不會是最好的選擇。如果我們使用windows平臺,那IOCP將是首選;如果是linux,epool將是不二選擇。我們也不打算討論基於IOCP或是基於epool的伺服器實現,如果僅僅只是要實現伺服器功能,很簡單的幾個API呼叫即可,而且網上已有很多好的教程;如果是要做一個成熟的網路伺服器產品,不是我幾篇簡單的技術介紹文章所能達到。

  另外,在伺服器實現上,網路IO與邏輯處理一般會放在不同的執行緒中,以免耗時較長的IO過程阻塞住了需要立即反應的遊戲邏輯。

  資料庫的處理也類似,會使用非同步的方式,也是避免耗時的查詢過程將遊戲伺服器主迴圈阻塞住。想象一下,因某個玩家上線而發起的一次資料庫查詢操作導致伺服器內所有線上玩家都卡住不動將是多麼恐怖的一件事!

  另外還有一些如事件、指令碼、訊息佇列、狀態機、日誌和異常處理等公共元件,我們也會在接下來的時間裡進行探討。

伺服器公共元件實現 -- 繼續來說主迴圈

  前面我們只簡單瞭解了下mangos登入服的程式結構,也發現了一些不足之處,現在我們就來看看如何提供一個更好的方案。

  正如我們曾討論過的,為了遊戲主邏輯迴圈的流暢執行,所有比較耗時的IO操作都會分享到單獨的執行緒中去做,如網路IO,資料庫IO和日誌IO等。當然,也有把這些分享到單獨的程序中去做的。

  另外對於大多數伺服器程式來說,在執行時都是作為精靈程序或服務程序的,所以我們並不需要伺服器能夠處理控制檯使用者輸入,我們所要處理的資料來源都來自網路。

  這樣,主邏輯迴圈所要做的就是不停要取訊息包來處理,當然這些訊息包不僅有來自客戶端的玩家操作資料包,也有來自GM伺服器的管理命令,還包括來自資料庫查詢執行緒的返回結果訊息包。這個迴圈將一直持續,直到收到一個通知伺服器關閉的訊息包。

  主邏輯迴圈的結構還是很簡單的,複雜的部分都在如何處理這些訊息包的邏輯上。我們可以用一段簡單的偽碼來描述這個迴圈過程:

while (Message* msg = getMessage()) 
{ 
    if (msg為伺服器關閉訊息) 
    break; 
    處理msg訊息; 
}

  這裡就有一個問題需要探討了,在getMessage()的時候,我們應該去哪裡取訊息?前面我們考慮過,至少會有三個訊息來源,而我們還討論過,這些訊息源的IO操作都是在獨立的執行緒中進行的,我們這裡的主執行緒不應該直接去那幾處訊息源進行阻塞式的IO操作。

  很簡單,讓那些獨立的IO執行緒在接收完資料後自己送過來就是了。好比是,我這裡提供了一個倉庫,有很多的供貨商,他們有貨要給我的時候只需要交到倉庫,然後我再到倉庫去取就是了,這個倉庫也就是訊息佇列。訊息佇列是一個普通的佇列實現,當然必須要提供多執行緒互斥訪問的安全性支援,其基本的介面定義大概類似這樣:

IMessageQueue 
{ 
    void putMessage(Message*);
    Message* getMessage(); 
}

  網路IO,資料庫IO執行緒把整理好的訊息包都加入到主邏輯迴圈執行緒的這個訊息佇列中便返回。有關訊息佇列的實現和執行緒間訊息的傳遞在ACE中有比較完全的程式碼實現及描述,還有一些使用示例,是個很好的參考。

  這樣的話,我們的主迴圈就很清晰了,從主執行緒的訊息佇列中取訊息,處理訊息,再取下一條訊息......

伺服器公共元件實現 -- 訊息佇列

  既然說到了訊息佇列,那我們繼續來稍微多聊一點吧。

  我們所能想到的最簡單的訊息佇列可能就是使用stl的list來實現了,即訊息佇列內部維護一個list和一個互斥鎖,putMessage時將message加入到佇列尾,getMessage時從佇列頭取一個message返回,同時在getMessage和putMessage之前都要求先獲取鎖資源。

  實現雖然簡單,但功能是絕對滿足需求的,只是效能上可能稍稍有些不盡如人意。其最大的問題在頻繁的鎖競爭上。

  對於如何減少鎖競爭次數的優化方案,Ghost Cheng提出了一種。提供一個佇列容器,裡面有多個佇列,每個佇列都可固定存放一定數量的訊息。網路IO執行緒要給邏輯執行緒投遞訊息時,會從佇列容器中取一個空佇列來使用,直到將該佇列填滿後再放回容器中換另一個空佇列。而邏輯執行緒取訊息時是從佇列容器中取一個有訊息的佇列來讀取,處理完後清空佇列再放回到容器中。

  這樣便使得只有在對佇列容器進行操作時才需要加鎖,而IO執行緒和邏輯執行緒在操作自己當前使用的佇列時都不需要加鎖,所以鎖競爭的機會大大減少了。

  這裡為每個佇列設了個最大訊息數,看來好像是打算只有當IO執行緒寫滿佇列時才會將其放回到容器中換另一個佇列。那這樣有時也會出現IO執行緒未寫滿一個佇列,而邏輯執行緒又沒有資料可處理的情況,特別是當資料量很少時可能會很容易出現。Ghost Cheng在他的描述中沒有講到如何解決這種問題,但我們可以先來看看另一個方案。

  這個方案與上一個方案基本類似,只是不再提供佇列容器,因為在這個方案中只使用了兩個佇列,arthur在他的一封郵件中描述了這個方案的實現及部分程式碼。兩個佇列,一個給邏輯執行緒讀,一個給IO執行緒用來寫,當邏輯執行緒讀完佇列後會將自己的佇列與IO執行緒的佇列相調換。所以,這種方案下加鎖的次數會比較多一些,IO執行緒每次寫佇列時都要加鎖,邏輯執行緒在調換佇列時也需要加鎖,但邏輯執行緒在讀佇列時是不需要加鎖的。

  雖然看起來鎖的呼叫次數是比前一種方案要多很多,但實際上大部分鎖呼叫都是不會引起阻塞的,只有在邏輯執行緒調換佇列的那一瞬間可能會使得某個執行緒阻塞一下。另外對於鎖呼叫過程本身來說,其開銷是完全可以忽略的,我們所不能忍受的僅僅是因為鎖呼叫而引起的阻塞而已。

  兩種方案都是很優秀的優化方案,但也都是有其適用範圍的。Ghost Cheng的方案因為提供了多個佇列,可以使得多個IO執行緒可以總工程師的,互不干擾的使用自己的佇列,只是還有一個遺留問題我們還不瞭解其解決方法。arthur的方案很好的解決了上一個方案遺留的問題,但因為只有一個寫佇列,所以當想要提供多個IO執行緒時,執行緒間互斥地寫入資料可能會增大競爭的機會,當然,如果只有一個IO執行緒那將是非常完美的。

伺服器公共元件實現 -- 環形緩衝區

  訊息佇列鎖呼叫太頻繁的問題算是解決了,另一個讓人有些苦惱的大概是這太多的記憶體分配和釋放操作了。頻繁的記憶體分配不但增加了系統開銷,更使得記憶體碎片不斷增多,非常不利於我們的伺服器長期穩定執行。也許我們可以使用記憶體池,比如SGI STL中附帶的小記憶體分配器。但是對於這種按照嚴格的先進先出順序處理的,塊大小並不算小的,而且塊大小也並不統一的記憶體分配情況來說,更多使用的是一種叫做環形緩衝區的方案,mangos的網路程式碼中也有這麼一個東西,其原理也是比較簡單的。

  就好比兩個人圍著一張圓形的桌子在追逐,跑的人被網路IO執行緒所控制,當寫入資料時,這個人就往前跑;追的人就是邏輯執行緒,會一直往前追直到追上跑的人。如果追上了怎麼辦?那就是沒有資料可讀了,先等會兒唄,等跑的人向前跑幾步了再追,總不能讓遊戲沒得玩了吧。那要是追的人跑的太慢,跑的人轉了一圈過來反追上追的人了呢?那您也先歇會兒吧。要是一直這麼反著追,估計您就只能換一個跑的更快的追逐者了,要不這遊戲還真沒法玩下去。

  前面我們特別強調了,按照嚴格的先進先出順序進行處理,這是環形緩衝區的使用必須遵守的一項要求。也就是,大家都得遵守規定,追的人不能從桌子上跨過去,跑的人當然也不允許反過來跑。至於為什麼,不需要多做解釋了吧。

  環形緩衝區是一項很好的技術,不用頻繁的分配記憶體,而且在大多數情況下,記憶體的反覆使用也使得我們能用更少的記憶體塊做更多的事。

  在網路IO執行緒中,我們會為每一個連線都準備一個環形緩衝區,用於臨時存放接收到的資料,以應付半包及粘包的情況。在解包及解密完成後,我們會將這個資料包複製到邏輯執行緒訊息佇列中,如果我們只使用一個佇列,那這裡也將會是個環形緩衝區,IO執行緒往裡寫,邏輯執行緒在後面讀,互相追逐。可要是我們使用了前面介紹的優化方案後,可能這裡便不再需要環形緩衝區了,至少我們並不再需要他們是環形的了。因為我們對同一個佇列不再會出現同時讀和寫的情況,每個佇列在寫滿後交給邏輯執行緒去讀,邏輯執行緒讀完後清空佇列再交給IO執行緒去寫,一段固定大小的緩衝區即可。沒關係,這麼好的技術,在別的地方一定也會用到的。

伺服器公共元件實現 -- 發包的方式

  前面一直都在說接收資料時的處理方法,我們應該用專門的IO執行緒,接收到完整的訊息包後加入到主執行緒的訊息佇列,但是主執行緒如何傳送資料還沒有探討過。

  一般來說最直接的方法就是邏輯執行緒什麼時候想發資料了就直接呼叫相關的socket API傳送,這要求伺服器的玩家物件中儲存其連線的socket控制代碼。但是直接send呼叫有時候有會存在一些問題,比如遇到系統的傳送緩衝區滿而阻塞住的情況,或者只發送了一部分資料的情況也時有發生。我們可以將要傳送的資料先快取一下,這樣遇到未傳送完的,在邏輯執行緒的下一次處理時可以接著再發送。

  考慮資料快取的話,那這裡這可以有兩種實現方式了,一是為每個玩家準備一個緩衝區,另外就是隻有一個全域性的緩衝區,要傳送的資料加入到全域性緩衝區的時候同時要指明這個資料是發到哪個socket的。如果使用全域性緩衝區的話,那我們可以再進一步,使用一個獨立的執行緒來處理資料傳送,類似於邏輯執行緒對資料的處理方式,這個獨立傳送執行緒也維護一個訊息佇列,邏輯執行緒要發資料時也只是把資料加入到這個佇列中,傳送執行緒迴圈取包來執行send呼叫,這時的阻塞也就不會對邏輯執行緒有任何影響了。

  採用第二種方式還可以附帶一個優化方案。一般對於廣播訊息而言,傳送給周圍玩家的資料都是完全相同的,我們如果採用給每個玩家一個緩衝佇列的方式,這個資料包將需要拷貝多份,而採用一個全域性傳送佇列時,我們只需要把這個訊息入隊一次,同時指明該訊息包是要傳送給哪些socket的即可。有關該優化的說明在雲風描述其連線伺服器實現的blog文章中也有講到,有興趣的可以去閱讀一下。

伺服器公共元件實現 -- 狀態機

  有關State模式的設計意圖及實現就不從設計模式中摘抄了,我們只來看看遊戲伺服器程式設計中如何使用State設計模式。

  首先還是從mangos的程式碼開始看起,我們注意到登入服在處理客戶端發來的訊息時用到了這樣一個結構體:

struct AuthHandler 
{ 
    eAuthCmd cmd; 
    uint32 status; 
    bool (AuthSocket::*handler)(void); 
};

  該結構體定義了每個訊息碼的處理函式及需要的狀態標識,只有當前狀態滿足要求時才會呼叫指定的處理函式,否則這個訊息碼的出現是不合法的。這個status狀態標識的定義是一個巨集,有兩種有效的標識,STATUS_CONNECTED和STATUS_AUTHED,也就是未認證通過和已認證通過。而這個狀態標識的改變是在執行時進行的,確切的說是在收到某個訊息並正確處理完後改變的。

  我們再來看看設計模式中對State模式的說明,其中關於State模式適用情況裡有一條,當操作中含有龐大的多分支的條件語句,且這些分支依賴於該物件的狀態,這個狀態通常用一個或多個列舉變量表示。

  描述的情況與我們這裡所要處理的情況是如此的相似,也許我們可以試一試。那再看看State模式提供的解決方案是怎樣的,State模式將每一個條件分支放入一個獨立的類中。

  由於這裡的兩個狀態標識只區分出了兩種狀態,所以,我們僅需要兩個獨立的類,用以表示兩種狀態即可。然後,按照State模式的描述,我們還需要一個Context類,也就是狀態機管理類,用以管理當前的狀態類。稍作整理,大概的程式碼會類似這樣:

  狀態基類介面: 

StateBase 
{ 
  void Enter() = 0; 
  void Leave() = 0; 
  void Process(Message* msg) = 0; 
};
//狀態機基類介面: 
MachineBase 
{ 
  void ChangeState(StateBase* state) = 0;
  StateBase* m_curState; 
};

  我們的邏輯處理類會從MachineBase派生,當取出資料包後交給當前狀態處理,前面描述的兩個狀態類從StateBase派生,每個狀態類只處理該狀態標識下需要處理的訊息。當要進行狀態轉換時,呼叫MachineBase的ChangeState()方法,顯示地告訴狀態機管理類自己要轉到哪一個狀態。所以,狀態類內部需要儲存狀態機管理類的指標,這個可以在狀態類初始化時傳入。具體的實現細節就不做過多描述了。

  使用狀態機雖然避免了複雜的判斷語句,但也引入了新的麻煩。當我們在進行狀態轉換時,可能會需要將一些現場資料從老狀態物件轉移到新狀態物件,這需要在定義介面時做一下考慮。如果不希望執行拷貝,那麼這裡公有的現場資料也可放到狀態機類中,只是這樣在使用時可能就不那麼優雅了。

  正如同在設計模式中所描述的,所有的模式都是已有問題的另一種解決方案,也就是說這並不是唯一的解決方案。放到我們今天討論的State模式中,就拿登入服所處理的兩個狀態來說,也許用mangos所採用的遍歷處理函式的方法可能更簡單,但當系統中的狀態數量增多,狀態標識也變多的時候,State模式就顯得尤其重要了。

  比如在遊戲伺服器上玩家的狀態管理,還有在實現NPC人工智慧時的各種狀態管理,這些就留作以後的專題吧。

伺服器公共元件 -- 事件與訊號

關於這一節,這幾天已經打了好幾遍草稿,總覺得說不清楚,也不好組織這些內容,但是打鐵要趁熱,為避免熱情消退,先整理一點東西放這,好繼續下面的主題,以後如果有機會再回來完善吧。本節內容欠考慮,希望大家多給點意見。

有些類似於QT中的event與signal,我將一些動作請求訊息定義為事件,而將狀態改變訊息定義為訊號。比如在QT應用程式中,使用者的一次滑鼠點選會產生一個滑鼠點選事件加入到事件佇列中,當處理此事件時可能會導致某個按鈕控制元件產生一個clicked()訊號。

對應到我們的伺服器上的一個例子,玩家登入時會發給伺服器一個請求登入的資料包,伺服器可將其當作一個使用者登入事件,該事件處理完後可能會產生一個使用者已登入訊號。

這樣,與QT類似,對於事件我們可以重定義其處理方法,甚至過濾掉某些事件使其不被處理,但對於訊號我們只是收到了一個通知,有些類似於Observe模式中的觀察者,當收到更新通知時,我們只能更新自己的狀態,對剛剛發生的事件我不已不能做任何影響。

仔細來看,事件與訊號其實並無多大差別,從我們對其需求上來說,都只要能註冊事件或訊號響應函式,在事件或訊號產生時能夠被通知到即可。但有一項區別在於,事件處理函式的返回值是有意義的,我們要根據這個返回值來確定是否還要繼續事件的處理,比如在QT中,事件處理函式如果返回true,則這個事件處理已完成,QApplication會接著處理下一個事件,而如果返回false,那麼事件分派函式會繼續向上尋找下一個可以處理該事件的註冊方法。訊號處理函式的返回值對訊號分派器來說是無意義的。

簡單點說,就是我們可以為事件定義過濾器,使得事件可以被過濾。這一功能需求在遊戲伺服器上是到處存在的。

關於事件和訊號機制的實現,網路上的開源訓也比較多,比如FastDelegate,sigslot,boost::signal等,其中sigslot還被Google採用,在libjingle的程式碼中我們可以看到他是如何被使用的。

在實現事件和訊號機制時或許可以考慮用同一套實現,在前面我們就分析過,兩者唯一的區別僅在於返回值的處理上。

另外還有一個需要我們關注的問題是事件和訊號處理時的優先順序問題。在QT中,事件因為都是與視窗相關的,所以事件回撥時都是從當前視窗開始,一級一級向上派發,直到有一個視窗返回true,截斷了事件的處理為止。對於訊號的處理則比較簡單,預設是沒有順序的,如果需要明確的順序,可以在訊號註冊時顯示地指明槽的位置。

在我們的需求中,因為沒有視窗的概念,事件的處理也與訊號類似,對註冊過的處理器要按某個順序依次回撥,所以優先順序的設定功能是需要的。

最後需要我們考慮的是事件和訊號的處理方式。在QT中,事件使用了一個事件佇列來維護,如果事件的處理中又產生了新的事件,那麼新的事件會加入到佇列尾,直到當前事件處理完畢後,QApplication再去佇列頭取下一個事件來處理。而訊號的處理方式有些不同,訊號處理是立即回撥的,也就是一個訊號產生後,他上面所註冊的所有槽都會立即被回撥。這樣就會產生一個遞迴呼叫的問題,比如某個訊號處理器中又產生了一個訊號,會使得訊號的處理像一棵樹一樣的展開。我們需要注意的一個很重要的問題是會不會引起迴圈呼叫。

關於事件機制的考慮其實還很多,但都是一些不成熟的想法。在上面的文字中就同時出現了訊息、事件和訊號三個相近的概念,而在實際處理中,經常發現三者不知道如何界定的情況,實際的情況比我在這裡描述的要混亂的多。

這裡也就當是挖下一個坑,希望能夠有所交流。

再談登入服的實現

    離我們的登入服實現已經太遠了,先拉回來一下。 
    
    關於登入服、大區服及遊戲世界服的結構之前已做過探討,這裡再把各自的職責和關係列一下。

        GateWay/WorldServer   GateWay/WodlServer LoginServer LoginServer DNSServer WorldServerMgr 
                |                     |                     |                 |            | 
      --------------------------------------------------------------------------------------------- 
                                             | | | 
                                             internet 
                                                | 
                                              clients

    其中DNSServer負責帶負載均衡的域名解析服務,返回LoginServer的IP地址給客戶端。WorldServerMgr維護當前大區內的世界服列表,LoginServer會從這裡取世界列表發給客戶端。LoginServer處理玩家的登入及世界服選擇請求。GateWay/WorldServer為各個獨立的世界服或者通過閘道器連線到後面的世界服。

    在mangos的程式碼中,我們注意到登入服是從資料庫中取的世界列表,而在wow官方伺服器中,我們卻會注意到,這個世界服列表並不是一開始就固定,而是動態生成的。當每週一次的維護完成之後,我們可以很明顯的看到這個列表生成的過程。剛開始時,世界列表是空的,慢慢的,世界服會一個個加入進來,而這裡如果有世界服當機,他會顯示為離線,不會從列表中刪除。但是當下一次伺服器再維護後,所有的世界服都不存在了,全部重新開始新增。

    從上面的過程描述中,我們很容易想到利用一個臨時的列表來儲存世界服資訊,這也是我們增加WorldServerMgr伺服器的目的所在。GateWay/WorldServer在啟動時會自動向WorldServerMgr註冊自己,這樣就把自己所代表的遊戲世界新增到世界列表中了。類似的,如果DNSServer也可以讓LoginServer自己去註冊,這樣在臨時LoginServer時就不需要去改動DNSServer的配置檔案了。

    WorldServerMgr內部的實現很簡單,監聽一個固定的埠,接受來自WorldServer的主動連線,並檢測其狀態。這裡可以用一個心跳包來實現其狀態的檢測,如果WorldServer的連線斷開或者在規定時間內未收到心跳包,則將其狀態更新為離線。另外WorldServerMgr還處理來自LoginServer的列表請求。由於世界列表並不常變化,所以LoginServer沒有必要每次傳送世界列表時都到WorldServerMgr上去取,LoginServer完全可以自己維護一個列表,當WorldServerMgr上的列表發生變化時,WorldServerMgr會主動通知所有的LoginServer也更新一下自己的列表。這個或許就可以用前面描述過的事件方式,或者就是觀察者模式了。

    WorldServerMgr實現所要考慮的內容就這些,我們再來看看LoginServer,這才是我們今天要重點討論的物件。

    前面探討一些伺服器公共元件,那我們這裡也應該試用一下,不能只是停留在理論上。先從狀態機開始,前面也說過了,登入服上的連線會有兩種狀態,一是帳號密碼驗證狀態,一是伺服器列表選擇狀態,其實還有另外一個狀態我們未曾討論過,因為它與我們的登入過程並無多大關係,這就是升級包傳送狀態。三個狀態的轉換流程大致為:

        LogonState -- 驗證成功 -- 版本檢查 -- 版本低於最新值 -- 轉到UpdateState 
                                          | 
                                           -- 版本等於最新值 -- 轉到WorldState

    這個版本檢查的和決定下一個狀態的過程是在LogonState中進行的,下一個狀態的選擇是由當前狀態來決定。密碼驗證的過程使用了SRP6協議,具體過程就不多做描述,每個遊戲使用的方式也都不大一樣。而版本檢查的過程就更無值得探討的東西,一個if-else即可。

    升級狀態其實就是檔案傳輸過程,檔案傳送完畢後通知客戶端開始執行升級檔案並關閉連線。世界選擇狀態則提供了一個列表給客戶端,其中包括了所有遊戲世界閘道器伺服器的IP、PORT和當前負載情況。如果客戶端一直連線著,則該狀態會以每5秒一次的頻率不停重新整理列表給客戶端,當然是否值得這樣做還是有待商榷。

    整個過程似乎都沒有值得探討的內容,但是,還沒有完。當客戶端選擇了一個世界之後該怎麼辦?wow的做法是,當客戶端選擇一個遊戲世界時,客戶端 
會主動去連線該世界服的IP和PORT,然後進入這個遊戲世界。與此同時,與登入服的連線還沒有斷開,直到客戶端確實連線上了選定的世界服並且走完了排隊過程為止。這是一個很必要的設計,保證了我們在因意外情況連線不上世界服或者發現世界服正在排隊而想換另外一個試試時不會需要重新進行密碼驗證。

    但是我們所要關注的還不是這些,而是客戶端去連線遊戲世界的閘道器服時伺服器該如何識別我們。打個比方,有個不自覺的玩家不遵守遊戲規則,沒有去驗證帳號密碼就直接跑去連線世界服了,就如同一個不自覺的乘客沒有換登機牌就直接跑到登機口一樣。這時,乘務員會客氣地告訴你要先換登機牌,那登機牌又從哪來?檢票口換的,人家會先驗明你的身份,確認後才會發給你登機牌。一樣的處理過程,我們的登入服在驗明客戶端身份後,也會發給客戶端一個登機牌,這個登機牌還有一個學名,叫做session key。

    客戶端拿著這個session key去世界服閘道器處就可正確登入了嗎?似乎還是有個疑問,他怎麼知道我這個key是不是造假的?沒辦法,假貨太多,我們不得不到處都考慮假貨的問題。方法很簡單,去找給他登機牌的那個檢票員問一下,這張牌是不是他發的不就得了。可是,那麼多的LoginServer,要一個個問下來,這效率也太低了,後面排的長隊一定會開始叫喚了。那麼,LoginServer將這個key存到資料庫中,讓閘道器服自己去資料庫驗證?似乎也是個可行的方案。

    如果覺得這樣給資料庫帶來了太大的壓力的話,也可以考慮類似WorldServerMgr的做法,用一個臨時的列表來儲存,甚至可以將這個列表就儲存到WorldServerMgr上,他正好是全區唯一的。這兩種方案的本質並無差別,只是看你願意將負載放在哪裡。而不管在哪裡,這個查詢的壓力都是有點大的,想想,全區所有玩家呢。所以,我們也可以試著考慮一種新的方案,一種不需要去全區唯一一個入口查詢的方案。

    那我們將這些session key分開儲存不就得了。一個可行的方案是,讓任意時刻只有一個地方儲存一個客戶端的session key,這個地方可能是客戶端當前正連線著的伺服器,也可以是它正要去連線的伺服器。讓我們來詳細描述一下這個過程,客戶端在LoginServer上驗證通過時,LoginServer為其生成了本次會話的session key,但只是儲存在當前的LoginServer上,不會存資料庫,也不會發送給WorldServerMgr。如果客戶端這時想要去某個遊戲世界,那麼他必須先通知當前連線的LoginServer要去的伺服器地址,LoginServer將session key安全轉移給目標伺服器,轉移的意思是要確保目標伺服器收到了session key,本地儲存的要刪除掉。轉移成功後LoginServer通知客戶端再去連線目標伺服器,這時目標伺服器在驗證session key合法性的時候就不需要去別處查詢了,只在本地儲存的session key列表中查詢即可。

    當然了,為了session key的安全,所有的伺服器在收到一個新的session key後都會為其設一個有效期,在有效期過後還沒來認證的,則該session key會被自動刪除。同時,所有伺服器上的session key在連線關閉後一定會被刪除,保證一個session key真正只為一次連線會話服務。

    但是,很顯然的,wow並沒有採用這種方案,因為客戶端在選擇世界服時並沒有向伺服器傳送要求確認的訊息。wow中的session key應該是儲存在一個類似於WorldServerMgr的地方,或者如mangos一樣,就是儲存在了資料庫中。不管是怎樣一種方式,瞭解了其過程,程式碼實現都是比較簡單的,我們就不再贅述了。