|
发表于 2022-9-21 14:22:07
|
显示全部楼层
ECS作为一种经典的GamePlay架构,凭借与oop截然不同的数据和逻辑分离的架构设计,使其在游戏客户端领域拥有诸多独有优势,深受很多客户端开发同学的推崇。本文从后端开发的视角出发,期望能借鉴ECS的思想来解决游戏后端开发中遇到的问题。
结论前置
与大部分架构先定义对象,再根据对象的功能扩充数据不同。ECS模型基于“数据定义对象”的思想,首先根据功能需要定义不同类型的组件(即数据)。再将相互关联的组件组成一个实体(即对象)。系统(即业务逻辑)只关注组件,只要一个实体拥有系统所依赖的组件,那么这个系统就可以应用在该实体上。
本文借鉴ECS模型,提出一个适用于后台有状态服务的开发架构:
- 结构分为框架层和业务层。
- 所有的数据以组件形式存储,实体只用于表示组件之间的关联关系。
- 系统是表达业务逻辑的纯方法。系统以组件作为参数,且必须显式的将其依赖的组件类型注册到框架层。
- 业务层只关注系统,组件以及系统对组件的依赖关系(即业务逻辑,数据 和 执行业务逻辑需要哪些数据)。
- 框架层负责管理所有的组件,并根据外部请求为系统准备其所依赖的组件。

系统 - 组件 - 实体
这种设计使得框架获得了对业务层极细粒度的治理能力。在此加持下,框架可以做到:
- 组件级别的数据管理能力。组件级别的按需加载能力,可以降低单个实体的实用内存,提高响应速度。
- 实体内部的安全并发执行。对于作用在同一个实体,但是依赖的组件不重合的系统,可以“绝对安全”的并发执行。
- 清晰可控的数据依赖关系。对系统依赖数据的强制声明要求,没有声明则不可用,杜绝不可知的隐秘关联。
- 更加便利的代码复用机制。系统只依赖于组件,亦即不同类型的实体只要拥有相同类型的组件,就可以直接适用同一个系统。
ECS概念同步
ECS(Entity-Component-System)是一种软件架构模式,主要用于游戏开发中。ECS包括 由数据组件(Component) 组成的实体(Entity),以及在组件上运行的系统(System)。
- 实体:一个实体代表一个通用对象,实体是由组件构成。
- 组件:组件用于保存实现某方面功能所需的数据。通过不同的组件让实体拥有不同的功能。
- 系统:系统是一个过程,它作用于具有所需组件的所有实体。
简而言之,所有的数据都以组件的形式存在;实体是互相关联的组件的聚合体;系统是只作用于拥有其关注的组件的实体的方法。
举个例子,这是一个Player数据模型
Name | Level | Gold | Weapon | 大壮 | 15 | 50 | fist | 小美 | 5 | 100 | / | 丧彪 | 50 | 0 | knife | 佛伯乐 | 80 | 200 | gun | 表中每个格子即为一个组件,每行组件构成一个玩家实体。其中玩家“小美”,没有Weapon组件,只由三个组件构成,其他的“大壮”,“丧彪”,“佛伯乐”都由四个组件构成。
假设存在一个“收你5块钱,给你的武器加一个buff”的系统,显然这个系统依赖 Gold组件和 Weapon组件。那这个系统只可以作用于玩家“大壮”,玩家“丧彪”,玩家“佛伯乐” 。而不能作用于玩家“小美”,因为小美没有Weapon组件。

只有三个组件的 “小美 ”
“典型后端”遇到的“典型问题”
了解什么是ECS后,还需要了解什么是游戏后端。这里以笔者对一些游戏项目的了解(道听途说),给出一类“典型游戏后端”的描述:
- 将游戏数据抽象为玩家,战队,军团等不同类型的Actor,并使用不同的服务来处理对针对不同类型Actor的操作。
- 为了保证实时交互效率,在内存中缓存大量的游戏数据,核心业务很多都是有状态服务。
- 数据管理以Actor为核心,挂载各种Mgr来管理Actor的数据。
- 整个Actor都暴露给业务开发。同一个Actor的不同Mgr可以通过Actor来任意的互相访问。
- 线程调度以Actor为单位,保证同一个Actor只有一个工作线程在运行。
- 消息分发针对Actor进行,框架层保证将消息传递给对应的Actor,之后由业务层来分发给具体的业务逻辑来处理。

一个“ 典型后端 ”
显然,这类后端架构以Actor为界,Actor以外由框架层负责,Actor以内由业务层负责,大部分的业务逻辑都以Actor为核心。当收到一个外部请求后,框架层会根据请求检索Actor。如果检索到对应Actor,则转发请求,由Actor附带的业务层代码来处理业务逻辑。
根据这些特点,就能预测到经过长期的开发工作后,这类架构将面临的“典型问题”。
- 将整个Actor暴露给业务层,可以让业务开发更加的便利。但是长期的代码腐化,必然会造成Actor内部逻辑强耦合,各模块交叉依赖,难以梳理。任何对老模块的修改和引用都会变成一场难以预料的冒险。
- 框架层对Actor的状态感知只有可用(完全加载)和不可用(未加载 或 部分加载)。即只能等Actor的完全加载后才能提供服务,如果出现数据加载瓶颈(比如服务迁移场景)或者部分数据源异常,会影响到整个Actor的响应速度。
- 随着游戏玩法的逐渐丰富,日渐堆砌起的巨型Actor肆意侵占着宝贵的内存资源,造成一种“明明玩家越来越少,但每个玩家的服务器成本却越来越高”的情景。
- 一个Actor只能有一个工作线程在运行。但是对于像战队,军团等与玩家呈一对多关系的Actor会存在有并发请求的场景,串行执行的模式,可能会带来不可忽视的延迟问题,而且也浪费CPU资源。
造成这些问题的主要原因就是 框架层所提供的针对Actor级别的治理能力,面对逻辑和数据都日益膨胀的Actor本身,显得捉襟见肘。而以Actor为核心的业务开发模式,又反向限制了框架层向更深层治理能力发展的可能。
要解决这些问题有很多办法。本文选择的出路是对开发架构重构,将Actor移入到框架层,业务层抛开Actor只专注于逻辑和与逻辑直接关联的数据。
如果能重来...
如前述,ECS模型由实体,组件,系统构成。如果将实体视为Actor,将组件视为业务数据,将系统视为业务逻辑。那完全可以用ECS模型来重构前述开发架构:
- 使用组件来承载所有的数据,组件之间根据承载的功能分割数据。相同类型的组件集中管理。
- 一组相互关联的组件组成一个逻辑上的实体。实体退化成一个抽象概念,类似“Key”,拥有相同“Key”的组件在逻辑上组成一个实体。
- 业务逻辑以系统的方式实现,系统是一个以组件作为参数的纯方法。系统本身不存储任何数据,是可重入可并发的。系统以组件做为参数,通过修改一个或多个组件的内容,来实现业务逻辑。
- 系统需要显式的声明其依赖的组件类型,且只能感知(读取&修改)其依赖的组件。同一类实体,可能拥有不同的组件(例如到达XX等级,才能解锁XX系统)。因此系统需要根据业务逻辑,显式的声明其依赖的组件,只有同时拥有这些组件的实体才能(将系统依赖的组件)作为参数传入。同样的,系统在表达业务逻辑时,也只能读取和修改其依赖的这些组件。

经过ECS模型重组的后端
在ECS模型中,业务层通过系统,组件,以及系统对组件依赖关系 来实现。而实体则退化为用于表示组件间关联关系的抽象概念。丧失了逻辑功能的实体,可以很容易的被吸收进框架层。
在ECS模型中,一次外部请求的流程可以按以下流程进行:
1、框架层接受外部请求 <系统名, 实体Key>
2、框架层检索到对应系统,获得依赖组件列表(业务层显式定义)
3、框架层检索本地组件池,查找匹配实体Key的组件列表
4、框架层对于缺少的组件,将实体Key传入对应组件的加载接口(业务层实现),加载数据。
5、如果获取到满足系统依赖的组件,则将这些组件传入系统的业务层接口(业务层实现)。
6、系统的业务层接口通过修改组件内容,实现具体的业务逻辑。
如下是几种构思的请求链路

几种常见的请求链路
辅助说明的样例
为了便于理解,给出一个样例。样例只是用于说明设计思想,重在理解。
如下是一个包含两种组件 < 武器组件,钱包组件 > 和 两个系统 < 激活武器系统,查看武器列表系统 > 的服务。业务层只需要实现组件和系统接口即可完成业务逻辑开发。该样例可实现:
- 针对外部请求按需加载内存
- 针对同一个实体可以并发运行多个互不冲突的系统(依赖组件列表不重合)
//component.go
// 组件,包含一个武器组件 (激活武器,获取武器列表) 和 一个钱包组件(消耗金币)
package ecs
// 组件接口
type Component interface {
Create(Key) error // 创建组件,并加载数据
Clear() // 清空组件内存
}
// 武器组件
type WeaponComponent struct {
WeaponList []Weapon
}
func (this *WeaponComponent)Create(Key) error{
// 假装我有从DB加载数据
}
func (this *WeaponComponent) Clear() {
// 假装我有清理内存
}
// 激活武器,并花5金币
func (this *WeaponComponent) ActiveWeapon(weaponid int, bag BagComponent) error{
bag.UseGold(5)
this.WeaponList = append(this.WeaponList, weaponid)
return nil
}
// 获取武器列表
func (this *WeaponComponent) GetWeaponList() []Weapon {
reuturn this.Weapon
}
type BagComponent struct {
Gold int
}
func (this *BagComponent)Create(Key) error{
// 假装我有从DB加载数据
}
func (this *BagComponent) Clear() {
// 假装我有清理内存
}
func (this *BagComponent) UseGold(num int) {
this.Gold -= num
}
//entity.go
// 定义Key 和 组件编号,便于使用
type Key int
// 定义一下编号
type Component_Code
const (
WeaponCop_Code Component_Code = 1
BagCop_Code Component_Code = 2
)
// 编号和组件关联一下
func CreateCop(code Component_Code) Cop {
if code == WeaponCop_Code {
return &WeaponComponent{}
}
if code == BagCop_Code {
return &BagComponent{}
}
return nil
}
// sys.go
// 定义两个系统,激活武器系统,获取武器详情系统
// 系统接口
type Sys interface {
RouteMatch(msg Msg) bool
GetCopList()[]int
FuncMain(msg Msg , Cop...)
}
// 激活武器系统
type ActiveWeaponSys struct {
}
func (this*ActiveWeaponSys) RouteMatch(msg Msg) bool {
if msg.Sysname = &#34;ActiveWeaponSys&#34; {
return true
}
return false
}
// 依赖 武器组件和钱包组件
func (this*ActiveWeaponSys)GetCopList()[]int {
return []int{WeaponCop_Code, BagCop_Code}
}
// 同时依赖多个组件
func (this*ActiveWeaponSys) FuncMain(msg Msg, weaponcop, bagcop) {
weaponcop.ActiveWeapon(msg.Weaponid, bagcop)
}
// 获取武器信息系统
type GetWeaponSys struct {
}
func (this*GetWeaponSys) RouteMatch(msg Msg) bool {
if msg.Sysname = &#34;GetWeaponSys&#34; {
return true
}
return false
}
// 依赖 武器组件
func (this*GetWeaponSys)GetCopList()[]int {
return []int{WeaponCop_Code}
}
// 只依赖一个组件
func (this*GetWeaponSys) FuncMain(msg Msg, weaponcop) {
return weaponcop.GetWeaponList()
}
// main.go
// 框架驱动
type WorldEngine struct {
SysPool []System
CopPool []Component
}
func main(){
//
engine := WorldEngine{}
engine.SysPool = append(engine.SysPool, ActiveWeaponSys)
engine.SysPool = append(engine.SysPool, GetWeaponSys)
// 请求 激活武器
for Msg <- {&#34;ActiveWeapon&#34;,&#34;大壮&#34;} {
// 检索满足条件的Sys
sys = MatchSys(engine.SysPool, Msg.&#34;ActiveWeapon&#34;)
// 获取依赖的组件列表
CopList := sys.GetCopList()
// 检索匹配到“大壮”的组件是否满足Sys要求
WeakCopCodeList = CheckCop(CopList, engine.CopPool, Msg.&#34;大壮&#34;)
// 调用Cop加载接口,加载缺少的Cop
for CopCode in range WeakCopList {
CopPool = append(CopPool, CreateCop(CopCode, Msg.&#34;大壮&#34;))
}
go func() {
Sys.FuncMain(Msg, CopList)
}
}
}
一个可以Run的Demo
这是对上述样例的实践Demo,考虑到在实践中会存在一些和实体本身相关的逻辑,所以保留了实体(Entity)用作触发器和组件(Component)的索引。
gameserver-ecs/README.md at main · Tudongye/gameserver-ecs

我最喜欢画图了.jpg |
|