1. 程式人生 > >教你從頭寫遊戲伺服器框架

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

浪費了“黃金五年”的Java程式設計師,還有救嗎? >>>   

本文由雲+社群發表

作者:韓偉

前言

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

這個框架的基本執行環境是 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 或者其他現成功能,其地位和“工具層”一樣不應該呼叫其他層程式碼,甚至不應該修改其原始碼

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

說明通訊處理器快取持久化
功能實現TcpUdpKcpTlvLineJsonHandlerObjectProcessorSessionLocalCacheRedisMapRamMapZooKeeperMapFileDataStoreRedisDataStroe
介面定義TransferProtocolServerClientProcessorDataMapSerializableDataStore
工具類庫ConfigLOGJSONCoroutine

通訊模組

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

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

擁有了以上兩個層次,是可以完成最基本的協議層能力了。但是,我們往往希望業務資料的協議包,能自動化的成為程式設計中的 物件,所以在處理訊息體這裡,需要一個可選的額外層次,用來把位元組陣列,轉換成物件。所以我設計了一個特別的處理器:ObjectProcessor ,去規範通訊模組中物件序列化、反序列化的介面。

輸入層次功能輸出
dataTransport通訊buffer
bufferProtocol分包Message
MessageProcessor分發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() ;
};

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

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

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