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

qcloud發表於2019-03-05

> 本文由雲 + 社群發表

> 作者:韓偉

前言

大概已經有差不多一年沒寫技術文章了,原因是今年投入了一些具體遊戲專案的開發。這些新的遊戲專案,比較接近獨立遊戲的開發方式。我覺得公司的 “祖傳” 伺服器框架技術不太適合,所以從頭寫了一個遊戲伺服器端的框架,以便獲得更好的開發效率和靈活性。現在專案將近上線,有時間就想總結一下,這樣一個遊戲伺服器框架的設計和實現過程。

這個框架的基本執行環境是 Linux ,採用 C++ 編寫。為了能在各種環境上執行和使用,所以採用了 gcc 4.8 這個 “古老” 的編譯器,以 C99 規範開發。

需求

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

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

img

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

功能性需求

  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 的叢集管理。自動容災和自動擴容,其實關鍵點是服務程式的狀態同步和管理。我希望一個通用的底層,可以把所有的伺服器間呼叫,都通過一個統一的集權管理模型管理起來,這樣就可以不再每個專案去關心叢集間通訊、定址等問題。

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

層次 功能 約束
邏輯層 實現更具體的業務邏輯 能呼叫所有下層程式碼,但應主要依賴介面層
實現層 對各種具體的通訊協議、儲存裝置等功能的實現 滿足下層的介面層來做實現,禁止同層間互相呼叫
介面層 定義了各模組的基本使用方式,用以隔離具體的實現和設計,從而提供互相替換的能力 本層之間程式碼可以互相呼叫,但禁止呼叫上層程式碼
工具層 提供通用的 C++ 工具庫功能,如 log/json/ini/日期時間/字串處理 等等 不應該呼叫其他層程式碼,也不應該呼叫同層其他模組
第三方庫 提供諸如 redis/tcaplus 或者其他現成功能,其地位和 “工具層” 一樣 不應該呼叫其他層程式碼,甚至不應該修改其原始碼

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

說明 通訊 處理器 快取 持久化
功能實現 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][長度:int:2][字串內容:chars:訊息長度]
序列號 [欄位:int:2][整數內容:int:4]
會話 ID [欄位:int:2][整數內容:int:4]
訊息體 [欄位:int:2][長度:int:2][字串內容:chars:訊息長度]
Response 服務名 [欄位:int:2][長度:int:2][字串內容:chars:訊息長度]
序列號 [欄位:int:2][整數內容:int:4]
會話 ID [欄位:int:2][整數內容:int:4]
訊息體 [欄位:int:2][長度:int:2][字串內容:chars:訊息長度]
Notice 服務名 [欄位:int:2][長度:int:2][字串內容:chars:訊息長度]
訊息體 [欄位:int:2][長度: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() ;
};

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

此文已由騰訊雲 + 社群在各渠道釋出,一切權利歸作者所有

獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號

更多原創文章乾貨分享,請關注公眾號
  • 教你從頭寫遊戲伺服器框架
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章