×

遊戲開發教程之服務器框架設計和實現
2019-03-19 16:03:05

在遊戲開發過程中,會有很對學生好奇遊戲服務器框架的設計和實現是怎樣的一個過程,所以今天小編特意整理一篇實用的攻略,以供大家參考:

首先來介紹下這個框架的基本運行環境是 Linux ,采用 C++ 編寫。為了能在各種環境上運行和使用,所以采用了 gcc4.8 這個“古老”的編譯器,以 C99 規範開發。

需求

由於“越通用的代碼,就是越沒用的代碼”,所以在設計之初,我就認為應該使用分層的模式來構建整個係統。按照遊戲服務器的一般需求劃分,最基本的可以分為兩層:

底層基礎功能:包括通信、持久化等非常通用的部分,關注的是性能、易用性、擴展性等指標。

高層邏輯功能:包括具體的遊戲邏輯,針對不同的遊戲會有不同的設計。

我希望能有一個基本完整的“底層基礎功能”的框架,可以被複用於多個不同的遊戲。由於目標是開發一個適合獨立遊戲開發的遊戲服務器框架。所以最基本的需求分析為:

功能性需求:

並發:所有的服務器程序,都會碰到這個基本的問題:如何處理並發任務。一般來說,會有多線程、異步兩種技術。多線程編程在編碼上比較符合人類的思維習慣,但帶來了“鎖”這個問題。而異步非阻塞的模型,其程序執行的情況是比較簡單的,而且也能比較充分的利用硬件性能,但是問題是很多代碼需要以“回調”的形式編寫,對於複雜的業務邏輯來說,顯得非常繁瑣,可讀性非常差。雖然這兩種方案各有利弊,也有人結合這兩種技術希望能各取所長,但是我更傾向於基礎是使用異步、單線程、非阻塞的調度方式,因為這個方案是最清晰簡單的。為了解決“回調”的問題,我們可以在其上再添加其他的抽象層,比如協程或者添加線程池之類的技術予以改善。

通信:支持請求響應模式以及通知模式的通信(廣播視為一種多目標的通知)。遊戲有很多登錄、買賣、打開背包之類的功能,都是明確的有請求和響應的。而大量的聯機遊戲中,多個客戶端的位置、HP 等東西都需要經過網絡同步,其實就是一種“主動通知”的通信方式。

持久化:可以存取對象遊戲存檔的格式非常複雜,但其索引的需求往往都是根據玩家 ID 來讀寫就可以。在很多遊戲主機如 PlayStation 上,以前的存檔都是可以以類似“文件”的方式存放在記憶卡裏的。所以遊戲持久化最基本的需求,就是一個 key-value 存取模型。當然,遊戲中還會有更複雜的持久化需求,比如排行榜、拍賣行等,這些需求應該額外對待,不適合包含在一個最基本的通用底層中。

緩存:支持遠程、分布式的對象緩存。遊戲服務基本上都是“帶狀態”的服務,因為遊戲要求響應延遲非常苛刻,基本上都需要利用服務器進程的內存來存放過程數據。但是遊戲的數據,往往是變化越快的,價值越低,比如經驗值、金幣、HP,而等級、裝備等變化比較慢的,價值則越高,這種特征,非常適合用一個緩存模型來處理。

協程:可以用 C++ 來編寫協程代碼,避免大量回調函數分割代碼。這個是對於異步代碼非常有用的特性,能大大提高代碼的可讀性和開發效率。特別是把很多底層涉及IO的功能,都提供了協程化 API,使用起來就會像同步的 API 一樣輕鬆愜意。

腳本:初步設想是支持可以用 Lua 來編寫業務邏輯。遊戲需求變化是出了名快的,用腳本語言編寫業務邏輯正好能提供這方麵的支持。實際上腳本在遊戲行業裏的使用非常廣泛。所以支持腳本,也是一個遊戲服務器框架很重要的能力。

其他功能:包括定時器、服務器端的對象管理等等。這些功能很常用,所以也需要包含在框架中,但已經有很多成熟方案,所以隻要選取常見易懂的模型即可。比如對象管理,我會采用類似 Unity 的組件模型來實現。

非功能性需求

靈活性:支持可替換的通信協議;可替換的持久化設備(如數據庫);可替換的緩存設備(如 memcached/redis);以靜態庫和頭文件的方式發布,不對使用者代碼做過多的要求。遊戲的運營環境比較複雜,特別是在不同的項目之間,可能會使用不同的數據庫、不同的通信協議。但是遊戲本身業務邏輯很多都是基於對象模型去設計的,所以應該有一層能夠基於“對象”來抽象所有這些底層功能的模型。這樣才能讓多個不同的遊戲,都基於一套底層進行開發。

部署便利性:支持靈活的配置文件、命令行參數、環境變量的引用;支持單獨進程啟動,而無須依賴數據庫、消息隊列中間件等設施。一般遊戲都會有至少三套運行環境,包括一個開發環境、一個內測環境、一個外測或運營環境。一個遊戲的版本更新,往往需要更新多個環境。所以如何能盡量簡化部署就成為一個很重要的問題。我認為一個好的服務器端框架,應該能讓這個服務器端程序,在無配置、無依賴的情況下獨立啟動,以符合在開發、測試、演示環境下快速部署。並且能很簡單的通過配置文件、或者命令行參數的不同,在集群化下的外部測試或者運營環境下啟動。

性能:很多遊戲服務器,都會使用異步非阻塞的方式來編程。因為異步非阻塞可以很好的提高服務器的吞吐量,而且可以很明確的控製多個用戶任務並發下的代碼執行順序,從而避免多線程鎖之類的複雜問題。所以這個框架我也希望是以異步非阻塞作為基本的並發模型。這樣做還有另外一個好處,就是可以手工的控製具體的進程,充分利用多核 CPU 服務器的性能。當然異步代碼可讀性因為大量的回調函數,會變得很難閱讀,幸好我們還可以用“協程”來改善這個問題。

擴展性:支持服務器之間的通信,進程狀態管理,類似SOA 的集群管理。自動容災和自動擴容,其實關鍵點是服務進程的狀態同步和管理。我希望一個通用的底層,可以把所有的服務器間調用,都通過一個統一的集權管理模型管理起來,這樣就可以不再每個項目去關心集群間通信、尋址等問題。

一旦需求明確,基本的層級結構也可以設計了:

實用幹貨1.png

最後,整體的架構模塊類似:

實用幹貨2.png

通信模塊

對於通信模塊來說,需要有靈活的可替換協議的能力,就必須按一定的層次進行進一步的劃分。對於遊戲來說,最底層的通信協議,一般會使用 TCP 和 UDP 這兩種,在服務器之間,也會使用消息隊列中間件一類通信軟件。框架必須要有能同事支持這幾通信協議的能力。故此設計了一個層次為: Transport。

在協議層麵,最基本的需求有“分包”“分發”“對象序列化”等幾種需求。如果要支持“請求-響應”模式,還需要在協議中帶上“序列號”的數據,以便對應“請求”和“響應”。另外,遊戲通常都是一種“會話”式的應用,也就是一係列的請求,會被視為一次“會話”,這就需要協眾需要有類似 SessionID 這種數據。為了滿足這些需求,設計一個層次為:Protocol。

 

擁有了以上兩個層次,是可以完成最基本的協議層能力了。但是,我們往往希望業務數據的協議包,能自動化的成為編程中的對象,所以在處理消息體這裏,需要一個可選的額外層次,用來把字節數組,轉換成對象。所以我設計了一個特別的處理器:ObjectProcessor ,去規範通信模塊中對象序列化、反序列化的接口。

實用幹貨3.png

Transport

此層次是為了統一各種不同的底層傳輸協議而設置的,最基本應該支持 TCP 和 UDP 這兩種協議。對於通信協議的抽象,其實在很多底層庫也做的非常好了,比如Linux 的 socket 庫,其讀寫 API 甚至可以和文件的讀寫通用。C# 的 Socket 庫在 TCP 和 UDP 之間,其 API 也幾乎是完全一樣的。但是由於作用遊戲服務器,很多時候還會接入一些特別的“接入層”,比如一些代理服務器,或者一些消息中間件,這些 API 可是五花八門的。另外,在 html5 遊戲(比如微信小遊戲)和一些頁遊領域,還有用 HTTP 服務器作為遊戲服務器的傳統(如使用 WebSocket 協議),這樣就需要一個完全不同的傳輸層了。

服務器傳輸層在異步模型下的基本使用序列:

在主循環中,不斷嚐試讀取有什麼數據可讀

如果上一步返回有數據到達了,則讀取數據

讀取數據處理後,需要發送數據,則向網絡寫入數據

根據上麵三個特點,可以歸納出一個基本接口:

實用幹貨4.png

在上麵的定義中,可以看到需要有一個 Peer 類型。這個類型是為了代表通信的客戶端(對端)對象。在一般的 Linux 係統中,一般我們用 fd (File Description)來代表。但是因為在框架中,我們還需要為每個客戶端建立接收數據的緩存區,以及記錄通信地址等功能,所以在 fd 的基礎上封裝了一個這樣的類型。這樣也有利於把 UDP 通信以不同客戶端的模型,進行封裝。

實用幹貨5.png

遊戲使用 UDP 協議的特點:一般來說 UDP 是無連接的,但是對於遊戲來說,是肯定需要有明確的客戶端的,所以就不能簡單用一個UDP socket 的fd 來代表客戶端,這就造成了上層的代碼無法簡單在 UDP 和 TCP 之間保持一致。因此這裏使用 Peer 這個抽象層,正好可以解決這個問題。這也可以用於那些使用某種消息隊列中間件的情況,因為可能這些中間件,也是多路複用一個 fd 的,甚至可能就不是通過使用 fd 的 API 來開發的。

對於上麵的 Transport 定義,對於 TCP 的實現者來說,是非常容易能完成的。但是對於 UDP 的實現者來說,則需要考慮如何充分利用 Peer ,特別是 Peer.fd_ 這個數據。我在實現的時候,使用了一套虛擬的 fd 機製,通過一個客戶端的 IPv4 地址到 int 的對應 Map ,來對上層提供區分客戶端的功能。在 Linux 上,這些 IO 都可以使用epoll 庫來實現,在 Peek() 函數中讀取 IO 事件,在 Read()/Write() 填上 socket 的調用就可以了。

另外,為了實現服務器之間的通信,還需要設計和Tansport 對應的一個類型:Connector 。這個抽象基類,用於以客戶端模型對服務器發起請求。其設計和 Transport 大同小異。除了 Linux 環境下的 Connecotr ,我還實現了在 C# 下的代碼,以便用Unity 開發的客戶端可以方便的使用。由於 .NET 本身就支持異步模型,所以其實現也不費太多功夫。

實用幹貨6.png

Protocol

對於通信“協議”來說,其實包含了許許多多的含義。在眾多的需求中,我所定義的這個協議層,隻希望完成四個最基本的能力:

分包:從流式傳輸層切分出一個個單獨的數據單元,或者把多個“碎片”數據拚合成一個完整的數據單元的能力。一般解決這個問題,需要在協議頭部添加一個“長度”字段。

請求響應對應:這對於異步非阻塞的通信模式下,是非常重要的功能。因為可能在一瞬間發出了很多個請求,而回應則會不分先後的到達。協議頭部如果有一個不重複的“序列號”字段,就可以對應起哪個回應是屬於哪個請求的。

會話保持:由於遊戲的底層網絡,可能會使用 UDP 或者 HTTP 這種非長連接的傳輸方式,所以要在邏輯上保持一個會話,就不能單純的依靠傳輸層。加上我們都希望程序有抗網絡抖動、斷線重連的能力,所以保持會話成為一個常見的需求。我參考在 Web 服務領域的會話功能,設計了一個 Session 功能,在協議中加上 Session ID 這樣的數據,就能比較簡單的保持會話。

分發:遊戲服務器必定會包含多個不同的業務邏輯,因此需要多種不同數據格式的協議包,為了把對應格式的數據轉發。

除了以上三個功能,實際上希望在協議層處理的能力,還有很多,最典型的就是對象序列化的功能,還有壓縮、加密功能等等。我之所以沒有把對象序列化的能力放在 Protocol 中,原因是對象序列化中的“對象”本身是一個業務邏輯關聯性非常強的概念。在 C++ 中,並沒有完整的“對象”模型,也缺乏原生的反射支持,所以無法很簡單的把代碼層次通過“對象”這個抽象概念劃分開來。但是我也設計了一個 ObjectProcessor ,把對象序列化的支持,以更上層的形式結合到框架中。這個Processor 是可以自定義對象序列化的方法,這樣開發者就可以自己選擇任何“編碼、解碼”的能力,而不需要依靠底層的支持。

至於壓縮和加密這一類功能,確實是可以放在 Protocol 層中實現,甚至可以作為一個抽象層次加入 Protocol ,可能隻有一個 Protocol 層不足以支持這麼豐富的功能,需要好像 Apache Mina 這樣,設計一個“調用鏈”的模型。但是為了簡單起見,我覺得在具體需要用到的地方,再額外添加 Protocol 的實現類就好,比如添加一個“帶壓縮功能的 TLV Protocol 類型”之類的。

消息本身被抽象成一個叫 Message 的類型,它擁有“服務名字”“會話ID”兩個消息頭字段,用以完成“分發”和“會話保持”功能。而消息體則被放在一個字節數組中,並記錄下字節數組的長度。

實用幹貨7.png

根據之前設計的“請求響應”和“通知”兩種通信模式,需要設計出三種消息類型繼承於 Message,他們是:Request(請求包)、Response(響應包)、Notice(通知包)。

Request 和 Response 兩個類,都有記錄序列號的seq_id 字段,但 Notice 沒有。Protocol 類就是負責把一段 buffer 字節數組,轉換成 Message 的子類對象。所以需要針對三種 Message 的子類型都實現對應的 Encode() / Decode() 方法。

實用幹貨8.png

這裏有一點需要注意,由於 C++ 沒有內存垃圾搜集和反射的能力,在解釋數據的時候,並不能一步就把一個 char[] 轉換成某個子類對象,而必須分成兩步處理。

先通過 DecodeBegin() 來返回,將要解碼的數據是屬於哪個子類型的。同時完成分包的工作,通過返回值來告知調用者,是否已經完整的收到一個包。

調用對應類型為參數的 Decode() 來具體把數據寫入對應的輸出變量。

對於 Protocol 的具體實現子類,我首先實現了一個 LineProtocol ,是一個非常不嚴謹的,基於文本ASCII編碼的,用空格分隔字段,用回車分包的協議。用來測試這個框架是否可行。因為這樣可以直接通過 telnet 工具,來測試協議的編解碼。然後我按照 TLV (Type Length Value)的方法設計了一個二進製的協議。大概的定義如下:

協議分包: [消息類型:int:2] [消息長度:int:4] [消息內容:bytes:消息長度]

消息類型取值:

0x00 Error

0x01 Request

0x02 Response

0x03 Notice

實用幹貨9.png

一個名為 TlvProtocol 的類型完成對這個協議的實現。

Processor

處理器層是我設計用來對接具體業務邏輯的抽象層,它主要通過輸入參數 Request 和 Peer 來獲得客戶端的輸入數據,然後通過 Server 類的 Reply()/Inform() 來返回 Response 和 Notice 消息。實際上 Transport 和 Protocol 的子類們,都屬於 net 模塊,而各種 Processor 和 Server/Client 這些功能類型,屬於另外一個 processor 模塊。這樣設計的原因,是希望所有 processor 模塊的代碼單向的依賴 net 模塊的代碼,但反過來不成立。

Processor 基類非常簡單,就是一個處理函數回調函數入口 Process():

實用幹貨10.png

設計完Transport/Protocol/Processor 三個通信處理層次後,就需要一個組合這三個層次的代碼,那就是 Server 類。這個類在 Init() 的時候,需要上麵三個類型的子類作為參數,以組合成不同功能的服務器,如:

實用幹貨11.png

Server 類型還需要一個 Update() 函數,讓用戶進程的“主循環”不停的調用,用來驅動整個程序的運行。這個 Update() 函數的內容非常明確:

檢查網絡是否有數據需要處理(通過 Transport對象)

有數據的話就進行解碼處理(通過 Protocol 對象)

解碼成功後進行業務邏輯的分發調用(通過Processor 對象)

另外,Server 還需要處理一些額外的功能,比如維護一個會話緩存池(Session),提供發送 Response 和 Notice 消息的接口。當這些工作都完成後,整套係統已經可以用來作為一個比較“通用”的網絡消息服務器框架存在了。剩下的就是添加各種Transport/Protocol/Processor 子類的工作。

實用幹貨12.png

有了 Server 類型,肯定也需要有 Client 類型。而 Client 類型的設計和 Server 類似,但就不是使用 Transport 接口作為傳輸層,而是 Connector 接口。不過 Protocol 的抽象層是完全重用的。Client 並不需要 Processor 這種形式的回調,而是直接傳入接受數據消息就發起回調的接口對象 ClientCallback。

實用幹貨13.png

至此,客戶端和服務器端基本設計完成,可以直接通過編寫測試代碼,來檢查是否運行正常。

熱門課程

專業講師指導 快速擺脫技能困惑

相關文章

多種教程 總有一個適合自己

專業問題谘詢

你擔心的問題 火星幫你解答

华体会hth体育网 賞析

複製成功。粘貼給你的微信好友吧~
×