日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪(fǎng)問(wèn) 生活随笔!

生活随笔

當(dāng)前位置: 首頁(yè) > 编程资源 > 编程问答 >内容正文

编程问答

教你从头写游戏服务器框架

發(fā)布時(shí)間:2023/12/20 编程问答 23 豆豆
生活随笔 收集整理的這篇文章主要介紹了 教你从头写游戏服务器框架 小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.
本文由云+社區(qū)發(fā)表

作者:韓偉

前言

大概已經(jīng)有差不多一年沒(méi)寫(xiě)技術(shù)文章了,原因是今年投入了一些具體游戲項(xiàng)目的開(kāi)發(fā)。這些新的游戲項(xiàng)目,比較接近獨(dú)立游戲的開(kāi)發(fā)方式。我覺(jué)得公司的“祖?zhèn)鳌狈?wù)器框架技術(shù)不太適合,所以從頭寫(xiě)了一個(gè)游戲服務(wù)器端的框架,以便獲得更好的開(kāi)發(fā)效率和靈活性。現(xiàn)在項(xiàng)目將近上線(xiàn),有時(shí)間就想總結(jié)一下,這樣一個(gè)游戲服務(wù)器框架的設(shè)計(jì)和實(shí)現(xiàn)過(guò)程。

這個(gè)框架的基本運(yùn)行環(huán)境是 Linux ,采用 C++ 編寫(xiě)。為了能在各種環(huán)境上運(yùn)行和使用,所以采用了 gcc 4.8 這個(gè)“古老”的編譯器,以 C99 規(guī)范開(kāi)發(fā)。

需求

由于“越通用的代碼,就是越?jīng)]用的代碼”,所以在設(shè)計(jì)之初,我就認(rèn)為應(yīng)該使用分層的模式來(lái)構(gòu)建整個(gè)系統(tǒng)。按照游戲服務(wù)器的一般需求劃分,最基本的可以分為兩層:

  • 底層基礎(chǔ)功能:包括通信、持久化等非常通用的部分,關(guān)注的是性能、易用性、擴(kuò)展性等指標(biāo)。
  • 高層邏輯功能:包括具體的游戲邏輯,針對(duì)不同的游戲會(huì)有不同的設(shè)計(jì)。
  • 我希望能有一個(gè)基本完整的“底層基礎(chǔ)功能”的框架,可以被復(fù)用于多個(gè)不同的游戲。由于目標(biāo)是開(kāi)發(fā)一個(gè) 適合獨(dú)立游戲開(kāi)發(fā) 的游戲服務(wù)器框架。所以最基本的需求分析為:

    功能性需求

  • 并發(fā):所有的服務(wù)器程序,都會(huì)碰到這個(gè)基本的問(wèn)題:如何處理并發(fā)處理。一般來(lái)說(shuō),會(huì)有多線(xiàn)程、異步兩種技術(shù)。多線(xiàn)程編程在編碼上比較符合人類(lèi)的思維習(xí)慣,但帶來(lái)了“鎖”這個(gè)問(wèn)題。而異步非阻塞的模型,其程序執(zhí)行的情況是比較簡(jiǎn)單的,而且也能比較充分的利用硬件性能,但是問(wèn)題是很多代碼需要以“回調(diào)”的形式編寫(xiě),對(duì)于復(fù)雜的業(yè)務(wù)邏輯來(lái)說(shuō),顯得非常繁瑣,可讀性非常差。雖然這兩種方案各有利弊,也有人結(jié)合這兩種技術(shù)希望能各取所長(zhǎng),但是我更傾向于基礎(chǔ)是使用異步、單線(xiàn)程、非阻塞的調(diào)度方式,因?yàn)檫@個(gè)方案是最清晰簡(jiǎn)單的。為了解決“回調(diào)”的問(wèn)題,我們可以在其上再添加其他的抽象層,比如協(xié)程或者添加線(xiàn)程池之類(lèi)的技術(shù)予以改善。
  • 通信:支持 請(qǐng)求響應(yīng) 模式以及 通知 模式的通信(廣播視為一種多目標(biāo)的通知)。游戲有很多登錄、買(mǎi)賣(mài)、打開(kāi)背包之類(lèi)的功能,都是明確的有請(qǐng)求和響應(yīng)的。而大量的聯(lián)機(jī)游戲中,多個(gè)客戶(hù)端的位置、HP 等東西都需要經(jīng)過(guò)網(wǎng)絡(luò)同步,其實(shí)就是一種“主動(dòng)通知”的通信方式。
  • 持久化:可以存取 對(duì)象 。游戲存檔的格式非常復(fù)雜,但其索引的需求往往都是根據(jù)玩家 ID 來(lái)讀寫(xiě)就可以。在很多游戲主機(jī)如 PlayStation 上,以前的存檔都是可以以類(lèi)似“文件”的方式存放在記憶卡里的。所以游戲持久化最基本的需求,就是一個(gè) key-value 存取模型。當(dāng)然,游戲中還會(huì)有更復(fù)雜的持久化需求,比如排行榜、拍賣(mài)行等,這些需求應(yīng)該額外對(duì)待,不適合包含在一個(gè)最基本的通用底層中。
  • 緩存:支持遠(yuǎn)程、分布式的對(duì)象緩存。游戲服務(wù)基本上都是“帶狀態(tài)”的服務(wù),因?yàn)橛螒蛞箜憫?yīng)延遲非常苛刻,基本上都需要利用服務(wù)器進(jìn)程的內(nèi)存來(lái)存放過(guò)程數(shù)據(jù)。但是游戲的數(shù)據(jù),往往是變化越快的,價(jià)值越低,比如經(jīng)驗(yàn)值、金幣、HP,而等級(jí)、裝備等變化比較慢的,價(jià)值則越高,這種特征,非常適合用一個(gè)緩存模型來(lái)處理。
  • 協(xié)程:可以用 C++ 來(lái)編寫(xiě)協(xié)程代碼,避免大量回調(diào)函數(shù)分割代碼。這個(gè)是對(duì)于異步代碼非常有用的特性,能大大提高代碼的可讀性和開(kāi)發(fā)效率。特別是把很多底層涉及IO的功能,都提供了協(xié)程化 API,使用起來(lái)就會(huì)像同步的 API 一樣輕松愜意。
  • 腳本:初步設(shè)想是支持可以用 Lua 來(lái)編寫(xiě)業(yè)務(wù)邏輯。游戲需求變化是出了名快的,用腳本語(yǔ)言編寫(xiě)業(yè)務(wù)邏輯正好能提供這方面的支持。實(shí)際上腳本在游戲行業(yè)里的使用非常廣泛。所以支持腳本,也是一個(gè)游戲服務(wù)器框架很重要的能力。
  • 其他功能:包括定時(shí)器、服務(wù)器端的對(duì)象管理等等。這些功能很常用,所以也需要包含在框架中,但已經(jīng)有很多成熟方案,所以只要選取常見(jiàn)易懂的模型即可。比如對(duì)象管理,我會(huì)采用類(lèi)似 Unity 的組件模型來(lái)實(shí)現(xiàn)。
  • 非功能性需求

  • 靈活性:支持可替換的通信協(xié)議;可替換的持久化設(shè)備(如數(shù)據(jù)庫(kù));可替換的緩存設(shè)備(如 memcached/redis);以靜態(tài)庫(kù)和頭文件的方式發(fā)布,不對(duì)使用者代碼做過(guò)多的要求。游戲的運(yùn)營(yíng)環(huán)境比較復(fù)雜,特別是在不同的項(xiàng)目之間,可能會(huì)使用不同的數(shù)據(jù)庫(kù)、不同的通信協(xié)議。但是游戲本身業(yè)務(wù)邏輯很多都是基于對(duì)象模型去設(shè)計(jì)的,所以應(yīng)該有一層能夠基于“對(duì)象”來(lái)抽象所有這些底層功能的模型。這樣才能讓多個(gè)不同的游戲,都基于一套底層進(jìn)行開(kāi)發(fā)。
  • 部署便利性:支持靈活的配置文件、命令行參數(shù)、環(huán)境變量的引用;支持單獨(dú)進(jìn)程啟動(dòng),而無(wú)須依賴(lài)數(shù)據(jù)庫(kù)、消息隊(duì)列中間件等設(shè)施。一般游戲都會(huì)有至少三套運(yùn)行環(huán)境,包括一個(gè)開(kāi)發(fā)環(huán)境、一個(gè)內(nèi)測(cè)環(huán)境、一個(gè)外測(cè)或運(yùn)營(yíng)環(huán)境。一個(gè)游戲的版本更新,往往需要更新多個(gè)環(huán)境。所以如何能盡量簡(jiǎn)化部署就成為一個(gè)很重要的問(wèn)題。我認(rèn)為一個(gè)好的服務(wù)器端框架,應(yīng)該能讓這個(gè)服務(wù)器端程序,在無(wú)配置、無(wú)依賴(lài)的情況下獨(dú)立啟動(dòng),以符合在開(kāi)發(fā)、測(cè)試、演示環(huán)境下快速部署。并且能很簡(jiǎn)單的通過(guò)配置文件、或者命令行參數(shù)的不同,在集群化下的外部測(cè)試或者運(yùn)營(yíng)環(huán)境下啟動(dòng)。
  • 性能:很多游戲服務(wù)器,都會(huì)使用異步非阻塞的方式來(lái)編程。因?yàn)楫惒椒亲枞梢院芎玫奶岣叻?wù)器的吞吐量,而且可以很明確的控制多個(gè)用戶(hù)任務(wù)并發(fā)下的代碼執(zhí)行順序,從而避免多線(xiàn)程鎖之類(lèi)的復(fù)雜問(wèn)題。所以這個(gè)框架我也希望是以異步非阻塞作為基本的并發(fā)模型。這樣做還有另外一個(gè)好處,就是可以手工的控制具體的進(jìn)程,充分利用多核 CPU 服務(wù)器的性能。當(dāng)然異步代碼可讀性因?yàn)榇罅康幕卣{(diào)函數(shù),會(huì)變得很難閱讀,幸好我們還可以用“協(xié)程”來(lái)改善這個(gè)問(wèn)題。
  • 擴(kuò)展性:支持服務(wù)器之間的通信,進(jìn)程狀態(tài)管理,類(lèi)似 SOA 的集群管理。自動(dòng)容災(zāi)和自動(dòng)擴(kuò)容,其實(shí)關(guān)鍵點(diǎn)是服務(wù)進(jìn)程的狀態(tài)同步和管理。我希望一個(gè)通用的底層,可以把所有的服務(wù)器間調(diào)用,都通過(guò)一個(gè)統(tǒng)一的集權(quán)管理模型管理起來(lái),這樣就可以不再每個(gè)項(xiàng)目去關(guān)心集群間通信、尋址等問(wèn)題。
  • 一旦需求明確下來(lái),基本的層級(jí)結(jié)構(gòu)也可以設(shè)計(jì)了:

    層次功能約束
    邏輯層實(shí)現(xiàn)更具體的業(yè)務(wù)邏輯能調(diào)用所有下層代碼,但應(yīng)主要依賴(lài)接口層
    實(shí)現(xiàn)層對(duì)各種具體的通信協(xié)議、存儲(chǔ)設(shè)備等功能的實(shí)現(xiàn)滿(mǎn)足下層的接口層來(lái)做實(shí)現(xiàn),禁止同層間互相調(diào)用
    接口層定義了各模塊的基本使用方式,用以隔離具體的實(shí)現(xiàn)和設(shè)計(jì),從而提供互相替換的能力本層之間代碼可以互相調(diào)用,但禁止調(diào)用上層代碼
    工具層提供通用的 C++ 工具庫(kù)功能,如 log/json/ini/日期時(shí)間/字符串處理 等等不應(yīng)該調(diào)用其他層代碼,也不應(yīng)該調(diào)用同層其他模塊
    第三方庫(kù)提供諸如 redis/tcaplus 或者其他現(xiàn)成功能,其地位和“工具層”一樣不應(yīng)該調(diào)用其他層代碼,甚至不應(yīng)該修改其源碼

    最后,整體的架構(gòu)模塊類(lèi)似:

    說(shuō)明通信處理器緩存持久化
    功能實(shí)現(xiàn)TcpUdpKcpTlvLineJsonHandlerObjectProcessorSessionLocalCacheRedisMapRamMapZooKeeperMapFileDataStoreRedisDataStroe
    接口定義TransferProtocolServerClientProcessorDataMapSerializableDataStore
    工具類(lèi)庫(kù)ConfigLOGJSONCoroutine

    通信模塊

    對(duì)于通信模塊來(lái)說(shuō),需要有靈活的可替換協(xié)議的能力,就必須按一定的層次進(jìn)行進(jìn)一步的劃分。對(duì)于游戲來(lái)說(shuō),最底層的通信協(xié)議,一般會(huì)使用 TCP 和 UDP 這兩種,在服務(wù)器之間,也會(huì)使用消息隊(duì)列中間件一類(lèi)通信軟件。框架必須要有能同事支持這幾通信協(xié)議的能力。故此設(shè)計(jì)了一個(gè)層次為: Transport

    在協(xié)議層面,最基本的需求有“分包”“分發(fā)”“對(duì)象序列化”等幾種需求。如果要支持“請(qǐng)求-響應(yīng)”模式,還需要在協(xié)議中帶上“序列號(hào)”的數(shù)據(jù),以便對(duì)應(yīng)“請(qǐng)求”和“響應(yīng)”。另外,游戲通常都是一種“會(huì)話(huà)”式的應(yīng)用,也就是一系列的請(qǐng)求,會(huì)被視為一次“會(huì)話(huà)”,這就需要協(xié)眾需要有類(lèi)似 Session ID 這種數(shù)據(jù)。為了滿(mǎn)足這些需求,設(shè)計(jì)一個(gè)層次為: Protocol

    擁有了以上兩個(gè)層次,是可以完成最基本的協(xié)議層能力了。但是,我們往往希望業(yè)務(wù)數(shù)據(jù)的協(xié)議包,能自動(dòng)化的成為編程中的 對(duì)象,所以在處理消息體這里,需要一個(gè)可選的額外層次,用來(lái)把字節(jié)數(shù)組,轉(zhuǎn)換成對(duì)象。所以我設(shè)計(jì)了一個(gè)特別的處理器:ObjectProcessor ,去規(guī)范通信模塊中對(duì)象序列化、反序列化的接口。

    輸入層次功能輸出
    dataTransport通信buffer
    bufferProtocol分包Message
    MessageProcessor分發(fā)object
    object處理模塊處理業(yè)務(wù)邏輯

    Transport

    此層次是為了統(tǒng)一各種不同的底層傳輸協(xié)議而設(shè)置的,最基本應(yīng)該支持 TCP 和 UDP 這兩種協(xié)議。對(duì)于通信協(xié)議的抽象,其實(shí)在很多底層庫(kù)也做的非常好了,比如 Linux 的 socket 庫(kù),其讀寫(xiě) API 甚至可以和文件的讀寫(xiě)通用。C# 的 Socket 庫(kù)在 TCP 和 UDP 之間,其 api 也幾乎是完全一樣的。但是由于作用游戲服務(wù)器,很多適合還會(huì)接入一些特別的“接入層”,比如一些代理服務(wù)器,或者一些消息中間件,這些 API 可是五花八門(mén)的。另外,在 html5 游戲(比如微信小游戲)和一些頁(yè)游領(lǐng)域,還有用 HTTP 服務(wù)器作為游戲服務(wù)器的傳統(tǒng)(如使用 WebSocket 協(xié)議),這樣就需要一個(gè)完全不同的傳輸層了。

    服務(wù)器傳輸層在異步模型下的基本使用序列,就是:

  • 在主循環(huán)中,不斷嘗試讀取有什么數(shù)據(jù)可讀
  • 如果上一步返回有數(shù)據(jù)到達(dá)了,則讀取數(shù)據(jù)
  • 讀取數(shù)據(jù)處理后,需要發(fā)送數(shù)據(jù),則向網(wǎng)絡(luò)寫(xiě)入數(shù)據(jù)
  • 根據(jù)上面三個(gè)特點(diǎn),可以歸納出一個(gè)基本的接口:

    class Transport { public: /*** 初始化Transport對(duì)象,輸入Config對(duì)象配置最大連接數(shù)等參數(shù),可以是一個(gè)新建的Config對(duì)象。*/ virtual int Init(Config* config) = 0;/*** 檢查是否有數(shù)據(jù)可以讀取,返回可讀的事件數(shù)。后續(xù)代碼應(yīng)該根據(jù)此返回值循環(huán)調(diào)用Read()提取數(shù)據(jù)。* 參數(shù)fds用于返回出現(xiàn)事件的所有fd列表,len表示這個(gè)列表的最大長(zhǎng)度。如果可用事件大于這個(gè)數(shù)字,并不影響后續(xù)可以Read()的次數(shù)。* fds的內(nèi)容,如果出現(xiàn)負(fù)數(shù),表示有一個(gè)新的終端等待接入。*/virtual int Peek(int* fds, int len) = 0;/*** 讀取網(wǎng)絡(luò)管道中的數(shù)據(jù)。數(shù)據(jù)放在輸出參數(shù) peer 的緩沖區(qū)中。* @param peer 參數(shù)是產(chǎn)生事件的通信對(duì)端對(duì)象。* @return 返回值為可讀數(shù)據(jù)的長(zhǎng)度,如果是 0 表示沒(méi)有數(shù)據(jù)可以讀,返回 -1 表示連接需要被關(guān)閉。*/virtual int Read( Peer* peer) = 0;/*** 寫(xiě)入數(shù)據(jù),output_buf, buf_len為想要寫(xiě)入的數(shù)據(jù)緩沖區(qū),output_peer為目標(biāo)隊(duì)端,* 返回值表示成功寫(xiě)入了的數(shù)據(jù)長(zhǎng)度。-1表示寫(xiě)入出錯(cuò)。*/virtual int Write(const char* output_buf, int buf_len, const Peer& output_peer) = 0;/*** 關(guān)閉一個(gè)對(duì)端的連接*/virtual void ClosePeer(const Peer& peer) = 0;/*** 關(guān)閉Transport對(duì)象。*/virtual void Close() = 0;}

    在上面的定義中,可以看到需要有一個(gè) Peer 類(lèi)型。這個(gè)類(lèi)型是為了代表通信的客戶(hù)端(對(duì)端)對(duì)象。在一般的 Linux 系統(tǒng)中,一般我們用 fd (File Description)來(lái)代表。但是因?yàn)樵诳蚣苤?#xff0c;我們還需要為每個(gè)客戶(hù)端建立接收數(shù)據(jù)的緩存區(qū),以及記錄通信地址等功能,所以在 fd 的基礎(chǔ)上封裝了一個(gè)這樣的類(lèi)型。這樣也有利于把 UDP 通信以不同客戶(hù)端的模型,進(jìn)行封裝。

    ///@brief 此類(lèi)型負(fù)責(zé)存放連接過(guò)來(lái)的客戶(hù)端信息和數(shù)據(jù)緩沖區(qū) class Peer { public: int buf_size_; ///< 緩沖區(qū)長(zhǎng)度char* const buffer_;///< 緩沖區(qū)起始地址int produced_pos_; ///< 填入了數(shù)據(jù)的長(zhǎng)度int consumed_pos_; ///< 消耗了數(shù)據(jù)的長(zhǎng)度int GetFd() const;void SetFd(int fd); /// 獲得本地地址const struct sockaddr_in& GetLocalAddr() const;void SetLocalAddr(const struct sockaddr_in& localAddr); /// 獲得遠(yuǎn)程地址const struct sockaddr_in& GetRemoteAddr() const;void SetRemoteAddr(const struct sockaddr_in& remoteAddr);private:int fd_; ///< 收發(fā)數(shù)據(jù)用的fdstruct sockaddr_in remote_addr_; ///< 對(duì)端地址struct sockaddr_in local_addr_; ///< 本端地址 };

    游戲使用 UDP 協(xié)議的特點(diǎn):一般來(lái)說(shuō) UDP 是無(wú)連接的,但是對(duì)于游戲來(lái)說(shuō),是肯定需要有明確的客戶(hù)端的,所以就不能簡(jiǎn)單用一個(gè) UDP socket 的fd 來(lái)代表客戶(hù)端,這就造成了上層的代碼無(wú)法簡(jiǎn)單在 UDP 和 TCP 之間保持一致。因此這里使用 Peer 這個(gè)抽象層,正好可以接近這個(gè)問(wèn)題。這也可以用于那些使用某種消息隊(duì)列中間件的情況,因?yàn)榭赡苓@些中間件,也是多路復(fù)用一個(gè) fd 的,甚至可能就不是通過(guò)使用 fd 的 API 來(lái)開(kāi)發(fā)的。

    對(duì)于上面的 Transport 定義,對(duì)于 TCP 的實(shí)現(xiàn)者來(lái)說(shuō),是非常容易能完成的。但是對(duì)于 UDP 的實(shí)現(xiàn)者來(lái)說(shuō),則需要考慮如何寵妃利用 Peer ,特別是 Peer.fd_ 這個(gè)數(shù)據(jù)。我在實(shí)現(xiàn)的時(shí)候,使用了一套虛擬的 fd 機(jī)制,通過(guò)一個(gè)客戶(hù)端的 IPv4 地址到 int 的對(duì)應(yīng) Map ,來(lái)對(duì)上層提供區(qū)分客戶(hù)端的功能。在 Linux 上,這些 IO 都可以使用 epoll 庫(kù)來(lái)實(shí)現(xiàn),在 Peek() 函數(shù)中讀取 IO 事件,在 Read()/Write() 填上 socket 的調(diào)用就可以了。

    另外,為了實(shí)現(xiàn)服務(wù)器之間的通信,還需要設(shè)計(jì)和 Tansport 對(duì)應(yīng)的一個(gè)類(lèi)型:Connector 。這個(gè)抽象基類(lèi),用于以客戶(hù)端模型對(duì)服務(wù)器發(fā)起請(qǐng)求。其設(shè)計(jì)和 Transport 大同小異。除了 Linux 環(huán)境下的 Connecotr ,我還實(shí)現(xiàn)了在 C# 下的代碼,以便用 Unity 開(kāi)發(fā)的客戶(hù)端可以方便的使用。由于 .NET 本身就支持異步模型,所以其實(shí)現(xiàn)也不費(fèi)太多功夫。

    /*** @brief 客戶(hù)端使用的連接器類(lèi),代表傳輸協(xié)議,如 TCP 或 UDP*/ class Connector {public: virtual ~Connector() {} /*** @brief 初始化建立連接等* @param config 需要的配置* @return 0 為成功*/virtual int Init(Config* config) = 0;/*** @brief 關(guān)閉*/virtual void Close() = 0;/*** @brief 讀取是否有網(wǎng)絡(luò)數(shù)據(jù)到來(lái)* 讀取有無(wú)數(shù)據(jù)到來(lái),返回值為可讀事件的數(shù)量,通常為1* 如果為0表示沒(méi)有數(shù)據(jù)可以讀取。* 如果返回 -1 表示出現(xiàn)網(wǎng)絡(luò)錯(cuò)誤,需要關(guān)閉此連接。* 如果返回 -2 表示此連接成功連上對(duì)端。* @return 網(wǎng)絡(luò)數(shù)據(jù)的情況*/virtual int Peek() = 0;/*** @brief 讀取網(wǎng)絡(luò)數(shù) * 讀取連接里面的數(shù)據(jù),返回讀取到的字節(jié)數(shù),如果返回0表示沒(méi)有數(shù)據(jù),* 如果buffer_length是0, 也會(huì)返回0,* @return 返回-1表示連接需要關(guān)閉(各種出錯(cuò)也返回0)*/virtual int Read(char* ouput_buffer, int buffer_length) = 0;/*** @brief 把input_buffer里的數(shù)據(jù)寫(xiě)入網(wǎng)絡(luò)連接,返回寫(xiě)入的字節(jié)數(shù)。* @return 如果返回-1表示寫(xiě)入出錯(cuò),需要關(guān)閉此連接。*/virtual int Write(const char* input_buffer, int buffer_length) = 0;protected:Connector(){} };

    Protocol

    對(duì)于通信“協(xié)議”來(lái)說(shuō),其實(shí)包含了許許多多的含義。在眾多的需求中,我所定義的這個(gè)協(xié)議層,只希望完成四個(gè)最基本的能力:

  • 分包:從流式傳輸層切分出一個(gè)個(gè)單獨(dú)的數(shù)據(jù)單元,或者把多個(gè)“碎片”數(shù)據(jù)拼合成一個(gè)完整的數(shù)據(jù)單元的能力。一般解決這個(gè)問(wèn)題,需要在協(xié)議頭部添加一個(gè)“長(zhǎng)度”字段。
  • 請(qǐng)求響應(yīng)對(duì)應(yīng):這對(duì)于異步非阻塞的通信模式下,是非常重要的功能。因?yàn)榭赡茉谝凰查g發(fā)出了很多個(gè)請(qǐng)求,而回應(yīng)則會(huì)不分先后的到達(dá)。協(xié)議頭部如果有一個(gè)不重復(fù)的“序列號(hào)”字段,就可以對(duì)應(yīng)起哪個(gè)回應(yīng)是屬于哪個(gè)請(qǐng)求的。
  • 會(huì)話(huà)保持:由于游戲的底層網(wǎng)絡(luò),可能會(huì)使用 UDP 或者 HTTP 這種非長(zhǎng)連接的傳輸方式,所以要在邏輯上保持一個(gè)會(huì)話(huà),就不能單純的依靠傳輸層。加上我們都希望程序有抗網(wǎng)絡(luò)抖動(dòng)、斷線(xiàn)重連的能力,所以保持會(huì)話(huà)成為一個(gè)常見(jiàn)的需求。我參考在 Web 服務(wù)領(lǐng)域的會(huì)話(huà)功能,設(shè)計(jì)了一個(gè) Session 功能,在協(xié)議中加上 Session ID 這樣的數(shù)據(jù),就能比較簡(jiǎn)單的保持會(huì)話(huà)。
  • 分發(fā):游戲服務(wù)器必定會(huì)包含多個(gè)不同的業(yè)務(wù)邏輯,因此需要多種不同數(shù)據(jù)格式的協(xié)議包,為了把對(duì)應(yīng)格式的數(shù)據(jù)轉(zhuǎn)發(fā)。
  • 除了以上三個(gè)功能,實(shí)際上希望在協(xié)議層處理的能力,還有很多,最典型的就是對(duì)象序列化的功能,還有壓縮、加密功能等等。我之所以沒(méi)有把對(duì)象序列化的能力放在 Protocol 中,原因是對(duì)象序列化中的“對(duì)象”本身是一個(gè)業(yè)務(wù)邏輯關(guān)聯(lián)性非常強(qiáng)的概念。在 C++ 中,并沒(méi)有完整的“對(duì)象”模型,也缺乏原生的反射支持,所以無(wú)法很簡(jiǎn)單的把代碼層次通過(guò)“對(duì)象”這個(gè)抽象概念劃分開(kāi)來(lái)。但是我也設(shè)計(jì)了一個(gè) ObjectProcessor ,把對(duì)象序列化的支持,以更上層的形式結(jié)合到框架中。這個(gè) Processor 是可以自定義對(duì)象序列化的方法,這樣開(kāi)發(fā)者就可以自己選擇任何“編碼、解碼”的能力,而不需要依靠底層的支持。

    至于壓縮和加密這一類(lèi)功能,確實(shí)是可以放在 Protocol 層中實(shí)現(xiàn),甚至可以作為一個(gè)抽象層次加入 Protocol ,可能只有一個(gè) Protocol 層不足以支持這么豐富的功能,需要好像 Apache Mina 這樣,設(shè)計(jì)一個(gè)“調(diào)用鏈”的模型。但是為了簡(jiǎn)單起見(jiàn),我覺(jué)得在具體需要用到的地方,再額外添加 Protocol 的實(shí)現(xiàn)類(lèi)就好,比如添加一個(gè)“帶壓縮功能的 TLV Protocol 類(lèi)型”之類(lèi)的。

    消息本身被抽象成一個(gè)叫 Message 的類(lèi)型,它擁有“服務(wù)名字”“會(huì)話(huà)ID”兩個(gè)消息頭字段,用以完成“分發(fā)”和“會(huì)話(huà)保持”功能。而消息體則被放在一個(gè)字節(jié)數(shù)組中,并記錄下字節(jié)數(shù)組的長(zhǎng)度。

    enum MessageType {TypeError, ///< 錯(cuò)誤的協(xié)議TypeRequest, ///< 請(qǐng)求類(lèi)型,從客戶(hù)端發(fā)往服務(wù)器TypeResponse, ///< 響應(yīng)類(lèi)型,服務(wù)器收到請(qǐng)求后返回TypeNotice ///< 通知類(lèi)型,服務(wù)器主動(dòng)通知客戶(hù)端 };///@brief 通信消息體的基類(lèi) ///基本上是一個(gè) char[] 緩沖區(qū) struct Message { public:static int MAX_MAESSAGE_LENGTH;static int MAX_HEADER_LENGTH;MessageType type; ///< 此消息體的類(lèi)型(MessageType)信息virtual ~Message(); virtual Message& operator=(const Message& right);/*** @brief 把數(shù)據(jù)拷貝進(jìn)此包體緩沖區(qū)*/void SetData(const char* input_ptr, int input_length);///@brief 獲得數(shù)據(jù)指針inline char* GetData() const{return data_;}///@brief 獲得數(shù)據(jù)長(zhǎng)度inline int GetDataLen() const{return data_len_;}char* GetHeader() const;int GetHeaderLen() const;protected:Message();Message(const Message& message);private:char* data_; // 包體內(nèi)容緩沖區(qū)int data_len_; // 包體長(zhǎng)度};

    根據(jù)之前設(shè)計(jì)的“請(qǐng)求響應(yīng)”和“通知”兩種通信模式,需要設(shè)計(jì)出三種消息類(lèi)型繼承于 Message,他們是:

    • Request 請(qǐng)求包
    • Response 響應(yīng)包
    • Notice 通知包

    Request 和 Response 兩個(gè)類(lèi),都有記錄序列號(hào)的 seq_id 字段,但 Notice 沒(méi)有。Protocol 類(lèi)就是負(fù)責(zé)把一段 buffer 字節(jié)數(shù)組,轉(zhuǎn)換成 Message 的子類(lèi)對(duì)象。所以需要針對(duì)三種 Message 的子類(lèi)型都實(shí)現(xiàn)對(duì)應(yīng)的 Encode() / Decode() 方法。

    class Protocol {public:virtual ~Protocol() {}/*** @brief 把請(qǐng)求消息編碼成二進(jìn)制數(shù)據(jù)* 編碼,把msg編碼到buf里面,返回寫(xiě)入了多長(zhǎng)的數(shù)據(jù),如果超過(guò)了 len,則返回-1表示錯(cuò)誤。* 如果返回 0 ,表示不需要編碼,框架會(huì)直接從 msg 的緩沖區(qū)讀取數(shù)據(jù)發(fā)送。* @param buf 目標(biāo)數(shù)據(jù)緩沖區(qū)* @param offset 目標(biāo)偏移量* @param len 目標(biāo)數(shù)據(jù)長(zhǎng)度* @param msg 輸入消息對(duì)象* @return 編碼完成所用的字節(jié)數(shù),如果 < 0 表示出錯(cuò)*/virtual int Encode(char* buf, int offset, int len, const Request& msg) = 0;/*** 編碼,把msg編碼到buf里面,返回寫(xiě)入了多長(zhǎng)的數(shù)據(jù),如果超過(guò)了 len,則返回-1表示錯(cuò)誤。* 如果返回 0 ,表示不需要編碼,框架會(huì)直接從 msg 的緩沖區(qū)讀取數(shù)據(jù)發(fā)送。* @param buf 目標(biāo)數(shù)據(jù)緩沖區(qū)* @param offset 目標(biāo)偏移量* @param len 目標(biāo)數(shù)據(jù)長(zhǎng)度* @param msg 輸入消息對(duì)象* @return 編碼完成所用的字節(jié)數(shù),如果 < 0 表示出錯(cuò)*/virtual int Encode(char* buf, int offset, int len, const Response& msg) = 0;/*** 編碼,把msg編碼到buf里面,返回寫(xiě)入了多長(zhǎng)的數(shù)據(jù),如果超過(guò)了 len,則返回-1表示錯(cuò)誤。* 如果返回 0 ,表示不需要編碼,框架會(huì)直接從 msg 的緩沖區(qū)讀取數(shù)據(jù)發(fā)送。* @param buf 目標(biāo)數(shù)據(jù)緩沖區(qū)* @param offset 目標(biāo)偏移量* @param len 目標(biāo)數(shù)據(jù)長(zhǎng)度* @param msg 輸入消息對(duì)象* @return 編碼完成所用的字節(jié)數(shù),如果 < 0 表示出錯(cuò)*/virtual int Encode(char* buf, int offset, int len, const Notice& msg) = 0;/*** 開(kāi)始編碼,會(huì)返回即將解碼出來(lái)的消息類(lèi)型,以便使用者構(gòu)造合適的對(duì)象。* 實(shí)際操作是在進(jìn)行“分包”操作。* @param buf 輸入緩沖區(qū)* @param offset 輸入偏移量* @param len 緩沖區(qū)長(zhǎng)度* @param msg_type 輸出參數(shù),表示下一個(gè)消息的類(lèi)型,只在返回值 > 0 的情況下有效,否則都是 TypeError* @return 如果返回0表示分包未完成,需要繼續(xù)分包。如果返回-1表示協(xié)議包頭解析出錯(cuò)。其他返回值表示這個(gè)消息包占用的長(zhǎng)度。*/virtual int DecodeBegin(const char* buf, int offset, int len,MessageType* msg_type) = 0;/*** 解碼,把之前DecodeBegin()的buf數(shù)據(jù)解碼成具體消息對(duì)象。* @param request 輸出參數(shù),解碼對(duì)象會(huì)寫(xiě)入此指針* @return 返回0表示成功,-1表示失敗。*/virtual int Decode(Request* request) = 0;/*** 解碼,把之前DecodeBegin()的buf數(shù)據(jù)解碼成具體消息對(duì)象。* @param request 輸出參數(shù),解碼對(duì)象會(huì)寫(xiě)入此指針* @return 返回0表示成功,-1表示失敗。*/virtual int Decode(Response* response) = 0;/*** 解碼,把之前DecodeBegin()的buf數(shù)據(jù)解碼成具體消息對(duì)象。* @param request 輸出參數(shù),解碼對(duì)象會(huì)寫(xiě)入此指針* @return 返回0表示成功,-1表示失敗。*/virtual int Decode(Notice* notice) = 0;protected:Protocol() {}};

    這里有一點(diǎn)需要注意,由于 C++ 沒(méi)有內(nèi)存垃圾搜集和反射的能力,在解釋數(shù)據(jù)的時(shí)候,并不能一步就把一個(gè) char[] 轉(zhuǎn)換成某個(gè)子類(lèi)對(duì)象,而必須分成兩步處理。

  • 先通過(guò) DecodeBegin() 來(lái)返回,將要解碼的數(shù)據(jù)是屬于哪個(gè)子類(lèi)型的。同時(shí)完成分包的工作,通過(guò)返回值來(lái)告知調(diào)用者,是否已經(jīng)完整的收到一個(gè)包。
  • 調(diào)用對(duì)應(yīng)類(lèi)型為參數(shù)的 Decode() 來(lái)具體把數(shù)據(jù)寫(xiě)入對(duì)應(yīng)的輸出變量。
  • 對(duì)于 Protocol 的具體實(shí)現(xiàn)子類(lèi),我首先實(shí)現(xiàn)了一個(gè) LineProtocol ,是一個(gè)非常不嚴(yán)謹(jǐn)?shù)?#xff0c;基于文本ASCII編碼的,用空格分隔字段,用回車(chē)分包的協(xié)議。用來(lái)測(cè)試這個(gè)框架是否可行。因?yàn)檫@樣可以直接通過(guò) telnet 工具,來(lái)測(cè)試協(xié)議的編解碼。然后我按照 TLV (Type Length Value)的方法設(shè)計(jì)了一個(gè)二進(jìn)制的協(xié)議。大概的定義如下:

    協(xié)議分包: [消息類(lèi)型:int:2] [消息長(zhǎng)度:int:4] [消息內(nèi)容:bytes:消息長(zhǎng)度]

    消息類(lèi)型取值:

    • 0x00 Error
    • 0x01 Request
    • 0x02 Response
    • 0x03 Notice
    包類(lèi)型字段編碼細(xì)節(jié)
    Request服務(wù)名字段:int:2[字符串內(nèi)容:chars:消息長(zhǎng)度]
    序列號(hào)字段:int:2
    會(huì)話(huà)ID字段:int:2
    消息體字段:int:2[字符串內(nèi)容:chars:消息長(zhǎng)度]
    Response服務(wù)名字段:int:2[字符串內(nèi)容:chars:消息長(zhǎng)度]
    序列號(hào)字段:int:2
    會(huì)話(huà)ID字段:int:2
    消息體字段:int:2[字符串內(nèi)容:chars:消息長(zhǎng)度]
    Notice服務(wù)名字段:int:2[字符串內(nèi)容:chars:消息長(zhǎng)度]
    消息體字段:int:2[字符串內(nèi)容:chars:消息長(zhǎng)度]

    一個(gè)名為 TlvProtocol 的類(lèi)型完成對(duì)這個(gè)協(xié)議的實(shí)現(xiàn)。

    Processor

    處理器層是我設(shè)計(jì)用來(lái)對(duì)接具體業(yè)務(wù)邏輯的抽象層,它主要通過(guò)輸入?yún)?shù) Request 和 Peer 來(lái)獲得客戶(hù)端的輸入數(shù)據(jù),然后通過(guò) Server 類(lèi)的 Reply()/Inform() 來(lái)返回 Response 和 Notice 消息。實(shí)際上 Transport 和 Protocol 的子類(lèi)們,都屬于 net 模塊,而各種 Processor 和 Server/Client 這些功能類(lèi)型,屬于另外一個(gè) processor 模塊。這樣設(shè)計(jì)的原因,是希望所有 processor 模塊的代碼單向的依賴(lài) net 模塊的代碼,但反過(guò)來(lái)不成立。

    Processor 基類(lèi)非常簡(jiǎn)單,就是一個(gè)處理函數(shù)回調(diào)函數(shù)入口 Process():

    ///@brief 處理器基類(lèi),提供業(yè)務(wù)邏輯回調(diào)接口class Processor {public:Processor();virtual ~Processor();/*** 初始化一個(gè)處理器,參數(shù)server為業(yè)務(wù)邏輯提供了基本的能力接口。*/virtual int Init(Server* server, Config* config = NULL);/*** 處理請(qǐng)求-響應(yīng)類(lèi)型包實(shí)現(xiàn)此方法,返回值是0表示成功,否則會(huì)被記錄在錯(cuò)誤日志中。* 參數(shù)peer表示發(fā)來(lái)請(qǐng)求的對(duì)端情況。其中 Server 對(duì)象的指針,可以用來(lái)調(diào)用 Reply(),* Inform() 等方法。如果是監(jiān)聽(tīng)多個(gè)服務(wù)器,server 參數(shù)則會(huì)是不同的對(duì)象。*/virtual int Process(const Request& request, const Peer& peer,Server* server);/*** 關(guān)閉清理處理器所占用的資源*/virtual int Close(); };

    設(shè)計(jì)完 Transport/Protocol/Processor 三個(gè)通信處理層次后,就需要一個(gè)組合這三個(gè)層次的代碼,那就是 Server 類(lèi)。這個(gè)類(lèi)在 Init() 的時(shí)候,需要上面三個(gè)類(lèi)型的子類(lèi)作為參數(shù),以組合成不同功能的服務(wù)器,如:

    TlvProtocol tlv_protocol; // Type Length Value 格式分包協(xié)議,需要和客戶(hù)端一致 TcpTransport tcp_transport; // 使用 TCP 的通信協(xié)議,默認(rèn)監(jiān)聽(tīng) 0.0.0.0:6666 EchoProcessor echo_processor; // 業(yè)務(wù)邏輯處理器 Server server; // DenOS 的網(wǎng)絡(luò)服務(wù)器主對(duì)象 server.Init(&tcp_transport, &tlv_protocol, &echo_processor); // 組裝一個(gè)游戲服務(wù)器對(duì)象:TLV 編碼、TCP 通信和回音服務(wù)

    Server 類(lèi)型還需要一個(gè) Update() 函數(shù),讓用戶(hù)進(jìn)程的“主循環(huán)”不停的調(diào)用,用來(lái)驅(qū)動(dòng)整個(gè)程序的運(yùn)行。這個(gè) Update() 函數(shù)的內(nèi)容非常明確:

  • 檢查網(wǎng)絡(luò)是否有數(shù)據(jù)需要處理(通過(guò) Transport 對(duì)象)
  • 有數(shù)據(jù)的話(huà)就進(jìn)行解碼處理(通過(guò) Protocol 對(duì)象)
  • 解碼成功后進(jìn)行業(yè)務(wù)邏輯的分發(fā)調(diào)用(通過(guò) Processor 對(duì)象)
  • 另外,Server 還需要處理一些額外的功能,比如維護(hù)一個(gè)會(huì)話(huà)緩存池(Session),提供發(fā)送 Response 和 Notice 消息的接口。當(dāng)這些工作都完成后,整套系統(tǒng)已經(jīng)可以用來(lái)作為一個(gè)比較“通用”的網(wǎng)絡(luò)消息服務(wù)器框架存在了。剩下的就是添加各種 Transport/Protocol/Processor 子類(lèi)的工作。

    class Server {public:Server();virtual ~Server();/*** 初始化服務(wù)器,需要選擇組裝你的通信協(xié)議鏈*/int Init(Transport* transport, Protocol* protocol, Processor* processor, Config* config = NULL);/*** 阻塞方法,進(jìn)入主循環(huán)。*/void Start();/*** 需要循環(huán)調(diào)用驅(qū)動(dòng)的方法。如果返回值是0表示空閑。其他返回值表示處理過(guò)的任務(wù)數(shù)。*/virtual int Update();void ClosePeer(Peer* peer, bool is_clear = false); //關(guān)閉當(dāng)個(gè)連接,is_clear 表示是否最終整體清理/*** 關(guān)閉服務(wù)器*/void Close();/*** 對(duì)某個(gè)客戶(hù)端發(fā)送通知消息,* 參數(shù)peer代表要通知的對(duì)端。*/int Inform(const Notice& notice, const Peer& peer);/*** 對(duì)某個(gè) Session ID 對(duì)應(yīng)的客戶(hù)端發(fā)送通知消息,返回 0 表示可以發(fā)送,其他值為發(fā)送失敗。* 此接口能支持?jǐn)嗑€(xiàn)重連,只要客戶(hù)端已經(jīng)成功連接,并使用舊的 Session ID,同樣有效。*/int Inform(const Notice& notice, const std::string& session_id);/*** 對(duì)某個(gè)客戶(hù)端發(fā)來(lái)的Request發(fā)回回應(yīng)消息。* 參數(shù)response的成員seqid必須正確填寫(xiě),才能正確回應(yīng)。* 返回0成功,其它值(-1)表示失敗。*/int Reply(Response* response, const Peer& peer);/*** 對(duì)某個(gè) Session ID 對(duì)應(yīng)的客戶(hù)端發(fā)送回應(yīng)消息。* 參數(shù) response 的 seqid 成員系統(tǒng)會(huì)自動(dòng)填寫(xiě)會(huì)話(huà)中記錄的數(shù)值。* 此接口能支持?jǐn)嗑€(xiàn)重連,只要客戶(hù)端已經(jīng)成功連接,并使用舊的 Session ID,同樣有效。* 返回0成功,其它值(-1)表示失敗。*/int Reply(Response* response, const std::string& session_id);/*** 會(huì)話(huà)功能*/Session* GetSession(const std::string& session_id = "", bool use_this_id = false);Session* GetSessionByNumId(int session_id = 0);bool IsExist(const std::string& session_id);};

    有了 Server 類(lèi)型,肯定也需要有 Client 類(lèi)型。而 Client 類(lèi)型的設(shè)計(jì)和 Server 類(lèi)似,但就不是使用 Transport 接口作為傳輸層,而是 Connector 接口。不過(guò) Protocol 的抽象層是完全重用的。Client 并不需要 Processor 這種形式的回調(diào),而是直接傳入接受數(shù)據(jù)消息就發(fā)起回調(diào)的接口對(duì)象 ClientCallback。

    class ClientCallback {public:ClientCallback() {}virtual ~ClientCallback() {// Do nothing}/*** 當(dāng)連接建立成功時(shí)回調(diào)此方法。* @return 返回 -1 表示不接受這個(gè)連接,需要關(guān)閉掉此連接。*/virtual int OnConnected() {return 0;}/*** 當(dāng)網(wǎng)絡(luò)連接被關(guān)閉的時(shí)候,調(diào)用此方法*/virtual void OnDisconnected() { // Do nothing}/*** 收到響應(yīng),或者請(qǐng)求超時(shí),此方法會(huì)被調(diào)用。* @param response 從服務(wù)器發(fā)來(lái)的回應(yīng)* @return 如果返回非0值,服務(wù)器會(huì)打印一行錯(cuò)誤日志。*/virtual int Callback(const Response& response) {return 0;}/*** 當(dāng)請(qǐng)求發(fā)生錯(cuò)誤,比如超時(shí)的時(shí)候,返回這個(gè)錯(cuò)誤* @param err_code 錯(cuò)誤碼*/virtual void OnError(int err_code){WARN_LOG("The request is timeout, err_code: %d", err_code);}/*** 收到通知消息時(shí),此方法會(huì)被調(diào)用*/virtual int Callback(const Notice& notice) {return 0;}/*** 返回此對(duì)象是否應(yīng)該被刪除。此方法會(huì)被在 Callback() 調(diào)用前調(diào)用。* @return 如果返回 true,則會(huì)調(diào)用 delete 此對(duì)象的指針。*/virtual bool ShouldBeRemoved() {return false;} };class Client : public Updateable {public:Client(); virtual ~Client();/*** 連接服務(wù)器* @param connector 傳輸協(xié)議,如 TCP, UDP ...* @param protocol 分包協(xié)議,如 TLV, Line, TDR ...* @param notice_callback 收到通知后觸發(fā)的回調(diào)對(duì)象,如果傳輸協(xié)議有“連接概念”(如TCP/TCONND),建立、關(guān)閉連接時(shí)也會(huì)調(diào)用。* @param config 配置文件對(duì)象,將讀取以下配置項(xiàng)目:MAX_TRANSACTIONS_OF_CLIENT 客戶(hù)端最大并發(fā)連接數(shù); BUFFER_LENGTH_OF_CLIENT客戶(hù)端收包緩存;CLIENT_RESPONSE_TIMEOUT 客戶(hù)端響應(yīng)等待超時(shí)時(shí)間。* @return 返回 0 表示成功,其他表示失敗*/int Init(Connector* connector, Protocol* protocol,ClientCallback* notice_callback = NULL, Config* config = NULL);/*** callback 參數(shù)可以為 NULL,表示不需要回應(yīng),只是單純的發(fā)包即可。*/virtual int SendRequest(Request* request, ClientCallback* callback = NULL);/*** 返回值表示有多少數(shù)據(jù)需要處理,返回-1為出錯(cuò),需要關(guān)閉連接。返回0表示沒(méi)有數(shù)據(jù)需要處理。*/virtual int Update();virtual void OnExit();void Close();Connector* connector() ;ClientCallback* notice_callback() ;Protocol* protocol() ; };

    至此,客戶(hù)端和服務(wù)器端基本設(shè)計(jì)完成,可以直接通過(guò)編寫(xiě)測(cè)試代碼,來(lái)檢查是否運(yùn)行正常。

    此文已由騰訊云+社區(qū)在各渠道發(fā)布,一切權(quán)利歸作者所有

    獲取更多新鮮技術(shù)干貨,可以關(guān)注我們騰訊云技術(shù)社區(qū)-云加社區(qū)官方號(hào)及知乎機(jī)構(gòu)號(hào)

    總結(jié)

    以上是生活随笔為你收集整理的教你从头写游戏服务器框架的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。

    如果覺(jué)得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。