遊戲服務器結構探討

原文:http://no001.blog.51cto.com/1142339/1346958

有關遊戲開發:遊戲開發始終是個小項目,另外由於開發時間的限制,軟件工程的思想和方法在遊戲開發領域並不怎麼受歡迎。從遊戲開發團隊的規模也能看出,基本上只能算小開發團隊,有些工作室性質的,那就更簡單了。


我所瞭解的早些的開發團隊,其成員間沒有什麼嚴格的分工,大家憑興趣自由選擇一些模塊來負責,完成了再去負責另一模塊,有其他同事的工作需要接手或協助的也會立即轉入。所以遊戲開發人員基本都是多面手,從網絡到數據庫,從遊戲邏輯到圖形圖象,每一項都有所瞭解,並能實際應用。或者說都具有非常強的學習能力,在接手一項新的任務後能在很短的時間內對該領域的技術迅速掌握並消化,而且還能現炒現賣。當然,這也與早期2D遊戲的技術要求相對比較簡單,遊戲邏輯也沒有現在這般複雜有關。而更重要的可能是,都是被逼出來的吧!:)





 

服務器結構探討 -- 最簡單的結構

  所謂服務器結構,也就是如何將服務器各部分合理地安排,以實現最初的功能需求。所以,結構本無所謂正確與錯誤;當然,優秀的結構更有助於系統的搭建,對系統的可擴展性及可維護性也有更大的幫助。

  好的結構不是一蹴而就的,而且每個設計者心中的那把尺都不相同,所以這個優秀結構的定義也就沒有定論。在這裏,我們不打算對現有遊戲結構做評價,而是試着從頭開始搭建一個我們需要的MMOG結構。

  對於一個最簡單的遊戲服務器來說,它只需要能夠接受來自客戶端的連接請求,然後處理客戶端在遊戲世界中的移動及交互,也即遊戲邏輯處理即可。如果我們把這兩項功能集成到一個服務進程中,則最終的結構很簡單:

  client ----- server

  嗯,太簡單了點,這樣也敢叫服務器結構?好吧,現在我們來往裏面稍稍加點東西,讓它看起來更像是服務器結構一些。

   一般來說,我們在接入遊戲服務器的時候都會要提供一個帳號和密碼,驗證通過後才能進入。關於爲什麼要提供用戶名和密碼才能進入的問題我們這裏不打算做過 多討論,雲風曾對此也提出過類似的疑問,並給出了只用一個標識串就能進入的設想,有興趣的可以去看看他們的討論。但不管是採用何種方式進入,照目前看來我 們的服務器起碼得提供一個帳號驗證的功能。

  我們把觀察點先集中在一個大區內。在大多數情況下,一個大區內都會有多組遊戲服,也就是多個 遊戲世界可供選擇。簡單點來實現,我們完全可以拋棄這個大區的概念,認爲一個大區也就是放在同一個機房的多臺服務器組,各服務器組間沒有什麼關係。這樣, 我們可爲每組服務器單獨配備一臺登錄服。最後的結構圖應該像這樣:

  loginServer  gameServer
     |          /
     |        /
     client

  該結構下的玩家操作流程爲,先選擇大區,再選擇大區下的某臺服務器,即某個遊戲世界,點擊進入後開始帳號驗證過程,驗證成功則進入了該遊戲世界。但是,如果玩家想要切換遊戲世界,他只能先退出當前遊戲世界,然後進入新的遊戲世界重新進行帳號驗證。

  早期的遊戲大都採用的是這種結構,有些遊戲在實現時採用了一些技術手段使得在切換遊戲服時不需要再次驗證帳號,但整體結構還是未做改變。

   該結構存在一個服務器資源配置的問題。因爲登錄服處理的邏輯相對來說比較簡單,就是將玩家提交的帳號和密碼送到數據庫進行驗證,和生成會話密鑰發送給遊 戲服和客戶端,操作完成後連接就會立即斷開,而且玩家在以後的遊戲過程中不會再與登錄服打任何交道。這樣處理短連接的過程使得系統在大多數情況下都是比較 空閒的,但是在某些時候,由於請求比較密集,比如開新服的時候,登錄服的負載又會比較大,甚至會處理不過來。

  另外在實際的遊戲運營中,有些遊戲世界很火爆,而有些遊戲世界卻非常冷清,甚至沒有多少人玩的情況也是很常見的。所以,我們能否更合理地配置登錄服資源,使得整個大區內的登錄服可以共享就成了下一步改進的目標。



 

發表於: 2007-9-10 9:07:00  



 

服務器結構探討 -- 登錄服的負載均衡

  回想一下我們在玩wow時的操作流程:運行wow.exe進入遊戲後,首先就會要求我們輸入用戶名和密碼進行驗證,驗證成功後才會出來遊戲世界列表,之後是排隊進入遊戲世界,開始遊戲...

  可以看到跟前面的描述有個很明顯的不同,那就是要先驗證帳號再選擇遊戲世界。這種結構也就使得登錄服不是固定配備給個遊戲世界,而是全區共有的。

   我們可以試着從實際需求的角度來考慮一下這個問題。正如我們之前所描述過的那樣,登錄服在大多數情況下都是比較空閒的,也許我們的一個擁有20個遊戲世 界的大區僅僅使用10臺或更少的登錄服即可滿足需求。而當在開新區的時候,或許要配備40臺登錄服才能應付那如潮水般涌入的玩家登錄請求。所以,登錄服在 設計上應該能滿足這種動態增刪的需求,我們可以在任何時候爲大區增加或減少登錄服的部署。

  當然,在這裏也不會存在要求添加太多登錄服的情況。還是拿開新區的情況來說,即使新增加登錄服滿足了玩家登錄的請求,遊戲世界服的承載能力依然有限,玩家一樣只能在排隊系統中等待,或者是進入到遊戲世界中導致大家都卡。

  另外,當我們在增加或移除登錄服的時候不應該需要對遊戲世界服有所改動,也不會要求重啓世界服,當然也不應該要求客戶端有什麼更新或者修改,一切都是在背後自動完成。

  最後,有關數據持久化的問題也在這裏考慮一下。一般來說,使用現有的商業數據庫系統比自己手工技術先進要明智得多。我們需要持久化的數據有玩家的帳號及密碼,玩家創建的角色相關信息,另外還有一些遊戲世界全局共有數據也需要持久化。

  好了,需求已經提出來了,現在來考慮如何將其實現。

   對於負載均衡來說,已有了成熟的解決方案。一般最常用,也最簡單部署的應該是基於DNS的負載均衡系統了,其通過在DNS中爲一個域名配置多個IP地址 來實現。最新的DNS服務已實現了根據服務器系統狀態來實現的動態負載均衡,也就是實現了真正意義上的負載均衡,這樣也就有效地解決了當某臺登錄服當機 後,DNS服務器不能立即做出反應的問題。當然,如果找不到這樣的解決方案,自己從頭打造一個也並不難。而且,通過DNS來實現的負載均衡已經包含了所做 的修改對登錄服及客戶端的透明。

  而對於數據庫的應用,在這種結構下,登錄服及遊戲世界服都會需要連接數據庫。從數據庫服務器的部署上來說,可以將帳號和角色數據都放在一箇中心數據庫中,也可分爲兩個不同的庫分別來處理,基到從物理上分到兩臺不同的服務器上去也行。

   但是對於不同的遊戲世界來說,其角色及遊戲內數據都是互相獨立的,所以一般情況下也就爲每個遊戲世界單獨配備一臺數據庫服務器,以減輕數據庫的壓力。所 以,整體的服務器結構應該是一個大區有一臺帳號數據庫服務器,所有的登錄服都連接到這裏。而每個遊戲世界都有自己的遊戲數據庫服務器,只允許本遊戲世界內 的服務器連接。

  最後,我們的服務器結構就像這樣:

              大區服務器       
          /     |       /
            /       |       /
           登錄服1  登錄服2   世界服1  世界服2
         /        |        |       |  
          /      |         |        |
          帳號數據庫        DBS     DBS

  這裏既然討論到了大區及帳號數據庫,所以順帶也說一下關於激活大區的概念。wow中一共有八個大區,我們想要進入某個大區遊戲之前,必須到官網上激活這個區,這是爲什麼呢?

   一般來說,在各個大區帳號數據庫之上還有一個總的帳號數據庫,我們可以稱它爲中心數據庫。比如我們在官網上註冊了一個帳號,這時帳號數據是隻保存在中心 數據庫上的。而當我們要到一區去創建角色開始遊戲的時候,在一區的帳號數據庫中並沒有我們的帳號數據,所以,我們必須先到官網上做一次激活操作。這個激活 的過程也就是從中心庫上把我們的帳號數據拷貝到所要到的大區帳號數據庫中。


 

發表於: 2007-9-10 21:49:00  

 

服務器結構探討 -- 簡單的世界服實現

  討論了這麼久我們一直都還沒有進入遊戲世界服務器內部,現在就讓我們來窺探一下里面的結構吧。

  對於現在大多數MMORPG來說,遊戲服務器要處理的基本邏輯有移動、聊天、技能、物品、任務和生物等,另外還有地圖管理與消息廣播來對其他高級功能做支撐。如縱隊、好友、公會、戰場和副本等,這些都是通過基本邏輯功能組合或擴展而成。

  在所有這些基礎邏輯中,與我們要討論的服務器結構關係最緊密的當屬地圖管理方式。決定了地圖的管理方式也就決定了我們的服務器結構,我們仍然先從最簡單的實現方式開始說起。

   回想一下我們曾戰鬥過無數個夜晚的暗黑破壞神,整個暗黑的世界被分爲了若干個獨立的小地圖,當我們在地圖間穿越時,一般都要經過一個叫做傳送門的裝置。 世界中有些地圖間雖然在地理上是直接相連的,但我們發現其遊戲內部的邏輯卻是完全隔離的。可以這樣認爲,一塊地圖就是一個獨立的數據處理單元。

  既然如此,我們就把每塊地圖都當作是一臺獨立的服務器,他提供了在這塊地圖上游戲時的所有邏輯功能,至於內部結構如何劃分我們暫不理會,先把他當作一個黑盒子吧。

   當兩個人合作做一件事時,我們可以以對等的關係相互協商着來做,而且一般也都不會有什麼問題。當人數增加到三個時,我們對等的合作關係可能會有些複雜, 因爲我們每個人都同時要與另兩個人合作協商。正如俗語所說的那樣,三個和尚可能會碰到沒水喝的情況。當人數繼續增加,情況就變得不那麼簡單了,我們得需要 一個管理者來對我們的工作進行分工、協調。遊戲的地圖服務器之間也是這麼回事。

  一般來說,我們的遊戲世界不可能會只有一塊或者兩塊小地圖,那順理成章的,也就需要一個地圖管理者。先稱它爲遊戲世界的中心服務器吧,畢竟是管理者嘛,大家都以它爲中心。

   中心服務器主要維護一張地圖ID到地圖服務器地址的映射表。當我們要進入某張地圖時,會從中心服上取得該地圖的IP和port告訴客戶端,客戶端主動去 連接,這樣進入他想要去的遊戲地圖。在整個遊戲過程中,客戶端始終只會與一臺地圖服務器保持連接,當要切換地圖的時候,在獲取到新地圖的地址後,會先與當 前地圖斷開連接,再進入新的地圖,這樣保證玩家數據在服務器上只有一份。

  我們來看看結構圖是怎樣的:

             中心服務器
          /      /        /
         /        /        /
      登錄服     地圖1    地圖2   地圖n
         /        |         /       /
          /       |        /       /
               客戶端

  很簡單,不是嗎。但是簡單並不表示功能上會有什麼損失,簡單也更不能表示遊戲不能賺錢。早期不少遊戲也確實採用的就是這種簡單結構。


 

發表於: 2007-9-11 22:26:00  

 

服務器結構探討 -- 繼續世界服

  都已經看出來了,這種每切換一次地圖就要重新連接服務器的方式實在是不夠優雅,而且在實際遊戲運營中也發現,地圖切換導致的卡號,複製裝備等問題非常多,這裏完全就是一個事故多發地段,如何避免這種頻繁的連接操作呢?

  最直接的方法就是把那個圖倒轉過來就行了。客戶端只需要連接到中心服上,所有到地圖服務器的數據都由中心服來轉發。很完美的解決方案,不是嗎?

   這種結構在實際的部署中也遇到了一些挑戰。對於一般的MMORPG服務器來說,單臺服務器的承載量平均在2000左右,如果你的服務器很不幸地只能帶 1000人,沒關係,不少遊戲都是如此;如果你的服務器上跑了3000多玩家依然比較流暢,那你可以自豪地告訴你的策劃,多設計些大量消耗服務器資源的玩 法吧,比如大型國戰、公會戰爭等。

  2000人,似乎我們的策劃朋友們不大願意接受這個數字。我們將地圖服務器分開來原來也是想將負載分開,以多帶些客戶端,現在要所有的連接都從中心服上轉發,那連接數又遇到單臺服務器的可最大承載量的瓶頸了。

   這裏有必要再解釋下這個數字。我知道,有人一定會說,才帶2000人,那是你水平不行,我隨便寫個TCP服務器都可帶個五六千連接。問題恰恰在於你是隨 便寫的,而MMORPG的服務器是複雜設計的。如果一個演示socket API用的echo服務器就能滿足MMOG服務器的需求,那寫服務器該是件多麼 愜意的事啊。

  但我們所遇到的事實是,服務器收到一個移動包後,要向周圍所有人廣播,而不是echo服務器那樣簡單的迴應;服務器在收到 一個連接斷開通知時要向很多人通知玩家退出事件,並將該玩家的資料寫入數據庫,而不是echo服務器那樣什麼都不需要做;服務器在收到一個物品使用請求包 後要做一系列的邏輯判斷以檢查玩家有沒有作弊;服務器上還啓動着很多定時器用來更新遊戲世界的各種狀態......

  其實這麼一比較,我們也看出資源消耗的所在了:服務器上大量的複雜的邏輯處理。再回過頭來看看我們想要實現的結構,我們既想要有一個唯一的入口,使得客戶端不用頻繁改變連接,又希望這個唯一入口的負載不會太大,以致於接受不了多少連接。

   仔細看一看這個需求,我們想要的僅僅只是一臺管理連接的服務器,並不打算讓他承擔太多的遊戲邏輯。既然如此,那五六千個連接也還有滿足我們的要求。至少 在現在來說,一個遊戲世界內,也就是一組服務器內同時有五六千個在線的玩家還是件讓人很興奮的事。事實上,在大多數遊戲的大部分時間裏,這個數字也是很讓 人眼紅的。

  什麼?你說夢幻、魔獸還有史先生的那個什麼征途遠不止這麼點人了!噢,我說的是大多數,是大多數,不包括那些明星。你知道大陸現在有多少遊戲在運營嗎?或許你又該說,我們不該在一開始就把自己的目標定的太低!好吧,我們還是先不談這個。

  繼續我們的結構討論。一般來說,我們把這臺負責連接管理的服務器稱爲網關服務器,因爲內部的數據都要通過這個網關才能出去,不過從這臺服務器提供的功能來看,稱其爲反向代理服務器可能更合適。我們也不在這個名字上糾纏了,就按大家通用的叫法,還是稱他爲網關服務器吧。

   網關之後的結構我們依然可以採用之前描述的方案,只是,似乎並沒有必要爲每一個地圖都開一個獨立的監聽端口了。我們可以試着對地圖進行一些劃分,由一個 Master Server來管理一些更小的Zone Server,玩家通過網關連接到Master Server上,而實際與地圖有關的邏輯是分派給 更小的Zone Server去處理。

  最後的結構看起來大概是這樣的:

        Zone Server        Zone Server
                /            /
                 /          /
                Master Server          Master Server
                    /       /                   /
                   /         /                 /
        Gateway Server        /               /
            |        /         /             /
            |         /         /           /
            |               Center Server
            |
            |
           Client

什麼MMORPG的邏輯可以使單臺服務器能上2000?

NPC都是死的?玩家都不幹活????



首先2000這個數字是我統計的一個平均值,統計的依據來自於與其他人的討論和別人提供的數據,另外一些公司提供的運營數據打點折也可以估算個大概。或許是大家都誇大了自己的能力,或公司公開的數據水分超過了我的估計?呵呵

當然,我們的遊戲也沒有單臺2000,但我認爲現在的服務器存在很大的提升餘地

至於你說的NPC是死的,玩家幹不幹活

NPC最消耗系統資源的當屬AI了,特別是一些複雜的尋路,這個完全可以分離到一臺單獨的AI服務器上,不少遊戲都是這麼做的,從一些泄漏出來的服務器端上也可以看到

生物管理,狀態更新和消息廣播在Zone Server上做,其他公共邏輯模塊在Master Server和Center Server上跑,那這樣其實也就沒有一個單臺服務器承載量的概念了,可能要說數據的話還是以一組服務器爲單位比較合適

另外我一直以wow的結構來做參考,希望實現的也跟wow差不多,所以這些數字也可以算做是我的目標吧。




服務器結構探討 -- 最終的結構

  如果我們就此打住,可能馬上就會有人要嗤之以鼻了,就這點古董級的技術也敢出來現。好吧,我們還是把之前留下的問題拿出來解決掉吧。


  一般來說,當某一部分能力達不到我們的要求時,最簡單的解決方法就是在此多投入一點資源。既然想要更多的連接數,那就再加一臺網關服務器吧。新增加了網關服後需要在大區服上做相應的支持,或者再簡單點,有一臺主要的網關服,當其負載較高時,主動將新到達的連接重定向到其他網關服上。

  而對於遊戲服來說,有一臺還是多臺網關服是沒有什麼區別的。每個代表客戶端玩家的對象內部都保留一個代表其連接的對象,消息廣播時要求每個玩家對象使用自己的連接對象發送數據即可,至於連接是在什麼地方,那是完全透明的。當然,這只是一種簡單的實現,也是普通使用的一種方案,如果後期想對消息廣播做一些優化的話,那可能才需要多考慮一下。

  既然說到了優化,我們也稍稍考慮一下現在結構下可能採用的優化方案。

  首先是當前的Zone Server要做的事情太多了,以至於他都處理不了多少連接。這其中最消耗系統資源的當屬生物的AI處理了,尤其是那些複雜的尋路算法,所以我們可以考慮把這部分AI邏輯獨立出來,由一臺單獨的AI服務器來承擔。

  然後,我們可以試着把一些與地圖數據無關的公共邏輯放到Master Server上去實現,這樣Zone Server上只保留了與地圖數據緊密相關的邏輯,如生物管理,玩家移動和狀態更新等。

  還有聊天處理邏輯,這部分與遊戲邏輯沒有任何關聯,我們也完全可以將其獨立出來,放到一臺單獨的聊天服務器上去實現。

  最後是數據庫了,爲了減輕數據庫的壓力,提高數據請求的響應速度,我們可以在數據庫之前建立一個數據庫緩存服務器,將一些常用數據緩存在此,服務器與數據庫的通信都要通過這臺服務器進行代理。緩存的數據會定時的寫入到後臺數據庫中。

  好了,做完這些優化我們的服務器結構大體也就定的差不多了,暫且也不再繼續深入,更細化的內容等到各個部分實現的時候再探討。

  好比我們去看一場晚會,舞臺上演員們按着預定的節目單有序地上演着,但這就是整場晚會的全部嗎?顯然不止,在幕後還有太多太多的人在忙碌着,甚至在晚會前和晚會後都有。我們的遊戲服務器也如此。

  在之前描述的部分就如同舞臺上的演員,是我們能直接看到的,幕後的工作人員我們也來認識一下。

  現實中有警察來維護秩序,遊戲中也如此,這就是我們常說的GM。GM可以採用跟普通玩家一樣的拉入方式來進入遊戲,當然權限會比普通玩家高一些,也可以提供一臺GM服務器專門用來處理GM命令,這樣可以有更高的安全性,GM服一般接在中心服務器上。

  在以時間收費的遊戲中,我們還需要一臺計費的服務器,這臺服務器一般接在網關服務器上,註冊玩家登錄和退出事件以記錄玩家的遊戲時間。

  任何爲用戶提供服務的地方都會有日誌記錄,遊戲服務器當然也不例外。從記錄玩家登錄的時間,地址,機器信息到遊戲過程中的每一項操作都可以作爲日誌記錄下來,以備查錯及數據挖掘用。至於蒐集玩家機器資料所涉及到的法律問題不是我們該考慮的。

  差不多就這麼多了吧,接下來我們會按照這個大致的結構來詳細討論各部分的實現。




服務器結構探討 -- 一點雜談

  再強調一下,服務器結構本無所謂好壞,只有是否適合自己。我們在前面探討了一些在現在的遊戲中見到過的結構,並盡我所知地分析了各自存在的一些問題和可以做的一些改進,希望其中沒有謬誤,如果能給大家也帶來些啓發那自然更好。


  突然發現自己一旦羅嗦起來還真是沒完沒了。接下來先說說我在開發中遇到過的一些困惑和一基礎問題探討吧,這些問題可能有人與我一樣,也曾遇到過,或者正在被困擾中,而所要探討的這些基礎問題向來也是爭論比較多的,我們也不評價其中的好與壞,只做簡單的描述。

  首先是服務器操作系統,linux與windows之爭隨處可見,其實在大多數情況下這不是我們所能決定的,似乎各大公司也基本都有了自己的傳統,如網易的freebsd,騰訊的linux等。如果真有權利去選擇的話,選自己最熟悉的吧。

  決定了OS也就基本上確定了網絡IO模型,windows上的IOCP和linux下的epool,或者直接使用現有的網絡框架,如ACE和asio等,其他還有些商業的網絡庫在國內的使用好像沒有見到,不符合中國國情嘛。:)

  然後是網絡協議的選擇,以前的選擇大多傾向於UDP,爲了可靠傳輸一般自己都會在上面實現一層封裝,而現在更普通的是直接採用本身就很可靠的TCP,或者TCP與UDP的混用。早期選擇UDP的主要原因還是帶寬限制,現在寬帶普通的情況下TCP比UDP多出來的一點點開銷與開發的便利性相比已經不算什麼了。當然,如果已有了成熟的可靠UDP庫,那也可以繼續使用着。

  還有消息包格式的定義,這個曾在雲風的blog上展開過激烈的爭論。消息包格式定義包括三段,包長、消息碼和包體,爭論的焦點在於應該是消息碼在前還是包長在前,我們也把這個當作是信仰問題吧,有興趣的去雲風的blog上看看,論論。

  另外早期有些遊戲的包格式定義是以特殊字符作分隔的,這樣一個好處是其中某個包出現錯誤後我們的遊戲還能繼續。但實際上,我覺得這是完全沒有必要的,真要出現這樣的錯誤,直接斷開這個客戶端的連接可能更安全。而且,以特殊字符做分隔的消息包定義還加大了一點點網絡數據量。

  最後是一個純技術問題,有關socket連接數的最大限制。開始學習網絡編程的時候我犯過這樣的錯誤,以爲port的定義爲unsigned short,所以想當然的認爲服務器的最大連接數爲65535,這會是一個硬性的限制。而實際上,一個socket描述符在windows上的定義是unsigned int,因此要有限制那也是四十多億,放心好了。

  在服務器上port是監聽用的,想象這樣一種情況,web server在80端口上監聽,當一個連接到來時,系統會爲這個連接分配一個socket句柄,同時與其在80端口上進行通訊;當另一個連接到來時,服務器仍然在80端口與之通信,只是分配的socket句柄不一樣。這個socket句柄纔是描述每個連接的唯一標識。按windows網絡編程第二版上的說法,這個上限值配置影響。

  好了,廢話說完了,下一篇,我們開始進入登錄服的設計吧。



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

  正如我們在前面曾討論過的,登錄服要實現的功能相當簡單,就是帳號驗證。爲了便於描述,我們暫不引入那些討論過的優化手段,先以最簡單的方式實現,另外也將基本以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一樣,就是保存在了數據庫中。不管是怎樣一種方式,瞭解了其過程,代碼實現都是比較簡單的,我們就不再贅述了。

    有關登錄服的討論或許該告一段落了吧。



Mangos預編譯頭文件及模塊劃分隨想


  花了幾個小時的時間給MANGOS的幾個工程都加上了預編譯頭文件,編譯速度與以前相比大大提高,不過game工程的編譯速度還是不太理想,裏面的文件包含關係錯綜複雜,再加上大量模板的使用,一個小小的改動都會引起好多文件的重新編譯,實在是影響效率。


  其中game工程生成的庫文件game.lib居然有近四百兆之巨,mangosd和realmd在連接這個庫的時候也要花上好長一段時間。當然,mangos現在的代碼量也確實不少了,這也就要考慮到大的工程項目的源代碼管理及模塊工程劃分,至少,像mangos現在這樣,代碼全部放入game目錄中,並且就做爲一個大的工程的方式,其弊端是已經顯現了。另外還有功能模塊劃分的問題,需要找一個功能的實現時,不知道該到哪塊代碼去找,而要擴展某部分功能時,也是無從下手。

  模塊劃分使用最廣泛也是最容易實現的應該算是按接口編程了,實現的方法不用我多說,程序員都知道。其好處也是顯而易見的,定義了接口之後,接口的實現便可以作爲一個獨立模塊,也就可以單獨爲一個工程了。

  可以拿mangos處理玩家登錄的過程來做個比方,現在mangos的做法是一個很長的順序執行的過程,如果玩家在隊伍中,則向隊友發送上線消息,如果玩家有好友,則向好友發送上線消息,如果玩家有公會,則向會員發送上線消息,等等。這些代碼都是直接調用各功能部分的代碼來實現,如果這裏考慮一下模塊劃分,並定義出相應的接口,那就可以改成調用好友模塊的上線處理接口,調用組隊模塊的上線處理接口,調用會會模塊的上線處理接口,等等。

  有了接口後,這幾個模塊可以在另外的工程中實現,不用再混在game工程中。對象通過定義好的接口來調用,這樣只要沒有改動接口,模塊的實現修改都不會影響到game工程。

  還可以再進一步,在遊戲邏輯的處理上再做一些解耦合。還是上面這個例子,玩家在登錄時,先調用組隊模塊接口,再調用好友模塊接口,再調用公會模塊接口......這些順序的執行過程將這些模塊緊緊地耦合在了一起,當遊戲邏輯變得越來越複雜時,類似的接口及調用數量會呈爆炸式的增長,這也將會成爲另一個巨大的問題。

  一個可行的方法是使用被稱作事件或者信號的對象來實現解耦合。仍然拿上面的例子來說,當玩家登錄成功時,玩家對象發出一個“玩家已登錄”的事件或者信號,對此事件感興趣的模塊,會響應這個事件並且做出相應的邏輯處理,具體來說就是好友模塊會向該玩家的好友廣播上線消息,組隊模塊會向該玩家的隊友廣播上線消息,公會模塊會向該玩家所在的公會廣播會員上線消息,等等。註冊感興趣的事件及響應事件的處理過程都是在各獨立模塊內部完成,玩家對象本身並不知道也不需要知道有這麼些過程。這樣,想要刪除或者擴展功能就比較的方便了。

  sigslot這個開源庫就提供了我們所要的這項功能。在玩家對象內部定義一個Signal對象,功能模塊從has_slot派生,並且將自己連接到玩家對象的signal對象上,這樣當玩家對象的signal對象被emit時便會調用到該模塊內。在收到這個信號時你可能還需要一些參數,至少應該知道到底是誰登錄了吧,沒關係,signal中可以帶任意多個參數,完全由你來控制,但遺憾的是他的slot不支持返回值。如果你不能容忍這樣大的一個功能缺失的話,boost::signal或許可以滿足你的要求,但太過於複雜的東西我一向不大喜歡,boost就屬於這一類,雖然他非常的強大。

  還有一個可考慮的選擇是FastDelegate,不過你得自己做一些封裝才能實現我們上面提到的類似功能。雖然FastDelegate基本上只是實現了一個安全的回調函數的功能,但是自己封裝出來的東西或許更適合你的需求,也可以試一試。



遊戲對象的實現 (上)

  狹義的遊戲對象是指遊戲世界中所能看到及可交互的對象,如玩家、怪物、物品等,我們這裏也主要討論這類對象在服務器上的組織及實現。


  在大部分的MMOG中,遊戲對象的類型都大同小異,主要有物品、生物、玩家等。比如在wow中,通過服務器發下來的GUID我們可以瞭解到,遊戲中有9大類對象,包括物品(Item)、揹包(Container)、生物(Unit)、玩家(Player)、遊戲對象(GameObject)、動態對象(DynamicObject)、屍體(Corpse)等。

  在mangos的實現中,對象使用類繼承的方式,由Object基類定義遊戲對象的公有接口及屬性,包括GUID的生成及管理、構造及更新UpdateData數據的虛接口、設置及獲取對象屬性集的方法等。然後分出了兩類派生對象,一是Item,另一是WorldObject。Item即物品對象,WorldObject顧名思義,爲世界對象,即可添加到遊戲世界場景中的對象,該對象類型定義了純虛接口,也就是不可被實例化,主要是在Object對象的基礎上又添加了座標設置或獲取的相關接口。

  Item類型又派兵出了一類Bag對象,這是一種特殊的物品對象,其本身具有物品的所有屬性及方法,但又可作爲新的容器類型,並具有自己特有的屬性和方法,所以實現上採用了派生。mangos在實現時對Bag的類型定義做了點小技巧,Item的類型爲2,Bag的類型爲6,這樣在通過位的方式來表示類型時,Bag類型也就同時屬於Item類型了。雖然只是很小的一個技巧,但在很多地方卻帶來了極大的便利。

  從WorldObject派生出的類型就有好幾種了,Unit、GameObject、DynamicObject和Corpse。Unit爲所有生物類型的基類,同WorldObject一樣,也不可被實例化。它定義了生物類型的公有屬性,如種族、職業、性別、生命、魔法等,另外還提供了相關的一些操作接口。遊戲中實際的生物對象類型爲Creature,從Unit派生,另外還有一類派生對象Player爲玩家對象。Player與Creature在實現上最大的區別是玩家的操作由客戶端發來的消息驅動,而Creature的控制是由自己定義的AI對象來驅動,另外Player內部還包括了很多的邏輯系統實現。

  另外還有兩類特殊的Creature,Pet和Totem,其對象類型仍然還是生物類,只是實現上與會有些特殊的東西需要處理,所以在mangos中將其作爲獨立的派生類,只是實現上的一點處理。另外在GameObject中也實現有派生對象,最終的繼承關係圖比較簡單,就不麻煩地去畫圖了。

  從我所瞭解的早期遊戲實現來看,大部分的遊戲對象結構都是採用的類似這種方式。可能與早期對面向對象的理解有關,當面向對象的概念剛出來時,大家認爲繼承就是面向對象的全部,所以處處皆對象,處處皆繼承。

  類實現的是一種封裝,雖然從雲風那裏出來的棄C++而轉投C的聲音可能會影響一部分人,但是,使用什麼語言本身就是個人喜好及團隊整體情況決定的。我們所要的也是最終的實現結果,至於中間的步驟,完全看個人。還是用雲風的話說,這只是一種信仰問題,我依然採用我所熟悉的C++,下面的描述也是如此。

  隨着面向對象技術的深入,以及泛型等概念的相繼提出,軟件程序結構方面的趨勢也有了很大改變。C++大師們常說的話中有一句是這樣說的,盡是採用組合而不是繼承。遊戲對象的實現也有類似的轉變,趨向於以組合的方式來實現遊戲對象類型,也就是實現一個通用的entity類型,然後以腳本定義的方式組合出不同的實際遊戲對象類型。

  描述的有些抽象,具體實現下一篇來仔細探討下。



遊戲對象的實現 (下)

  在上一篇中做了個簡單描述,還有一種遊戲對象實現方法是使用通用的實體對象。


  在遊戲編程精粹四有三篇文章講到了實體以及實體管理的實現方案,其中一篇文章說到了實體管理系統的四大要素:定義實體怎樣溝通的實體消息,實現一實體類代碼和數據的實體代碼,維護已經註冊在案的實體類列表,和用來創建、管理、發送消息的實體管理器。

  關於實體消息的內容之前討論事件機制的時候做過一點說明,其實這也就是按接口調用和按消息驅動的區別,現在mangos的做法是完全的接口調用,所以引擎內部就沒有任何的實體消息。實體代碼實現和實體管理器是我們重點要討論的內容。

  另有一篇文章也提到了使用類繼續的方式實現遊戲對象的兩大問題,一是它要求系統中的所有對象都必須從一個起點衍生而成,也就是說所有對象類在編譯的時候已經確定,這可能是一個不受歡迎的限制,如果開發者決定添加新的對象類,則必須要對基類有所瞭解,方能支持新類。另一個問題在於所有的對象類都必須實現同樣的一些底層函數。

  對於第二個問題,可以通過接口繼承的方式來避免基類的方法太多。在mangos的實現中就採用了類似的方法,從Object虛基類派生的Unit和WorldObject仍然還是不可實例化的類,這兩種對象定義了不同的屬性和方法,分來實現不同類型的對象。在遊戲內部可以根據對象的實際類型來Object指針向下轉型爲Unit或WorldObject,以調用需要的接口。方法雖然不夠OO,也還能解決問題。但是第一個問題是始終無法避免的。

  所以我們便有了通用實體這麼一個概念,其主要方法是將原來基類的接口進行分類,分到一個個不同的子類中,然後以對象組合的方式來生成我們所需要的實際遊戲對象類型。這個組合的過程可以通過腳本定義的方式,這樣便可以在運行時生成爲同的對象類型,也就解決了上面提到的第一個問題。

  通用實體的實現方法在目前的遊戲引擎及開源代碼中也可以看到影子。一個是BigWorld,從提供的資料來看,其引擎只提供了一個entity遊戲對象,然後由遊戲內容實現者通過xml和python腳本來自由定義不同類型的entity類型,每種類型可有不同的property和不同的方法。這樣原來由基類定義的接口完全轉移到腳本定義,具有非常強的靈活性。

  另外還有一個是CEL中的entity實現。按照CEL的描述,entity可以是遊戲中的任意對象,包括玩家可交互的對象,如鑰匙、武器等,也可以包括不能直接交互的對象,如遊戲世界,甚至任務鏈中的一部分等。entity本身並沒有任何特性,具體的功能實現需要靠附加property來完成。簡單來說,property才定義了entity可以做什麼,至於該怎麼做,那又是依靠behavior來定義。所以,最終在CEL中一個遊戲對象其實是由entity組合了多個property及多個behavior而生成的。

  但是CEL中的property與BigWorld中的property意義不大一樣,在CEL中可定義的property其實是引擎內部要先提供的,比如其示例中所舉的pcobject.mesh、pcmove.linear、pctools.inventory等,而BigWorld中的property是完全的自由定製。從這個角度來講,其實可以把CEL中的property看作是遊戲的邏輯系統,也就是我們上面所描述的,接口分類後所定義的子類。

  由引擎內部提供可選擇的property與BigWorld所採用的完全自由定製property其實本質上是相同的。在用BigWorld實現的遊戲中,也不可能爲每種遊戲對象類型都完全從頭定義property,基於代碼利用的原則,也會先定義一些小類,然後在entity類型定義時以自定義property的方式來包含這些小類。當然,我沒有使用過BigWorld,上面的描述也只是基於遊戲實現的大原則所做出來的。

  描述的依然有些抽象,我們可以用wow及mangos代碼來說明一下。mangos中爲object定義了一個屬性集合,根據對象類型的不同,這個屬性集的大小及保存數據也會有差異,另外遊戲對象內部含有處理不同遊戲邏輯的系統,如RestSystem、FloodFilterSystem、VariousSystem等等,在player.h中以接口組的方式進行定義。

  如果要將這種結構改爲我們描述的通用entity系統,可以讓object只提供property註冊和刪除的接口,這裏的property定義與CEL中的相同,放在mangos中也就是上面說的RestSystem、FloodFilterSystem、VariousSystem這些。然後也通過xml文件的方式定義我們所需要的遊戲對象類型,如player,creature,item等,不同的對象類型可以選擇加載不同的property,加載的原則是需要哪些功能就加載哪些property。

  對象的定義按上面的方法完成後,對象的實現也需要做一些修改。以前客戶端的消息是直接交由player來處理,AI也是直接調用creature的接口來完成一些功能,現在通用的entity內部已經沒有任何可用的方法,所有的實現都轉到了property中,所以需要由各個property實現自己來註冊感興趣的事件及消息,entity實現一個消息的轉發,轉給對此感興趣的property來處理。其餘的實現就沒有什麼不同了。

  當然,我們再做一點擴展,讓property不光由引擎來提供,用腳本本身也能定義property,並且可以通過xml來註冊這些property,這樣便實現了與BigWorld一樣的完全自由特性。這其實也就是將很多用C++實現的功能轉移到了python中,這種做法可作爲參考,但不一定對所有人合適,至少在我看來,這樣的實現也基本只能由程序員來做,所以讓程序員選擇自己最擅長的語言可能會更易於開發和調試。



遊戲對象的實現 (補)

  有關遊戲對象實現的描述,前面兩篇文章中說的不甚清楚,主要是一直都要引用網上能夠找到的資料來進行描述,以避免與公司引起不必要的麻煩。所以語言有些拼湊的感覺,舉的例子也很不恰當,今天正好看到了遊戲編程精粹五和六上的兩篇文章,內容都差不多,<<基於組件的對象管理>>和<<基於組件的遊戲對象系統>>,說的也是我上兩篇文章想要描述的內容,所以再補一篇,引用其中的部分文字進行明確的說明。


  傳統的遊戲對象管理系統採用繼承的方式來實現,例如,所有的子類都從CObject派生。大多數情況下,直接派生的也是抽象類,其中帶一些功能而另一些子類則不帶這些功能,比如可控制/不可控制,可動畫/不可動畫等。mangos的實現中基本就是這種情況,從Object直接派生的Unit和WorldObject都是不可直接實例化的類。

  傳統方法的問題在於無法應對需求的變化,如要求武器也有動畫效果時就無法處理了。如果硬要是這樣做,那隨着需求的嗇,很多的方法會被放到基類中,最終的結果是繼承樹變得越來越頭重腳輕,這樣的類會喪失它的內聚性,因爲它們試圖爲所有對象完成所有的事。

  就是說到最後,基類會有一個很長的接口列表,而很多的遊戲對象類型根本不需要實現其中的一些甚至大部分接口,但是按照這種結構卻又必須去實現。以至於於實現一個非常龐大的對象,而且想要修改一點功能會導致系統的大調整。

  我們希望的系統是可以將現有的功能組合到新的對象中,並且在將新的功能添加到現有的對象中時不需要重構大量的代碼和調整繼承樹的結構。

  實現的方法就是從組件來創建一個對象。組件是一個包含所有相關數據成員和方法的類,它完成某個特定的任務。把幾個組件組合在一起就可以創建一個新的對象。如把Entity組件、Render組件和Collectable組件組合在一起生成了一個Spoon對象。Entity組件讓我們可以把對象放到遊戲世界中,Render組件讓我們可以爲對象指定一個模型進行渲染,而Collectable組件讓我們可以拾取這個對象。

  關於組件的實現,所有的組件都從一個基礎組件接口派生,可稱其爲IComponent。每個組件也有自己的接口定義,並且這個接口也需要從IComponent派生,類似於這樣:IComponent -- ICmpRender -- CCmpRender

  這裏的每個組件也就是我在上一篇中所說的由引擎提供的屬性,或者說在BigWorld中自己實現然後定義的屬性,或者使用mangos中的定義,就是一個個的System,雖然mangos並沒有將其完全做成組件,但是通過其代碼註釋可以看到,接口也是按功能組進行了分類,如果要拆分成組件也是比較方便的。

  組件之間的通信有兩種方法,一是用組件ID查詢到組件接口指針,然後調用接口方法;二是使用消息的方式,向對象中所有組件發消息。在初始化的時候,每一個組件類型都會告訴對象管理器應該接收什麼樣的消息。

  查詢接口的方法也就是直接的方法調用,只不過接口不是全部在基類中,所以必須先查詢到指定的組件然後才能調用其接口。消息的使用前面已經說過多次,其實現方案也有過說明。

  最後是關於遊戲對象功能的擴展和遊戲對象的定義。需要擴展功能也就是需要實現一個新的組件,或者修改現在組件。在大多數情況下,擴展都不會引起結構的很大調整,受影響的最多隻是使用到該組件的部分代碼。

  遊戲對象定義可採用完全數據驅動的方式,使用xml或者腳本語言來定義對象類型,以及每個類型需要加載的組件。對象類型註冊到對象管理器後,由管理器提供創建指定類型的對象的方法。數據驅動的方式能夠讓策劃自由定義遊戲對象類型,並且隨時可自由創建新的對象類型。

 

發佈了27 篇原創文章 · 獲贊 18 · 訪問量 80萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章