对象序列化
现代编程技术中,面向对象是一个最常见的思想。因此不管是 C++ Java C#,还是 Python JS,都有对象的概念。虽然说面向对象并不是软件开发的“银弹”,但也不失为一种解决复杂逻辑的优秀工具。回到游戏服务器端程序来说,自然我会希望能有一定面向对象方面的支持。所以,从游戏服务器端的整个处理过程来看,我认为,有以下几个地方,是可以用对象来抽象业务数据的:
数据传输:我们可以把通过网络传输的数据,看成是一个对象。这样我们可以简单的构造一个对象,然后直接通过网络收、发它。
数据缓存:一批在内存中的,可以用对象进行“抽象”。而 key-value 的模型也是最常见的数据容器,因此我们可以起码把 key-value 中的 value 作为对象处理。
数据持久化:长久以来,我们使用 SQL 的二维表结构来持久化数据。但是 ORM (对象关系映射)的库一直非常流行,就是想在二维表和对象之间搭起桥梁。现在 NoSQL 的使用越来越常见,其实也是一种 key-value 模型。所以也是可以视为一个存放“对象”的工具。
对象序列化的标准模式也很常见,因此我定义成:
1 | class Serializable { |
网络传输
有了对象序列化的定义,就可以从网络传输处使用了。因此专门在 Processor 层设计一个收发对象的处理器 ObjectProcessor,它可以接纳一种 ObjectHandler 的对象注册。这个对象根据注册的 Service 名字,负责把收发的接口从 Request, Response 里面的字节数组转换成对象,然后处理。
1 | // ObjectProcessor 定义 |
由于我们对于可序列化的对象,要求一定要实现 Serializable 这个接口,所以所有需要收发的数据,都要定义一个类来实现这个接口。但是,这种强迫用户一定要实现某个接口的方式,可能会不够友好,因为针对业务逻辑设计的类,加上一个这种接口,会比较繁琐。为了解决这种问题,我利用 C++ 的模板功能,对于那些不想去实现 Serializable 的类型,使用一个额外的 Pack()/Upack() 模板方法,来插入具体的序列化和反序列化方法(定义 ObjectHandlerCast 模板)。这样除了可以减少实现类型的代码,还可以让接受消息处理的接口方法 ProcessObject() 直接获得对应的类型指针,而不是通过 Serializable 来强行转换。在这里,其实也有另外一个思路,就是把 Serializable 设计成一个模板类,也是可以减少强制类型转换。但是我考虑到,序列化和反序列化,以及处理业务对象,都是使用同样一个(或两个,一个输入一个输出)模板类型参数,不如直接统一到一个类型里面好了。
1 | // ObjectHandlerCast 模板定义 |
任何类型的对象,如果想要在这个框架中以网络收发,只要为他写一个模板,完成 Pack() 和 UnPack() 这两个方法,就完成了。看起来确实方便。 (如果想节省注册的时候编写其“类名”,还需要完成一个简单的 GetName() 方法)
当我完成上面的设计,不禁赞叹 C++ 对于模板支持的好处。由于模板可以在编译时绑定,只要是具备“预设”的方法的任何类型,都可以自动生成一个符合既有继承结构的类。这对于框架设计来说,是一个巨大的便利。而且编译时绑定也把可能出现的类型错误,暴露在编译期。————对比那些可以通过反射实现同样功能的技术,其实是更容易修正的问题。
缓冲和持久化
数据传输的对象序列化问题解决后,下来就是缓存和持久化。由于缓存和持久化,我的设计都是基于 Map 接口的,也就是一种 Key-Value 的方式,所以就没有设计模板,而是希望用户自己去实现 Serializable
接口。但是我也实现了最常用的几种可序列化对象的实现代码:
固定长度的类型,比如 int 。序列化其实就是一个 memcpy() 而已。
std::string 字符串。这个需要使用 c_str() 变成一个字节数组。
JSON 格式串。使用了某个开源的 json 解析器。推荐 GITHUB 上的 Tencent/RapidJson。
数据缓冲
数据缓冲这个需求,虽然在互联网领域非常常见,但是游戏的缓冲和其他一些领域的缓冲,实际需求是有非常大的差别。这里的差别主要有:
游戏的缓冲要求延迟极低,而且需要对服务器性能占用极少,因为游戏运行过程中,会有非常非常频繁的缓冲读写操作。举个例子来说,一个“群体伤害”的技能,可能会涉及对几十上百个数据对象的修改。而且这个操作可能会以每秒几百上千次的频率请求服务器。如果我们以传统的 memcache 方式来建立缓冲,这么高频率的网络 IO 往往不能满足延迟的要求,而且非常容易导致服务器过载。
游戏的缓冲数据之间的关联性非常强。和一张张互不关联的订单,或者一条条浏览结果不一样。游戏缓冲中往往存放着一个完整的虚拟世界的描述。比如一个地区中有几个房间,每个房间里面有不通的角色,角色身上又有各种状态和道具。而角色会在不同的房间里切换,道具也经常在不同角色身上转移。这种复杂的关系会导致一个游戏操作,带来的是多个数据的同时修改。如果我们把数据分割放在多个不同的进程上,这种关联性的修改可能会让进程间通信发生严重的过载。
游戏的缓冲数据的安全性具有一个明显的特点:更新时间越短,变换频率越大的数据,安全性要求越低。这对于简化数据缓冲安全性涉及,非常具有价值。我们不需要过于追求缓冲的“一致性”和“时效”,对于一些异常情况下的“脏”数据丢失,游戏领域的忍耐程度往往比较高。只要我们能保证最终一致性,甚至丢失一定程度以内的数据,都是可以接受的。这给了我们不挑战 CAP 定律的情况下,设计分布式缓冲系统的机会。
基本模型
基于上面的分析,我首先希望是建立一个足够简单的缓冲使用模型,那就是 Map 模型。
1 | class DataMap : public Updateable { |
这个接口其实只是一个 std::map 的简单模仿,把 key 固定成 string ,而把 value 固定成一个 buffer 或者是一个可序列化对象。另外为了实现分布式的缓冲,所有的接口都增加了回调接口。
可以用来充当数据缓存的业界方案其实非常多,他们包括:
堆内存,这个是最简单的缓存容器
Redis
Memcached
ZooKeeper 这个自带了和进程关联的数据管理
由于我希望这个框架,可以让程序自由的选用不同的缓冲存储“设备”,比如在测试的时候,可以不按照任何其他软件,直接用自己的内存做缓冲,而在运营或者其他情况下,可以使用 Redis 或其他的设备。所以我们可以编写代码来实现上面的 DataMap 接口,以实现不同的缓冲存储方案。当然必须要把最简单的,使用堆内存的实现完成: RamMap
分布式设计
如果作为一个仅仅在“本地”服务器使用的缓冲,上面的 DataMap 已经足够了,但是我希望缓存是可以分布式的。不过,并不是任何的数据,都需要分布式存储,因为这会带来更多延迟和服务器负载。因此我希望设计一个接口,可以在使用时指定是否使用分布式存储,并且指定分布式存储的模式。
根据经验,在游戏领域中,分布式存储一般有以下几种模式:
本地模式 如果是分区分服的游戏,数据缓存全部放在一个地方即可。或者我们可以用一个 Redis 作为缓存存储点,然后多个游戏服务进程共同访问它。总之对于数据全部都缓存在一个地方的,都可以叫做本地模式。这也是最简单的缓冲模式。
按数据类型分布 这种模式和“本地模式”的差别,仅仅在于数据内容不同,就放在不同的地方。比如我们可以所有的场景数据放在一个 Redis 里面,然后把角色数据放在另外一个 Redis 里面。这种分布节点的选择是固定,仅仅根据数据类型来决定。这是为了减缓某一个缓冲节点的压力而设计。或者你对不同数据有缓冲隔离的需求:比如我不希望对用户的缓冲请求负载,影响对支付服务的缓冲请求负载。
按数据的 Key 分布 这是最复杂也最有价值的一种分布式缓存。因为缓冲模式是按照 Key-Vaule 的方式来存放的,所以我们可以把不同的 Key 的数据分布到不同节点上。如果刚好对应的 Key 数据,是分布在“本地”的,那么我们将获得本地操作的性能! 这种缓冲
按复制分布 就是多个节点间的数据一摸一样,在修改数据的时候,会广播到所有节点,这种是典型的读多写少性能好的模型。
在游戏开发中,我们往往习惯于把进程,按游戏所需要的数据来分布。比如我们会按照用户 ID ,把用户的状态数据,分布到不同的机器上,在登录的时候,就按照用户 ID 去引导客户端,直接连接到对应的服务器进程处。或者我们会把每个战斗副本或者游戏房间,放在不同的服务器上,所有的战斗操作请求,都会转发到对应的存放其副本、房间数据的服务器上。在这种开发中,我们会需要把大量的数据包路由、转发代码耦合到业务代码中。
如果我们按照上面的第 3 种模型,就可以把按“用户ID”或者“房间ID”分布的事情,交给底层缓冲模块去处理。当然如果仅仅这样做,也许会有大量的跨进程通信,导致性能下降。但是我们还可以增加一个“本地二级缓存”的设计,来提高性能。具体的流程大概为:
取 key 在本地二级缓存(一般是 RamMap)中读写数据。如果没有则从远端读取数据建立缓存,并在远端增加一条“二级缓存记录”。此记录包含了二级所在的服务器地址。
按 key 计算写入远端数据。根据“二级缓存记录”广播“清理二级缓存”的消息。此广播会忽略掉刚写入远端数据的那个服务节点。(此行为是异步的)
只要不是频繁的在不同的节点上写入同一个 Key 的记录,那么二级缓存的生存周期会足够长,从而提供足够好的性能。当然这种模式在同时多个写入记录时,有可能出现脏数据丢失或者覆盖,但可以再添加上乐观锁设计来防止。不过对于一般游戏业务,我们在 Key 的设计上,就应该尽量避免这种情况:如果涉及非常重要的,多个用户都可能修改的数据,应该避免使用“二级缓存”功能的模型 3 缓存,而是尽量利用服务器间通信,把请求集中转发到数据所在节点,以模式 1 (本地模式)使用缓冲。
以下为分布式设计的缓冲接口。
1 | /** |
持久化
长久以来,互联网的应用会使用类似 MySQL 这一类 SQL 数据库来存储数据。当然也有很多游戏是使用 SQL 数据库的,后来业界也出现“数据连接层”(DAL)的设计,其目的就是当需要更换不同的数据库时,可以不需要修改大量的代码。但是这种设计,依然是基于 SQL 这种抽象。然而不久之后,互联网业务都转向 NoSQL 的存储模型。实际上,游戏中对于玩家存档的数据,是完全可以不需要 SQL 这种关系型数据库的了。早期的游戏都是把玩家存档存放到文件里,就连游戏机如 PlayStation ,都是用存储卡就可以了。
一般来说,游戏中需要存储的数据会有两类:
玩家的存档数据
游戏中的各种设定数据
对于第一种数据,用 Key-Value 的方式基本上能满足。而第二种数据的模型可能会有很多种类,所以不需要特别的去规定什么模型。因此我设计了一个 key-value 模型的持久化结构。
1 | /** |
针对上面的 DataStore 模型,可以实现出多个具体的实现:
文件存储
Redis
其他的各种数据库
基本上文件存储,是每个操作系统都会具备,所以在测试和一般场景下,是最方便的用法,所以这个是一定需要的。
在游戏的持久化数据里面,还有两类功能是比较常用的,一种是排行榜的使用;另外一种是拍卖行。这两个功能是基本的 Key-Value 无法完成的。使用 SQL 或者 Redis 一类 NOSQL 都有排序功能,所以实现排行榜问题不大。而拍卖行功能,则需要多个索引,所以只有一个索引的 Key-Value NoSQL 是无法满足的。不过 NOSQL 也可以“手工”的去建立多个 Key 的记录。不过这类需求,还真的很难统一到某一个框架里面,所以设计也是有限度,包含太多的东西可能还会有反效果。因此我并不打算在持久化这里包含太多的模型。