教你從頭寫遊戲服務器框架

本文由雲+社區發表

做者:韓偉javascript

前言

大概已經有差很少一年沒寫技術文章了,緣由是今年投入了一些具體遊戲項目的開發。這些新的遊戲項目,比較接近獨立遊戲的開發方式。我以爲公司的「祖傳」服務器框架技術不太適合,因此從頭寫了一個遊戲服務器端的框架,以便得到更好的開發效率和靈活性。如今項目將近上線,有時間就想總結一下,這樣一個遊戲服務器框架的設計和實現過程。html

這個框架的基本運行環境是 Linux ,採用 C++ 編寫。爲了能在各類環境上運行和使用,因此採用了 gcc 4.8 這個「古老」的編譯器,以 C99 規範開發。html5

需求

因爲「越通用的代碼,就是越沒用的代碼」,因此在設計之初,我就認爲應該使用分層的模式來構建整個系統。按照遊戲服務器的通常需求劃分,最基本的能夠分爲兩層:java

  1. 底層基礎功能:包括通訊、持久化等很是通用的部分,關注的是性能、易用性、擴展性等指標。
  2. 高層邏輯功能:包括具體的遊戲邏輯,針對不一樣的遊戲會有不一樣的設計。

img

我但願能有一個基本完整的「底層基礎功能」的框架,能夠被複用於多個不一樣的遊戲。因爲目標是開發一個 適合獨立遊戲開發 的遊戲服務器框架。因此最基本的需求分析爲:redis

功能性需求數據庫

  1. 併發:全部的服務器程序,都會碰到這個基本的問題:如何處理併發處理。通常來講,會有多線程、異步兩種技術。多線程編程在編碼上比較符合人類的思惟習慣,但帶來了「鎖」這個問題。而異步非阻塞的模型,其程序執行的狀況是比較簡單的,並且也能比較充分的利用硬件性能,可是問題是不少代碼須要以「回調」的形式編寫,對於複雜的業務邏輯來講,顯得很是繁瑣,可讀性很是差。雖然這兩種方案各有利弊,也有人結合這兩種技術但願能各取所長,可是我更傾向於基礎是使用異步、單線程、非阻塞的調度方式,由於這個方案是最清晰簡單的。爲了解決「回調」的問題,咱們能夠在其上再添加其餘的抽象層,好比協程或者添加線程池之類的技術予以改善。
  2. 通訊:支持 請求響應 模式以及 通知 模式的通訊(廣播視爲一種多目標的通知)。遊戲有不少登陸、買賣、打開揹包之類的功能,都是明確的有請求和響應的。而大量的聯機遊戲中,多個客戶端的位置、HP 等東西都須要通過網絡同步,其實就是一種「主動通知」的通訊方式。
  3. 持久化:能夠存取 對象 。遊戲存檔的格式很是複雜,但其索引的需求每每都是根據玩家 ID 來讀寫就能夠。在不少遊戲主機如 PlayStation 上,之前的存檔都是能夠以相似「文件」的方式存放在記憶卡里的。因此遊戲持久化最基本的需求,就是一個 key-value 存取模型。固然,遊戲中還會有更復雜的持久化需求,好比排行榜、拍賣行等,這些需求應該額外對待,不適合包含在一個最基本的通用底層中。
  4. 緩存:支持遠程、分佈式的對象緩存。遊戲服務基本上都是「帶狀態」的服務,由於遊戲要求響應延遲很是苛刻,基本上都須要利用服務器進程的內存來存放過程數據。可是遊戲的數據,每每是變化越快的,價值越低,好比經驗值、金幣、HP,而等級、裝備等變化比較慢的,價值則越高,這種特徵,很是適合用一個緩存模型來處理。
  5. 協程:能夠用 C++ 來編寫協程代碼,避免大量回調函數分割代碼。這個是對於異步代碼很是有用的特性,能大大提升代碼的可讀性和開發效率。特別是把不少底層涉及IO的功能,都提供了協程化 API,使用起來就會像同步的 API 同樣輕鬆愜意。
  6. 腳本:初步設想是支持能夠用 Lua 來編寫業務邏輯。遊戲需求變化是出了名快的,用腳本語言編寫業務邏輯正好能提供這方面的支持。實際上腳本在遊戲行業裏的使用很是普遍。因此支持腳本,也是一個遊戲服務器框架很重要的能力。
  7. 其餘功能:包括定時器、服務器端的對象管理等等。這些功能很經常使用,因此也須要包含在框架中,但已經有不少成熟方案,因此只要選取常見易懂的模型便可。好比對象管理,我會採用相似 Unity 的組件模型來實現。

非功能性需求編程

  1. 靈活性:支持可替換的通訊協議;可替換的持久化設備(如數據庫);可替換的緩存設備(如 memcached/redis);以靜態庫和頭文件的方式發佈,不對使用者代碼作過多的要求。遊戲的運營環境比較複雜,特別是在不一樣的項目之間,可能會使用不一樣的數據庫、不一樣的通訊協議。可是遊戲自己業務邏輯不少都是基於對象模型去設計的,因此應該有一層可以基於「對象」來抽象全部這些底層功能的模型。這樣才能讓多個不一樣的遊戲,都基於一套底層進行開發。
  2. 部署便利性:支持靈活的配置文件、命令行參數、環境變量的引用;支持單獨進程啓動,而無須依賴數據庫、消息隊列中間件等設施。通常遊戲都會有至少三套運行環境,包括一個開發環境、一個內測環境、一個外測或運營環境。一個遊戲的版本更新,每每須要更新多個環境。因此如何能儘可能簡化部署就成爲一個很重要的問題。我認爲一個好的服務器端框架,應該能讓這個服務器端程序,在無配置、無依賴的狀況下獨立啓動,以符合在開發、測試、演示環境下快速部署。而且能很簡單的經過配置文件、或者命令行參數的不一樣,在集羣化下的外部測試或者運營環境下啓動。
  3. 性能:不少遊戲服務器,都會使用異步非阻塞的方式來編程。由於異步非阻塞能夠很好的提升服務器的吞吐量,並且能夠很明確的控制多個用戶任務併發下的代碼執行順序,從而避免多線程鎖之類的複雜問題。因此這個框架我也但願是以異步非阻塞做爲基本的併發模型。這樣作還有另一個好處,就是能夠手工的控制具體的進程,充分利用多核 CPU 服務器的性能。固然異步代碼可讀性由於大量的回調函數,會變得很難閱讀,幸虧咱們還能夠用「協程」來改善這個問題。
  4. 擴展性:支持服務器之間的通訊,進程狀態管理,相似 SOA 的集羣管理。自動容災和自動擴容,其實關鍵點是服務進程的狀態同步和管理。我但願一個通用的底層,能夠把全部的服務器間調用,都經過一個統一的集權管理模型管理起來,這樣就能夠再也不每一個項目去關心集羣間通訊、尋址等問題。

一旦需求明確下來,基本的層級結構也能夠設計了:json

層次 功能 約束
邏輯層 實現更具體的業務邏輯 能調用全部下層代碼,但應主要依賴接口層
實現層 對各類具體的通訊協議、存儲設備等功能的實現 知足下層的接口層來作實現,禁止同層間互相調用
接口層 定義了各模塊的基本使用方式,用以隔離具體的實現和設計,從而提供互相替換的能力 本層之間代碼能夠互相調用,但禁止調用上層代碼
工具層 提供通用的 C++ 工具庫功能,如 log/json/ini/日期時間/字符串處理 等等 不該該調用其餘層代碼,也不該該調用同層其餘模塊
第三方庫 提供諸如 redis/tcaplus 或者其餘現成功能,其地位和「工具層」同樣 不該該調用其餘層代碼,甚至不該該修改其源碼

最後,總體的架構模塊相似:api

說明 通訊 處理器 緩存 持久化
功能實現 TcpUdpKcpTlvLine JsonHandlerObjectProcessor SessionLocalCacheRedisMapRamMapZooKeeperMap FileDataStoreRedisDataStroe
接口定義 TransferProtocol ServerClientProcessor DataMapSerializable DataStore
工具類庫 ConfigLOGJSONCoroutine

通訊模塊

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

在協議層面,最基本的需求有「分包」「分發」「對象序列化」等幾種需求。若是要支持「請求-響應」模式,還須要在協議中帶上「序列號」的數據,以便對應「請求」和「響應」。另外,遊戲一般都是一種「會話」式的應用,也就是一系列的請求,會被視爲一次「會話」,這就須要協衆須要有相似 Session ID 這種數據。爲了知足這些需求,設計一個層次爲: Protocol

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

輸入 層次 功能 輸出
data Transport 通訊 buffer
buffer Protocol 分包 Message
Message Processor 分發 object
object 處理模塊 處理 業務邏輯

Transport

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

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

  1. 在主循環中,不斷嘗試讀取有什麼數據可讀
  2. 若是上一步返回有數據到達了,則讀取數據
  3. 讀取數據處理後,須要發送數據,則向網絡寫入數據

根據上面三個特色,能夠概括出一個基本的接口:

class Transport {
public:   
   /**
    * 初始化Transport對象,輸入Config對象配置最大鏈接數等參數,能夠是一個新建的Config對象。
    */   
   virtual int Init(Config* config) = 0;

   /**
    * 檢查是否有數據能夠讀取,返回可讀的事件數。後續代碼應該根據此返回值循環調用Read()提取數據。
    * 參數fds用於返回出現事件的全部fd列表,len表示這個列表的最大長度。若是可用事件大於這個數字,並不影響後續能夠Read()的次數。
    * fds的內容,若是出現負數,表示有一個新的終端等待接入。
    */
   virtual int Peek(int* fds, int len) = 0;

   /**
    * 讀取網絡管道中的數據。數據放在輸出參數 peer 的緩衝區中。
    * @param peer 參數是產生事件的通訊對端對象。
    * @return 返回值爲可讀數據的長度,若是是 0 表示沒有數據能夠讀,返回 -1 表示鏈接須要被關閉。
    */
   virtual int Read( Peer* peer) = 0;

   /**
    * 寫入數據,output_buf, buf_len爲想要寫入的數據緩衝區,output_peer爲目標隊端,
    * 返回值表示成功寫入了的數據長度。-1表示寫入出錯。
    */
   virtual int Write(const char* output_buf, int buf_len, const Peer& output_peer) = 0;

   /**
    * 關閉一個對端的鏈接
    */
   virtual void ClosePeer(const Peer& peer) = 0;

   /**
    * 關閉Transport對象。
    */
   virtual void Close() = 0;

}

在上面的定義中,能夠看到須要有一個 Peer 類型。這個類型是爲了表明通訊的客戶端(對端)對象。在通常的 Linux 系統中,通常咱們用 fd (File Description)來表明。可是由於在框架中,咱們還須要爲每一個客戶端創建接收數據的緩存區,以及記錄通訊地址等功能,因此在 fd 的基礎上封裝了一個這樣的類型。這樣也有利於把 UDP 通訊以不一樣客戶端的模型,進行封裝。

///@brief 此類型負責存放鏈接過來的客戶端信息和數據緩衝區
class Peer {
public:    
    int buf_size_;      ///< 緩衝區長度
    char* const buffer_;///< 緩衝區起始地址
    int produced_pos_;  ///< 填入了數據的長度
    int consumed_pos_;  ///< 消耗了數據的長度

    int GetFd() const;
    void SetFd(int fd);    /// 得到本地地址
    const struct sockaddr_in& GetLocalAddr() const;
    void SetLocalAddr(const struct sockaddr_in& localAddr);    /// 得到遠程地址

    const struct sockaddr_in& GetRemoteAddr() const;
    void SetRemoteAddr(const struct sockaddr_in& remoteAddr);

private:
    int fd_;                            ///< 收發數據用的fd
    struct sockaddr_in remote_addr_;    ///< 對端地址
    struct sockaddr_in local_addr_;     ///< 本端地址
};

遊戲使用 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 自己就支持異步模型,因此其實現也不費太多功夫。

/**
 * @brief 客戶端使用的鏈接器類,表明傳輸協議,如 TCP 或 UDP
 */
class Connector {

public:    virtual ~Connector() {}    
 
    /**
     * @brief 初始化創建鏈接等
     * @param config 須要的配置
     * @return 0 爲成功
     */
    virtual int Init(Config* config) = 0;

    /**
     * @brief 關閉
     */
    virtual void Close() = 0;

    /**
     * @brief 讀取是否有網絡數據到來
     * 讀取有無數據到來,返回值爲可讀事件的數量,一般爲1
     * 若是爲0表示沒有數據能夠讀取。
     * 若是返回 -1 表示出現網絡錯誤,須要關閉此鏈接。
     * 若是返回 -2 表示此鏈接成功連上對端。
     * @return 網絡數據的狀況
     */
    virtual int Peek() = 0;

    /**
     * @brief 讀取網絡數 
     * 讀取鏈接裏面的數據,返回讀取到的字節數,若是返回0表示沒有數據,
     * 若是buffer_length是0, 也會返回0,
     * @return 返回-1表示鏈接須要關閉(各類出錯也返回0)
     */
    virtual int Read(char* ouput_buffer, int buffer_length) = 0;

    /**
     * @brief 把input_buffer裏的數據寫入網絡鏈接,返回寫入的字節數。
     * @return 若是返回-1表示寫入出錯,須要關閉此鏈接。
     */
   virtual int Write(const char* input_buffer, int buffer_length) = 0;

protected:
    Connector(){}
};

Protocol

對於通訊「協議」來講,其實包含了許許多多的含義。在衆多的需求中,我所定義的這個協議層,只但願完成四個最基本的能力:

  1. 分包:從流式傳輸層切分出一個個單獨的數據單元,或者把多個「碎片」數據拼合成一個完整的數據單元的能力。通常解決這個問題,須要在協議頭部添加一個「長度」字段。
  2. 請求響應對應:這對於異步非阻塞的通訊模式下,是很是重要的功能。由於可能在一瞬間發出了不少個請求,而回應則會不分前後的到達。協議頭部若是有一個不重複的「序列號」字段,就能夠對應起哪一個迴應是屬於哪一個請求的。
  3. 會話保持:因爲遊戲的底層網絡,可能會使用 UDP 或者 HTTP 這種非長鏈接的傳輸方式,因此要在邏輯上保持一個會話,就不能單純的依靠傳輸層。加上咱們都但願程序有抗網絡抖動、斷線重連的能力,因此保持會話成爲一個常見的需求。我參考在 Web 服務領域的會話功能,設計了一個 Session 功能,在協議中加上 Session ID 這樣的數據,就能比較簡單的保持會話。
  4. 分發:遊戲服務器一定會包含多個不一樣的業務邏輯,所以須要多種不一樣數據格式的協議包,爲了把對應格式的數據轉發。

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

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

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

enum MessageType {
    TypeError, ///< 錯誤的協議
    TypeRequest, ///< 請求類型,從客戶端發往服務器
    TypeResponse, ///< 響應類型,服務器收到請求後返回
    TypeNotice  ///< 通知類型,服務器主動通知客戶端
};

///@brief 通訊消息體的基類
///基本上是一個 char[] 緩衝區
struct Message {
public:
    static int MAX_MAESSAGE_LENGTH;
    static int MAX_HEADER_LENGTH;
  
    MessageType type;  ///< 此消息體的類型(MessageType)信息

    virtual ~Message();    virtual Message& operator=(const Message& right);

    /**
     * @brief 把數據拷貝進此包體緩衝區
     */
    void SetData(const char* input_ptr, int input_length);

    ///@brief 得到數據指針
    inline char* GetData() const{
        return data_;
    }

     ///@brief 得到數據長度
    inline int GetDataLen() const{
        return data_len_;
    }
 
    char* GetHeader() const;
    int GetHeaderLen() const;

protected:
    Message();
    Message(const Message& message);
 
private:
    char* data_;                  // 包體內容緩衝區
    int data_len_;                // 包體長度

};

根據以前設計的「請求響應」和「通知」兩種通訊模式,須要設計出三種消息類型繼承於 Message,他們是:

  • Request 請求包
  • Response 響應包
  • Notice 通知包

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

class Protocol {

public:
    virtual ~Protocol() {
    }

    /**
     * @brief 把請求消息編碼成二進制數據
     * 編碼,把msg編碼到buf裏面,返回寫入了多長的數據,若是超過了 len,則返回-1表示錯誤。
     * 若是返回 0 ,表示不須要編碼,框架會直接從 msg 的緩衝區讀取數據發送。
     * @param buf 目標數據緩衝區
     * @param offset 目標偏移量
     * @param len 目標數據長度
     * @param msg 輸入消息對象
     * @return 編碼完成所用的字節數,若是 < 0 表示出錯
     */
    virtual int Encode(char* buf, int offset, int len, const Request& msg) = 0;

    /**
     * 編碼,把msg編碼到buf裏面,返回寫入了多長的數據,若是超過了 len,則返回-1表示錯誤。
     * 若是返回 0 ,表示不須要編碼,框架會直接從 msg 的緩衝區讀取數據發送。
     * @param buf 目標數據緩衝區
     * @param offset 目標偏移量
     * @param len 目標數據長度
     * @param msg 輸入消息對象
     * @return 編碼完成所用的字節數,若是 < 0 表示出錯
     */
    virtual int Encode(char* buf, int offset, int len, const Response& msg) = 0;

    /**
     * 編碼,把msg編碼到buf裏面,返回寫入了多長的數據,若是超過了 len,則返回-1表示錯誤。
     * 若是返回 0 ,表示不須要編碼,框架會直接從 msg 的緩衝區讀取數據發送。
     * @param buf 目標數據緩衝區
     * @param offset 目標偏移量
     * @param len 目標數據長度
     * @param msg 輸入消息對象
     * @return 編碼完成所用的字節數,若是 < 0 表示出錯
     */
    virtual int Encode(char* buf, int offset, int len, const Notice& msg) = 0;

    /**
     * 開始編碼,會返回即將解碼出來的消息類型,以便使用者構造合適的對象。
     * 實際操做是在進行「分包」操做。
     * @param buf 輸入緩衝區
     * @param offset 輸入偏移量
     * @param len 緩衝區長度
     * @param msg_type 輸出參數,表示下一個消息的類型,只在返回值 > 0 的狀況下有效,不然都是 TypeError
     * @return 若是返回0表示分包未完成,須要繼續分包。若是返回-1表示協議包頭解析出錯。其餘返回值表示這個消息包占用的長度。
     */
    virtual int DecodeBegin(const char* buf, int offset, int len,
                            MessageType* msg_type) = 0;

    /**
     * 解碼,把以前DecodeBegin()的buf數據解碼成具體消息對象。
     * @param request 輸出參數,解碼對象會寫入此指針
     * @return 返回0表示成功,-1表示失敗。
     */
    virtual int Decode(Request* request) = 0;

    /**
     * 解碼,把以前DecodeBegin()的buf數據解碼成具體消息對象。
     * @param request 輸出參數,解碼對象會寫入此指針
     * @return 返回0表示成功,-1表示失敗。
     */
    virtual int Decode(Response* response) = 0;

    /**
     * 解碼,把以前DecodeBegin()的buf數據解碼成具體消息對象。
     * @param request 輸出參數,解碼對象會寫入此指針
     * @return 返回0表示成功,-1表示失敗。
     */
    virtual int Decode(Notice* notice) = 0;protected:

    Protocol() {
    }

};

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

  1. 先經過 DecodeBegin() 來返回,將要解碼的數據是屬於哪一個子類型的。同時完成分包的工做,經過返回值來告知調用者,是否已經完整的收到一個包。
  2. 調用對應類型爲參數的 Decode() 來具體把數據寫入對應的輸出變量。

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

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

消息類型取值:

  • 0x00 Error
  • 0x01 Request
  • 0x02 Response
  • 0x03 Notice
包類型 字段 編碼細節
Request 服務名 字段:int:2[字符串內容:chars:消息長度]
序列號 字段:int:2
會話ID 字段:int:2
消息體 字段:int:2[字符串內容:chars:消息長度]
Response 服務名 字段:int:2[字符串內容:chars:消息長度]
序列號 字段:int:2
會話ID 字段:int:2
消息體 字段:int:2[字符串內容:chars:消息長度]
Notice 服務名 字段:int:2[字符串內容:chars:消息長度]
消息體 字段:int:2[字符串內容:chars:消息長度]

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

Processor

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

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

///@brief 處理器基類,提供業務邏輯回調接口

class Processor {

public:
    Processor();
    virtual ~Processor();
 
    /**
     * 初始化一個處理器,參數server爲業務邏輯提供了基本的能力接口。
     */
    virtual int Init(Server* server, Config* config = NULL);

    /**
     * 處理請求-響應類型包實現此方法,返回值是0表示成功,不然會被記錄在錯誤日誌中。
     * 參數peer表示發來請求的對端狀況。其中 Server 對象的指針,能夠用來調用 Reply(),
     * Inform() 等方法。若是是監聽多個服務器,server 參數則會是不一樣的對象。
     */
    virtual int Process(const Request& request, const Peer& peer,
                        Server* server);

    /**
     * 關閉清理處理器所佔用的資源
     */
    virtual int Close();
};

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

TlvProtocol tlv_protocol;   //  Type Length Value 格式分包協議,須要和客戶端一致
TcpTransport tcp_transport; // 使用 TCP 的通訊協議,默認監聽 0.0.0.0:6666
EchoProcessor echo_processor;   // 業務邏輯處理器
Server server;  // DenOS 的網絡服務器主對象
server.Init(&tcp_transport, &tlv_protocol, &echo_processor);    // 組裝一個遊戲服務器對象:TLV 編碼、TCP 通訊和迴音服務

Server 類型還須要一個 Update() 函數,讓用戶進程的「主循環」不停的調用,用來驅動整個程序的運行。這個 Update() 函數的內容很是明確:

  1. 檢查網絡是否有數據須要處理(經過 Transport 對象)
  2. 有數據的話就進行解碼處理(經過 Protocol 對象)
  3. 解碼成功後進行業務邏輯的分發調用(經過 Processor 對象)

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

class Server {

public:
    Server();
    virtual ~Server();
 
    /**
     * 初始化服務器,須要選擇組裝你的通訊協議鏈
     */
    int Init(Transport* transport, Protocol* protocol, Processor* processor, Config* config = NULL);

    /**
     * 阻塞方法,進入主循環。
     */
    void Start();

    /**
     * 須要循環調用驅動的方法。若是返回值是0表示空閒。其餘返回值表示處理過的任務數。
     */
    virtual int Update();
    void ClosePeer(Peer* peer, bool is_clear = false); //關閉當個鏈接,is_clear 表示是否最終總體清理

    /**
     * 關閉服務器
     */
    void Close();

    /**
     * 對某個客戶端發送通知消息,
     * 參數peer表明要通知的對端。
     */
    int Inform(const Notice& notice, const Peer& peer);

    /**
     * 對某個  Session ID 對應的客戶端發送通知消息,返回 0 表示能夠發送,其餘值爲發送失敗。
     * 此接口能支持斷線重連,只要客戶端已經成功鏈接,並使用舊的 Session ID,一樣有效。
     */
    int Inform(const Notice& notice, const std::string& session_id);

    /**
     * 對某個客戶端發來的Request發回迴應消息。
     * 參數response的成員seqid必須正確填寫,才能正確迴應。
     * 返回0成功,其它值(-1)表示失敗。
     */
    int Reply(Response* response, const Peer& peer);

    /**
     * 對某個 Session ID 對應的客戶端發送迴應消息。
     * 參數 response 的 seqid 成員系統會自動填寫會話中記錄的數值。
     * 此接口能支持斷線重連,只要客戶端已經成功鏈接,並使用舊的 Session ID,一樣有效。
     * 返回0成功,其它值(-1)表示失敗。
     */
    int Reply(Response* response, const std::string& session_id);

    /**
     * 會話功能
     */
    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 類型,確定也須要有 Client 類型。而 Client 類型的設計和 Server 相似,但就不是使用 Transport 接口做爲傳輸層,而是 Connector 接口。不過 Protocol 的抽象層是徹底重用的。Client 並不須要 Processor 這種形式的回調,而是直接傳入接受數據消息就發起回調的接口對象 ClientCallback。

class ClientCallback {

public:
  
    ClientCallback() {
    }
    virtual ~ClientCallback() {
         // Do nothing
    }

    /**
     *  當鏈接創建成功時回調此方法。
     * @return 返回 -1 表示不接受這個鏈接,須要關閉掉此鏈接。
     */
    virtual int OnConnected() {
        return 0;
    }

    /**
     * 當網絡鏈接被關閉的時候,調用此方法
     */
    virtual void OnDisconnected() {        // Do nothing
    }

    /**
     * 收到響應,或者請求超時,此方法會被調用。
     * @param response 從服務器發來的迴應
     * @return 若是返回非0值,服務器會打印一行錯誤日誌。
     */
    virtual int Callback(const Response& response) {
        return 0;
    }

    /**
     * 當請求發生錯誤,好比超時的時候,返回這個錯誤
     * @param err_code 錯誤碼
     */
    virtual void OnError(int err_code){
        WARN_LOG("The request is timeout, err_code: %d", err_code);
    }

    /**
     * 收到通知消息時,此方法會被調用
     */
    virtual int Callback(const Notice& notice) {
        return 0;
    }

    /**
     * 返回此對象是否應該被刪除。此方法會被在 Callback() 調用前調用。
     * @return 若是返回 true,則會調用 delete 此對象的指針。
     */
    virtual bool ShouldBeRemoved() {
        return false;
    }
};

class Client : public Updateable {
 
public:
    Client();    virtual ~Client();

     /**
     * 鏈接服務器
     * @param connector 傳輸協議,如 TCP, UDP ...
     * @param protocol 分包協議,如 TLV, Line, TDR ...
     * @param notice_callback 收到通知後觸發的回調對象,若是傳輸協議有「鏈接概念」(如TCP/TCONND),創建、關閉鏈接時也會調用。
     * @param config 配置文件對象,將讀取如下配置項目:MAX_TRANSACTIONS_OF_CLIENT 客戶端最大併發鏈接數; BUFFER_LENGTH_OF_CLIENT客戶端收包緩存;CLIENT_RESPONSE_TIMEOUT 客戶端響應等待超時時間。
     * @return 返回 0 表示成功,其餘表示失敗
     */
    int Init(Connector* connector, Protocol* protocol,
             ClientCallback* notice_callback = NULL, Config* config = NULL);

    /**
     * callback 參數能夠爲 NULL,表示不須要回應,只是單純的發包便可。
     */
    virtual int SendRequest(Request* request, ClientCallback* callback = NULL);

    /**
     * 返回值表示有多少數據須要處理,返回-1爲出錯,須要關閉鏈接。返回0表示沒有數據須要處理。
     */
    virtual int Update();
    virtual void OnExit();
    void Close();
    Connector* connector() ;
    ClientCallback* notice_callback() ;
    Protocol* protocol() ;
};

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

此文已由騰訊雲+社區在各渠道發佈,一切權利歸做者全部

獲取更多新鮮技術乾貨,能夠關注咱們騰訊雲技術社區-雲加社區官方號及知乎機構號

相關文章
相關標籤/搜索