另辟蹊径、ECS在游戏后端开发的应用

1

主题

4

帖子

3

积分

新手上路

Rank: 1

积分
3
发表于 2022-9-21 14:22:07 | 显示全部楼层
ECS作为一种经典的GamePlay架构,凭借与oop截然不同的数据和逻辑分离的架构设计,使其在游戏客户端领域拥有诸多独有优势,深受很多客户端开发同学的推崇。本文从后端开发的视角出发,期望能借鉴ECS的思想来解决游戏后端开发中遇到的问题。
结论前置

与大部分架构先定义对象,再根据对象的功能扩充数据不同。ECS模型基于“数据定义对象”的思想,首先根据功能需要定义不同类型的组件(即数据)。再将相互关联的组件组成一个实体(即对象)。系统(即业务逻辑)只关注组件,只要一个实体拥有系统所依赖的组件,那么这个系统就可以应用在该实体上。
本文借鉴ECS模型,提出一个适用于后台有状态服务的开发架构:

  • 结构分为框架层和业务层。
  • 所有的数据以组件形式存储,实体只用于表示组件之间的关联关系。
  • 系统是表达业务逻辑的纯方法。系统以组件作为参数,且必须显式的将其依赖的组件类型注册到框架层。
  • 业务层只关注系统,组件以及系统对组件的依赖关系(即业务逻辑,数据 和 执行业务逻辑需要哪些数据)。
  • 框架层负责管理所有的组件,并根据外部请求为系统准备其所依赖的组件。



系统  -  组件  -  实体

这种设计使得框架获得了对业务层极细粒度的治理能力。在此加持下,框架可以做到:

  • 组件级别的数据管理能力。组件级别的按需加载能力,可以降低单个实体的实用内存,提高响应速度。
  • 实体内部的安全并发执行。对于作用在同一个实体,但是依赖的组件不重合的系统,可以“绝对安全”的并发执行。
  • 清晰可控的数据依赖关系。对系统依赖数据的强制声明要求,没有声明则不可用,杜绝不可知的隐秘关联。
  • 更加便利的代码复用机制。系统只依赖于组件,亦即不同类型的实体只要拥有相同类型的组件,就可以直接适用同一个系统。
ECS概念同步

ECS(Entity-Component-System)是一种软件架构模式,主要用于游戏开发中。ECS包括 由数据组件(Component) 组成的实体(Entity),以及在组件上运行的系统(System)。

  • 实体:一个实体代表一个通用对象,实体是由组件构成。
  • 组件:组件用于保存实现某方面功能所需的数据。通过不同的组件让实体拥有不同的功能。
  • 系统:系统是一个过程,它作用于具有所需组件的所有实体。
简而言之,所有的数据都以组件的形式存在;实体是互相关联的组件的聚合体;系统是只作用于拥有其关注的组件的实体的方法
举个例子,这是一个Player数据模型
NameLevelGoldWeapon
大壮1550fist
小美5100/
丧彪500knife
佛伯乐80200gun
表中每个格子即为一个组件,每行组件构成一个玩家实体。其中玩家“小美”,没有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 = "ActiveWeaponSys" {
                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 = "GetWeaponSys" {
                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 <- {"ActiveWeapon","大壮"} {
                // 检索满足条件的Sys
                sys = MatchSys(engine.SysPool, Msg."ActiveWeapon")
                // 获取依赖的组件列表
                CopList := sys.GetCopList()
                // 检索匹配到“大壮”的组件是否满足Sys要求
                WeakCopCodeList = CheckCop(CopList, engine.CopPool, Msg."大壮")
                // 调用Cop加载接口,加载缺少的Cop
                for CopCode in range WeakCopList {
                        CopPool = append(CopPool, CreateCop(CopCode, Msg."大壮"))
                }
                go func() {
                        Sys.FuncMain(Msg, CopList)
                }

        }
}
一个可以Run的Demo

这是对上述样例的实践Demo,考虑到在实践中会存在一些和实体本身相关的逻辑,所以保留了实体(Entity)用作触发器和组件(Component)的索引。
gameserver-ecs/README.md at main · Tudongye/gameserver-ecs



我最喜欢画图了.jpg
回复

举报 使用道具

2

主题

7

帖子

12

积分

新手上路

Rank: 1

积分
12
发表于 2025-2-9 17:48:01 | 显示全部楼层
LZ是天才,坚定完毕
回复

举报 使用道具

您需要登录后才可以回帖 登录 | 立即注册
快速回复 返回顶部 返回列表