时间: 2020-09-03 00:08:26 人气: 2285 评论: 0
大概已经有差不多一年没写技术文章了,原因是今年投入了一些具体游戏项目的开发。这些新的游戏项目,比较接近独立游戏的开发方式。我觉得公司的“祖传”服务器框架技术不太适合,所以从头写了一个游戏服务器端的框架,以便获得更好的开发效率和灵活性。现在项目将近上线,有时间就想总结一下,这样一个游戏服务器框架的设计和实现过程。
这个框架的基本运行环境是 Linux ,采用 C++ 编写。为了能在各种环境上运行和使用,所以采用了 gcc 4.8 这个“古老”的编译器,以 C99 规范开发。
由于“越通用的代码,就是越没用的代码”,所以在设计之初,我就认为应该使用分层的模式来构建整个系统。按照游戏服务器的一般需求划分,最基本的可以分为两层:
我希望能有一个基本完整的“底层基础功能”的框架,可以被复用于多个不同的游戏。由于目标是开发一个 适合独立游戏开发 的游戏服务器框架。所以最基本的需求分析为:
功能性需求
非功能性需求
一旦需求明确下来,基本的层级结构也可以设计了:
层次 | 功能 | 约束 |
|---|---|---|
逻辑层 | 实现更具体的业务逻辑 | 能调用所有下层代码,但应主要依赖接口层 |
实现层 | 对各种具体的通信协议、存储设备等功能的实现 | 满足下层的接口层来做实现,禁止同层间互相调用 |
接口层 | 定义了各模块的基本使用方式,用以隔离具体的实现和设计,从而提供互相替换的能力 | 本层之间代码可以互相调用,但禁止调用上层代码 |
工具层 | 提供通用的 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 | 处理模块 | 处理 | 业务逻辑 |
此层次是为了统一各种不同的底层传输协议而设置的,最基本应该支持 TCP 和 UDP 这两种协议。对于通信协议的抽象,其实在很多底层库也做的非常好了,比如 Linux 的 socket 库,其读写 API 甚至可以和文件的读写通用。C# 的 Socket 库在 TCP 和 UDP 之间,其 api 也几乎是完全一样的。但是由于作用游戏服务器,很多适合还会接入一些特别的“接入层”,比如一些代理服务器,或者一些消息中间件,这些 API 可是五花八门的。另外,在 html5 游戏(比如微信小游戏)和一些页游领域,还有用 HTTP 服务器作为游戏服务器的传统(如使用 WebSocket 协议),这样就需要一个完全不同的传输层了。
服务器传输层在异步模型下的基本使用序列,就是:
根据上面三个特点,可以归纳出一个基本的接口:
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 中,原因是对象序列化中的“对象”本身是一个业务逻辑关联性非常强的概念。在 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 两个类,都有记录序列号的 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[] 转换成某个子类对象,而必须分成两步处理。
对于 Protocol 的具体实现子类,我首先实现了一个 LineProtocol ,是一个非常不严谨的,基于文本ASCII编码的,用空格分隔字段,用回车分包的协议。用来测试这个框架是否可行。因为这样可以直接通过 telnet 工具,来测试协议的编解码。然后我按照 TLV (Type Length Value)的方法设计了一个二进制的协议。大概的定义如下:
协议分包: [消息类型:int:2] [消息长度:int:4] [消息内容:bytes:消息长度]
消息类型取值: