通用背包设计范式以及背包数据持久化思路以及微服务化探索
笔者这几天在开发背包系统,调研了一些背包系统常规的设计思路,这里做下总结和思考。
通用道具设计以及配置表实现自定义化
所谓背包系统,对用户拥有的道具信息进行管理和操作,包括对道具的增删查改,道具有效期检查、道具信息持久化等。
首先我们需要设计道具表的数据结构,站在背包的视角,所有的道具理论上都可以使用四个字段来描述:
1
2
3
4
5
6
type Prop struct {
PropId string // 主键id
ConfigurationId string
KeyValuesInt map[key]int64
KeyValuesStr map[key]string
}
为什么说这个结构能够表示道具应该具备的所有状态呢?下面我们来一个字段一个字段的分析。
- PropId:道具Id这个不必多说,作为数据库的主键。但是这里我要提醒一下,PropId本身是个抽象度极高的字段,不能简单得认为同一种的道具的id是一样的。PropId最重要的特性就是证明这个道具的唯一性,用户背包内只能存在这一份数据。
- ConfigurationId:配置Id,这个字段非常重要,是实现通用道具的结构的关键。不同的游戏下,道具被赋予的业务是不同的,但是这并不是背包系统所关心的,但是后端又需要记录道具具备的业务方便客户端使用。配置表的作用就体现了,在游戏业务系统内实现一张配置表,存储着道具的业务信息,背包内的道具只需要记录配置表的key即可。到时候业务层自己去根据key去查表得到道具的业务信息。这就是配置设计模式的作用,将配置信息与道具结构体本身解耦开来。
- KeyValuesInt:int类型的key-value对,存储道具的属性信息。比如道具的耐久度、道具的等级等。这个字段是一个map,key是属性的名称,value是属性的值。这个字段可以存储道具的所有属性信息。因为是map,所以是可扩展的。也不一定要用map,但是要注意选取的数据结构要具备可扩展性。
- KeyValuesStr:string类型的key-value对,存储道具的属性信息。道具有些属性可以被序列化成字符串,比如属性是个结构体,能够被json序列化。这样道具能够存储的信息就更多了,不在局限于int类型。
这样这样的道具结构理论上可以表示所有的道具。
用户背包设计 / UserBag
我们从存储层读取用户道具信息后,我们需要使用这些信息来构建用户背包。常见的背包设计是这样的,背包由元数据、槽slots和装备信息组成。槽内可以放置道具prop。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type BagType int
type BagInfo struct {
userid string
bagid string
...
}
type BagConfig struct {
bagType BagType
capacity int
...
}
type UserBag struct {
info *BagInfo
config *BagConfig
slots []*Slot // 背包道具槽位
wearRef map[int, int] // 装备道具Id -> 存放槽位
...
}
type Slot struct {
slot_id int
prop *Prop
}
背包结构的增删查改比较简单,这里就不过多的赘述。
背包管理器 / BagManager
服务器系统需要有个管理所有玩家背包的生命周期的地方,系统访问具体的用户背包的入口就在这个模块,同时负责重要的背包系统的功能,比如加载或更新用户背包数据,持久化用户背包数据,背包道具过期检查,查询背包数据等。
分阶段做法
游戏玩法要求背包内的道具会频繁变动,此时对响应性的要求很高,对于每一次变动都要求持久化保证数据安全诚然是不合理的。通常的做法是,在游戏进入需要高响应性阶段的时候,向管理器申请背包数据进入游戏内存,之后的所有修改都在内存中,等到游戏进入低响应性阶段的时候,再想管理器申请持久化背包数据。或者设计存档点,在存档点时,顺便进行一次背包数据持久化。在低响应阶段,所有的数据修改都可以向管理器申请持久化。
持久化权威
持久化权威要求所有的数据以硬盘数据为权威,向它同步。这样的场景在于存在第三方发放道具,且道具的安全性能极高,需要即刻入盘。比如充值道具,活动道具等稀有道具。游戏业务层不再具备向管理器申请持久化的权限,只能通过加载刷新数据的方式将数据加载到内存中。也就是游戏业务需要背包信息的时候都向管理器申请加载最新的数据。
过期检查处理 / expire
道具具备有效期是非常常见的需求,背包系统也就承担起对道具过期检查的责任。过期检查是个非常令人头痛的问题,其问题在于实时性。过期的发生和过期发生的检测完成几乎不可能同时达成。
所以何时进行过期检查是问题的关键!
在持久化权威的方案中,过期检查发生的时机通常在申请 / load 数据的过程中,check when load and use;
在高响应下的方案中,过期检查发生的时机通常在use,即check before use。
了解完这些,相信读者能够设计出一个完善的背包系统哩!
背包服务微服务化探索:DDD领域设计模型架构
介绍一下DDD领域设计模型
- 用户接口层:负责接收前端请求(http请求、grpc调用),将消息参数转化为应用层需要的格式(转化为dto对象),调用应用层的服务方法,并将得到的结果返回给前端;
- 应用层:协调业务流程,但不处理具体的业务逻辑;调用领域层的各个核心业务,组合完成具体的服务操作。本层的工作重点是进行事务管理,日志埋点,将领域层调用的结果封装为dto结构;
- 领域层:一个领域就是一个核心业务,不同的领域之间保证独立,实现高内聚和低耦合;通过实体对象、值对象等实现业务逻辑。
- 基础设施层:中间件和持久层的所在地,为领域层提供技术服务和工具服务,但不具体参与业务逻辑;将数据操作和业务操作解耦,方便系统中中间件的替换和维护。
DDD分层结构分为四个层级,分别是接口层 / interface layer、应用层 / application layer、领域层 / domain layer、infrastructure / 基础设施层。下面自底向上介绍每一层在背包微服务中的作用:
基础设施是我们背包微服务中的最底层,提供基本的技术服务:如持久化服务、缓存服务,这两个服务也是背包系统中用到的服务。基础设施层需要封装技术服务接口,实现提供技术服务组件的可热替换;以持久层为例,抽象数据服务为interface接口,每一个可能会使用的数据库引擎实现这个接口;
1
2
3
4
5
6
7
8
9
10
type BagRepository interface {
// 根据用户ID获取聚合根
FindBagByUserID(userID string) (*domain.Bag, error)
// 保存聚合根
Save(bag *domain.Bag) error
// 获取背包快照(CQRS分离)
GetBagSnapshot(bagID string) ([]byte, error)
}
服务启动是可以根据配置使用不同的数据库服务,只需为这个数据库实现相应的接口即可。
领域层,背包系统中可以被抽象成一个个独立工作的核心业务,比如道具数据管理服务,道具数据管理就是我们的领域对象模型。针对道具领域模型,需要调用基础设施的接口实现核心业务,包括道具的增删查改、过期检查等功能。
业务层的作用就是实现接口层定义的接口服务,并将调用的结果封装成dto传回接口层。业务层具备调用多个领域的能力,可以实现更加复杂抽象的业务逻辑。同时,业务层需要关注事务管理、日志埋点等跨领域的关注点。比如我们要实现第三方sdk服务的道具发放接口服务,这个服务的实现需要两个领域的合作,一个是道具数据管理领域对象,一个是道具发放消息管理领域对象,道具发放后需要先使用道具数据管理去完成落库动作;然后再通过消息发放消息管理领域去实现消息缓存或者持久化。
背包服务使用grpc作为外部开放接口框架,grpc server 和 grpc service实现在接口层。同时需要将客户端数据模型和持久层业务模型解耦开,在接口层设计dto作为客户端返回数据结构;比如背包界面的展示所需的所有数据可以设计成一个BagViewDto,与prop数据库对象解耦开来。 使用proto文件定义dto对象,与相互调用的服务之间保持一致。
1
2
3
4
5
6
7
8
9
10
11
12
message BagViewDto {
string bag_id = 1;
repeated SlotViewDto slot_view_dto = 2;
message SlotViewDto {
int32 slot_id = 1;
int32 prop_id = 2;
int32 prop_num = 3;
int64 prop_expire = 4;
...
}
}
独立部署和lib化能力
背包服务面向多个内部服务提供服务(比如游戏服务和sdk服务等),可以单独部署将多个服务的数据信息汇流到一个数据库;而可以作为服务的库使用该服务的内部数据库。
实现这个功能也比较简单,grpc的service化的设计理念已经帮助我们解决这个问题。独立部署和lib化能力的实现主要依靠服务和网络框架的解耦,grpc中server和service是解耦的,我们可以选择将service注册到server实现微服务化,而可以将封装一个自己的server,将service的实现注册进入。