查看原文
其他

硬核干货丨教你写游戏服务器框架

韩大wade 腾讯GWB游戏无界 2022-08-30

关于作者:韩伟,腾讯互娱高级工程师,目前在Next产品中心研发创新类型游戏。


前言:从去年开始笔者投入了一些具体游戏项目的开发,这些新的游戏项目,比较接近独立游戏的开发方式。在这个过程中笔者从头写了一个游戏服务器端的框架,以便获得更好的开发效率和灵活性。现在项目将近上线,有时间就想总结一下,这样一个游戏服务器框架的设计和实现过程。这个框架的基本运行环境是 Linux ,采用 C++ 编写。为了能在各种环境上运行和使用,所以采用了 gcc4.8 这个“古老”的编译器,以 C99 规范开发。

                            




需求


由于“越通用的代码,就是越没用的代码”,所以在设计之初,我就认为应该使用分层的模式来构建整个系统。按照游戏服务器的一般需求划分,最基本的可以分为两层:


  1. 底层基础功能:包括通信、持久化等非常通用的部分,关注的是性能、易用性、扩展性等指标。

  2. 高层逻辑功能:包括具体的游戏逻辑,针对不同的游戏会有不同的设计。


我希望能有一个基本完整的“底层基础功能”的框架,可以被复用于多个不同的游戏。由于目标是开发一个适合独立游戏开发的游戏服务器框架。所以最基本的需求分析为:


  ↓ 功能性需求 


  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 或者其他现成功能,其地位和“工具层”一样不应该调用其他层代码,甚至不应该修改其源码


最后,整体的架构模块类似:


说明通信处理器缓存持久化
功能实现

Tcp

Udp

Kcp

Tlv

Line

JsonHandler
ObjectProcessor

Session
LocalCache

RedisMap

RamMap

ZooKeeperMap

FileDataStore
RedisDataStroe

接口定义

Transfer

Protocol

Server

Client

Processor

DataMap
Serializable

DataStore

工具类库

Config

LOG

JSON

Coroutine



通信模块


对于通信模块来说,需要有灵活的可替换协议的能力,就必须按一定的层次进行进一步的划分。对于游戏来说,最底层的通信协议,一般会使用 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() ;};


至此,客户端和服务器端基本设计完成,可以直接通过编写测试代码,来检查是否运行正常。


·END·


今日推荐  

·如何做好游戏的前5分钟?

·让玩家玩上600小时的单机游戏,制作人设计思路分享

·《刀塔自走棋》为什么爆火?这类游戏会是下一个蓝海吗?

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存