事件监听模型于游戏业务中的实践

背景

       随着版本的开发迭代,游戏开发者难免会面对一些业务扩展维护方面的难题。

       由于游戏业务具体开发周期短,需求灵活多变,开发量多,模块间逻辑关联度大,容易出bug 等特点,开发者往往很难同时兼顾开发效率和代码质量。而导致代码往糟糕趋势发展的原因,其中很重要的一点是,各系统模块间存在网状的调用关系,代码极易产生大量耦合,牵一发而动全身。这时候,如果项目中缺少一些统一的业务开发框架/模板/规则,不同风格的代码及处理流程整合在一起,维护起来就是非常痛苦的事情。此时,项目整体开发效率和质量只能取决于团队中每个成员自身的素质和追求。

       如何避免网状的调用关系产生耦合,游戏业务中常见的处理方式,一般是忽略或者在小范围内改进优化。而在其他领域中,可能是引入某种设计模式。事件监听模型作为一种成熟的设计模式,在工业界拥有广泛的应用。它有很多别称,如中介者模式,发布订阅模式等。有的面向对象语言原生支持,有的则天然融合在框架服务中,成为服务的一种特性。它的本质是对观察者模式的扩展,通过预先注册(Register)并提供中转的方式,解离事件源和观察者之间的耦合,而它所擅长的事情,正是对网状调用关系解耦。

       如何将事件监听模型合理有效的融入游戏业务的开发场景中,是本文探讨的主题。

设计目标

       一个服务及对应的规则设计出来,最终是要给用户使用的, 如果脱离了实际应用场景,只会成为纸上谈兵,最终被人所遗忘。因此,在实现服务功能的同时,应尽可能兼顾开发效率,可读性,可扩展性等。  对使用者来说,simple is the best。

      重新翻译一下游戏业务的痛点及对应需求:

痛点 需求
开发量大 应保证开发效率作为前提, 额外工作量应当尽可能的小, 不会对开发者产生过多负担。
需求灵活多变 服务提供的接口不会约束业务需求的灵活性,不会对需求扩展产生多余限制。
开发周期短 服务提供的接口和规则应当简单直观,额外的理解成本应尽可能少。
模块间关联度大 避免模块间产生耦合,保证模块内部的内聚性。
容易出bug 服务应提供有限且简单的接口,避免给使用者埋坑。


从观察者模式说起

在介绍事件监听模式前,先简单介绍一下观察者模式。

传统的观察者模式分为观察者主题两种角色, 观察者向主题注册, 主题向所有观察者推送事件

一个网状调用关系的系统如下图:

事件监听模型于游戏业务中的实践_第1张图片

经观察模式改进后:

事件监听模型于游戏业务中的实践_第2张图片

观察者模式解决了主题和观察者的单向耦合。

但另一方面, 未解决观察者对主题的耦合(每个观察者仍需要知道主题的存在,才能完成注册)。

更严重的,开发者的工作量成倍提升, 从简单的接口调用  到 每个主题都需要维护一份观察者列表, 每个观察者都需要预先注册多个主题。

这也是为什么人人都知道观察者模式,但大多可行场景下,仍会弃之不用的直接原因。 

 

出于以上两点不足,才有了解耦更彻底的事件监听模式。

事件监听模式在观察者主题(事件源)两种角色的基础上, 引入了第三者:事件触发器

事件触发器统一根据事件分类维护一个或多个观察者列表, 并往对应的观察者列表转发所有自己收到的事件。

另外,每个模块既可以是事件源,也可以是观察者。

作为观察者,往事件触发器预先注册需要监听的事件分类。

作为事件源,往事件触发器推送所有自己产生的事件。

由此, 观察者和事件源之间彻底解耦。

事件监听模型于游戏业务中的实践_第3张图片

那么文章开头提到的网状调用关系在事件监听模型中是如何运转的呢?

当原本的调用关系是一对多时:多个观察者处理同一事件。

当原本的调用关系是多对一时:多个事件源推送同一事件。

 

如何定义事件

如何定义并分发事件,常见的做法大致分为三种:

  1. 多类事件采用统一的结构, 事件中包含事件类型和参数列表,参数含义由事件类型决定。
  2. 每种类型的事件单独定义一个结构体, 结构体名和函数名可以标识事件类型。
  3. 事件包含的内容直接作为函数参数传递,函数名标识事件类型。
#define LoopCall(S, FUNC, ...) do { for(size_t i = 0; i < S.size(); ++ i){ S[i]->FUNC(__VA_ARGS__); } } while(0)

//1.统一结构
struct CommonEvent
{
    UINT32 type;   //事件类型
    UINT32 time;
    UINT32 param1;
    UINT32 param2;
    UINT32 param3;
    UINT64 ulParam1;
    UINT64 ulParam2;
    UINT32 op;
    std::string szName;
};

void OnTriggerEvent(Player& player, const CommonEvent& stEvent)
{
    LoopCall(m_eventListener, OnTriggerEvent, player, stEvent);
}

//2.独立结构
struct EventAddItem
{
    UINT32 uID;
    UINT32 uCount;
    UINT32 uTime;
};

struct EventCostItem
{
    UINT32 uID;
    UINT32 uCount;
};

void OnAddItem(Player& player, const EventAddItem& stEvent)
{
    LoopCall(m_eventListener, OnAddItem, player, stEvent);
}

void OnCostItem(Player& player, const EventCostItem& stEvent)
{
    LoopCall(m_eventListener, OnCostItem, player, stEvent);
}

//3. 直接传递
void OnAddItem(Player& player, UINT32 uID, UINT32 uCount, UINT32 uTime)
{
    LoopCall(m_eventListener, OnAddItem, player, uID, uCount, uTime);
}

void OnCostItem(Player& player, UINT32 uID, UINT32 uCount)
{
    LoopCall(m_eventListener, OnCostItem, player, uID, uCount);
}

 

  • 对于方案一,统一事件结构,可以减少新增事件带来的代码量,从事件源到具体观察者可以复用通用的处理流程。  带来的问题是, 在可读性和可维护性上的代价是惨重的,每个监听事件的观察者,都需要谨慎的审视一遍事件源传递过来的每一个参数及其含义,以确保不会对字段出现理解层面的偏差,这样的设计对开发者来说并不友好。与此同时,统一事件结构存在的另一个弊端是对新增的事件产生了约束, 约束了事件参数的类型及数量, 比较糟糕的场景是每次新增一个事件类型,都要修改通用的事件结构以满足新事件的要求,扩展性有限。
  • 和方案一相反的方案二,为每个事件定义一个独立的结构体,事件的每个参数意义都很明确, 而且当一个事件的参数需要扩展时,仅需在自己定义的结构中新增字段即可,不用再增加额外的代码。 对应的问题是, 定义并开发一个新事件的负担加重了,不仅需要定义一个新的结构,还需要在事件触发器和事件基类接口列表中补充定义对应的函数。
  • 直接传递事件参数的方式在某种层面上,兼顾了可读性和代码量, 每个参数的意义很明确,而且不需要为每个事件定义独立的结构体, 不需要在事件源处对事件赋值。 代价是牺牲了事件的扩展性,当事件有参数变动时,需要改动每个观察者对该事件的处理函数,变相加大了需求改动带来的风险

 

作者认为,三种定义事件的方式都是可行的, 业务中采用哪种方式取决于对可读性,代码量,可扩展性之间的取舍

服务中的事件类型有限且事件参数简单的场景下使用方案一是有效且可行的。

而在游戏业务的开发场景中,应当保证开发效率高且bug少(代码量少,可读性强)的前提下, 尽可能的考虑扩展性。

因此在追求开发效率时采用方案三, 当事件本身参数多且要求灵活时采用方案二。

方式 优势 劣势
统一结构 代码量少 可读性差,扩展性差
独立结构 扩展性强,可读性强 代码量多
直接传递 代码量适中,可读性强 扩展性差

 

一个案例

看了这么多抽象的描述,如何通过事件监听模型规避代码耦合,改善代码质量, 我们从一个具体的场景出发。

某游戏经部分删减后的对局结算代码如下:

int PVPMgr::OnPvpFinish(Player& player, const OnePvPSummaryInfo& stOneSummary)
{
    //社交类数据
	UpdateFriendIntimate(player, stOneSummary);
    //...

    if ( CTaskMgr::isCasualMode(stOneSummary.ModeType, stOneSummary.MatchType) )
    {
        CTaskMgr::Instance().UpdatePassTaskProgress(player, TASK_TARGET_TYPE_FINISH_ARCADEMODE);
        //....
    }

    // 段位分更新
    RateRankMgr::Instance().OnGameEnd(player, stOneSummary);

    // ELO分
    bool bInitElo =InitRateElo(player,stOneSummary.ModeType,stOneSummary.MatchType,stOneSummary.TrackId,stOneSummary.UseTime);
    if (!bInitElo)
    {
        if (0 != stOneSummary.EloAdd
            && ELOConfigMgr::IsEloOpen(stOneSummary.ModeType, stOneSummary.MatchType)
            && IsNeed2CalcElo(stOneSummary.ModeType, stOneSummary.MatchType))
        {
            int curElo = player.GetMatchValue(stOneSummary.ModeType, stOneSummary.MatchType);
            int addElo = GetAddEloValue(player, stOneSummary);
            player.UpdateElo(stOneSummary.ModeType, stOneSummary.MatchType, curElo + addElo);
        }
    }

    std::vector additions;
    // 师徒组队加成
    GetPvPGameMentorPupilAddition(player, stOneSummary, additions);
    // 特权加成
    CPrivilegeCommon::Instance().GetPrivilegeAddition(player, additions);
    GetActivityAddition(player, cGameMode,stOneSummary, additions);
    //xx加成....

    // 给奖励
    CalPvPReward(player, stOneSummary, additions);

    //统计信息更新
    PlayerPVPRecord::Instance()->OnPVPDataUpdata(&player, stOneSummary);
    //....
    return 0;
}

与对局结算存在关联的逻辑包括:

社交数据更新;  任务,活动数据更新; 排位分更新,elo分更新; 发奖; 统计信息更新。

 

简单概括上述代码因耦合,混乱产生的问题:

  • 冗长,复杂:随着新功能的开发迭代,对局结算函数内的代码将会日趋臃肿。
  • 代码难以复用: 整体服务需要复用,想要保留对局结算等模块并删除任务模块,这时候需要谨慎的浏览一遍所有调用任务的模块代码。
  • 不利于协同开发: A同学为了在自己开发的模块中监听对局结算事件, 因此必须在对局结算模块中增加自己的代码,对开发对局结算的同学造成困扰。

经过事件监听模型改造后:

//观察者基类
class CEventHandlerBase
{
    virtual void OnPvpFinish(Player& player, const PvPSummaryInfo& info) {}
};

//观察者
class CFriendInfoMgr : public CEventHandlerBase
{
    virtual void OnPvpFinish(Player& player, const PvPSummaryInfo& info)
    {
        UpdateFriendIntimate(player, stOneSummary);
    }
};


class CTaskMgr : public CEventHandlerBase
{
    virtual void OnPvpFinish(Player& player, const PvPSummaryInfo& info)
    {
        if ( IsCasualMode(stOneSummary.ModeType, stOneSummary.MatchType) )
        {
            UpdatePassTaskProgress(player, TASK_TARGET_TYPE_FINISH_ARCADEMODE);
            //....
        }
    }
};

class RateRankMgr : public CEventHandlerBase
{
    virtual void OnPvpFinish(Player& player, const PvPSummaryInfo& info)
    {
        OnGameEnd(player, stOneSummary);

        bool bInitElo =InitRateElo(player,stOneSummary.ModeType,stOneSummary.MatchType,stOneSummary.TrackId,stOneSummary.UseTime);
        if (!bInitElo)
        {
            
        }
    }
};

class CRewardMgr : public CEventHandlerBase
{
    virtual void OnPvpFinish(Player& player, const PvPSummaryInfo& info)
    {
        std::vector additions;
        for(int i = 0 ; i < m_additionSourceList.size(); ++i)
        {
            m_additionSourceList[i]->UpdateAdditions(player, stOneSummary, additions);
        }
        CalPvPReward(player, stOneSummary, additions);
    }
};

class PlayerPVPRecord: public CEventHandlerBase
{
    virtual void OnPvpFinish(Player& player, const PvPSummaryInfo& info)
    {
        OnPVPDataUpdata(&player, stOneSummary);
    }
};

//事件触发器
class CEventTrigger: public CEventHandlerBase
{
    public:
        virtual void OnPvpFinish(Player& player, const PvPSummaryInfo& info)
        {
            LoopCall(m_eventListener, OnPvpFinish, player, info);
        }

    public:
        void InitEventListener()
        {
            m_eventListener.clear();
            m_eventListener.push_back(&CFriendInfoMgr::Instance());
            m_eventListener.push_back(&CTaskMgr::Instance());
            m_eventListener.push_back(&RateRankMgr::Instance());
            m_eventListener.push_back(&CRewardMgr::Instance());
            m_eventListener.push_back(&PlayerPVPRecord::Instance());
        }

    private:
        std::vector m_eventListener;
};

//事件源
int PVPMgr::OnPvpFinish(Player& player, const OnePvPSummaryInfo& stOneSummary)
{
    CEventTrigger::Instance().OnPvpFinish(player, stOneSummary);
}

改造后的优势:

  1. 提升了模块的内聚性,相关逻辑及数据修改仅在模块内进行。
  2. 监听事件不用修改事件源所在模块的代码, 不再对其他模块造成困扰。
  3. 对新模块接入已有事件的帮助是巨大的,不需要再去逐个模块寻找事件触发的源头,浏览一遍CEventHandlerBase提供的接口,并重写需要监听的虚函数即可。
  4. 在原有服务中模块删除变得简单,以删除任务模块为例:
  • 解耦前需要删除四个模块的调用代码:

事件监听模型于游戏业务中的实践_第4张图片

  • 解耦后仅需删除事件监听器中的注册:

事件监听模型于游戏业务中的实践_第5张图片

 

本模型中的对象关系

熟悉观察者模式的同学看完上面案例中的具体实现,可能会存在以下疑问:

  1. 为什么选择单例作为观察者
  2. 为什么没有提供注册/注销的接口

很多关于OOP程序设计准则的介绍中都有提到应当减少单例的使用。主要原因包括如下。

  • 单例全局可访问, 促进了耦合的可能。
  • 多线程场景下,可能出现争夺单例这项公共资源造成死锁。
  • 无法预知单例的生命周期。

但实际上,以上问题都是可以解决或是缓和的:

  • 耦合:有时候因为业务需求产生的耦合是难以避免的,通过本文的事件监听模型在很大程度上缓解了这种耦合。
  • 多线程:本质是要解决多个线程并发访问单例的问题, 一个是可以限定单例的作用域,或是限定只能在某线程中才能访问。又或者是在访问单例前加锁
  • 生命周期: 一个合理的/线程安全的单例,生命周期应该是从进程启动到进程结束的,不应该人为控制单例的创建和销毁。

在更多的业务场景中,单例不可或缺:

  • 可能是作为资源分配器,如场景管理器,动态创建销毁场景对象, 同时维护一份场景列表。
  • 也可以是作为存放公共缓存的地方,如大厅服中的排行榜缓存,能避免每次跨服务拉取或是直接访问DB。
  • 还有作为存放公共配置的地方,如任务系统,存放公共配置的同时,还会存放根据某些规则为配置建立的索引。

在本模型中,具体触发事件的对象作为事件参数传递,选用单例作为观察者,保证了在观察者列表中的指针一定是有效的, 不会因为观察者失效而引发一系列异常。

 

接下来回答为什么没有提供注册/注销接口。

在提供一个接口前,务必要思考两个问题  1.接口是否必要,2.接口会不会导致某些隐患

因为一旦提供接口后,我们无法约束用户的行为,就有可能出现不可预知的问题。

而作为服务提供方,唯一能做的就是提前规避隐患,又或是权衡后发现规避不了只好给出错误案例作为提示。

提供注册/注销接口带来的优点:可以动态修改/调整观察者对象。

而另一面,在网上搜索因注册/注销带来的隐患,很容易搜到以下案例:

  • 同一个观察者注册多次导致事件重复处理。
  • 事件分发过程中注销某个观察者导致迭代器失效。

回想一下,观察者真的需要动态调整吗?

脑补一个观察者需要动态变动的场景:对局结算在平时会走正常发奖逻辑,而在活动期间,需要切换成活动期间的发奖逻辑,这两个发奖逻辑对应着两个观察者,因此这两个观察者需要在活动开启和活动结束的时刻需要动态切换。

但实际上这两个观察者可以同时观察对局结算这一事件,两者是否往下走取决于两个刚好相悖的条件,一个是不在活动期间内才往下执行, 一个是在活动期间才往下执行。只需要一点额外的cpu计算即可规避掉提供注册/注销带来的隐患。

 

 

跨服务的事件

更进一步,思考一下对局结算为什么会和诸多模块产生耦合:

大多游戏在架构设计中会分为大厅服和pvp服, 为保证数据的一致性, 玩家数据仅在大厅服写入DB。

所以当玩家在PVP服中触发事件发生数据变动时(如完成任务,消耗道具等),会记录下变动的增量信息,等待玩家对局完成返回大厅服时,统一写入DB。

这样做的好处是,当有新需求需要在PVP记录数据改动时,仅需往既有结算协议中添加字段即可, 开发更加迅捷。

而对应的问题是,结算的协议包体将会日趋臃肿,且与对局结算耦合的逻辑将会越来越多。

那么能否提供一种跨进程事件通知机制,即事件源和观察者允许不在同一进程中,事件的跨进程传输工作由框架统一完成,达到对使用者透明,无感知的目的呢。

答案必然是可以的,  这里要做到无感知,一个是要解决事件路由的问题,另一个是需要将不同类型的事件结构体,采用统一的方式进行编码解码。

一种巧妙的方案是,在事件源所在服务器建立一个代理观察者,代理观察者监听需要转发的事件,接收事件后不处理逻辑,仅将事件包体根据触发事件的对象设置路由的目标地址,并完成网络发包工作。

接下来就是要将事件在网络传输中的编解码通用化,如果事件是用统一结构定义的,那么这一步无需额外处理; 而如果每个事件都是独立的结构,可以使用union将不同结构联合在一起,并搭配上事件类型字段,编解码针对统一的union结构进行。 观察者在收到具体事件后,通过事件类型字段取用联合中正确的结构体。

以业务中网络协议采用tdr和protobuf作为示例:


  

  

 
 
 

	

  
  

 

message NetEvent {
  required EventType Type = 1;
  optional EventFinishStage FinishStage = 2;
  optional EventUpdateLevel UpdateLevel = 3;
  optional EventPlayerLogin PlayerLogin = 4;
}

 

仍可能存在的坑及解决方案

1. 留意死循环

有了事件监听模型后,开发各模块的同学各自安好, 无需关注其他模块的实现细节。然而有一天,奇迹出现了:

事件1 => 观察者A => 事件2 => 观察者B => 事件3 => 观察者C => 事件1 => 观察者A .........由此产生一条无限不终止的回路。

本质上,这种逻辑应该在业务层面避免。但既然它发生了,作为整条回路的旁观者,有义务打破这种轮回。

一种可行的解决方案是, 在事件触发器分发事件时加锁, 不允许同类事件在一轮分发中再次分发第二次, 保证事务分发不会出现递归, 第二次调用时直接返回并给出错误日志或是抛出异常。

void CEventTrigger::OnTriggerEvent(const Event& stEvent)
{
    if(eventLockSet.find(stEvent.type) != eventLockSet.end())
    {
        //........
        return;
    }
    eventLockSet.insert(stEvent.type);
    LoopCall(m_eventListener, OnTriggerEvent, stEvent);
    eventLockSet.erase(stEvent.type);
}

出于节省代码量的考量,这一步加锁解锁检查操作可以塞入LoopCall中。 

 

2. 顺序耦合

通常情况下,各个观察者之间相安无事,收到事件后处理好各自的逻辑就好了。而某天,模块A因产品需求,在执行事件处理时需要获取模块B经相同事件处理后的更新数据,但模块A却发现获取到的数据是旧的。

也就是说存在一种场景,观察者之间存在数据依赖关系,模块A依赖模块B的数据,但是由于注册顺序的原因,模块B更新数据发生在模块A获取数据之后, 这时候模块A因获取到的是旧数据执行了错误的逻辑。

临时的解决方案是调换模块A和模块B的注册顺序。但这只是一时之策,带有更多不确定的风险:因为无法预知其他同类别事件的观察者是否存在数据依赖关系,调换后很有可能出现的结果是,事件A的分发顺序正确了,事件B的分发顺序又出错了。

更合理的改进方法是仅在本事件的分发范围内控制执行顺序,分发前对观察者设置不同的优先级。具体的实现是在观察者基类中提供设置权重接口,事件触发器分发事件时按权重进行排序:

class CEventHandlerBase
{
    //......
public:
    CEventHandlerBase() { iWeight = 0; }
    void SetWeight(int w) { iWeight = w; }
    int GetWeight() { return iWeight; }

private:
    int iWeight;
};

bool EventHandlerCmp(const CEventHandlerBase* handler1, const CEventHandlerBase* handler2)
{
    return handler1.GetWeight() > handler2.GetWeight();
}

void CEventTrigger::OnTriggerEvent(const Event& stEvent)
{
    ModuleB::Instance().SetWeight(2);
    ModuleA::Instance().SetWeight(1);
    std::sort(m_eventListener.begin(), m_eventListener.end(), EventHandlerCmp);
    LoopCall(m_eventListener, OnTriggerEvent, stEvent);
    //考虑是否恢复顺序
}

 

3. 会阻塞的观察者

前文提到的大部分关于事件监听模型的描述,均是属于服务内部的事件转发,是同步的操作,因此如果某个观察者发生阻塞,可能会影响整个事件分发线程的运转。在多数游戏后台的业务场景中,很少会有业务在本地会阻塞的逻辑。但这并不意味着没有,如果某线程中的观察者提供的是负责磁盘IO的服务,又或者是数据分析(CPU密集型)服务,这时候收到事件要么只能丢弃,要么只能阻塞整个事件分发线程,都是不可取的。

对应的解决方案,就是在会阻塞的观察者处理事件前,对事件进行缓存。即建立一个队列,等结束阻塞后再去队列缓存中主动取出下一条事件继续执行。

更进一步, 如果缓存/事件队列由事件触发器来提供,且事件触发器本身已上升成为一组服务/进程, 那么整个事件监听模型就成了某些支持发布-订阅模式的事件队列的原型。

扯到了事件队列后,从原理到优化到各个实现细节,能往下说的内容很多,但在这里不继续展开,感兴趣的同学可以继续参阅网络上的文章。

 

写在最后

在引入一项服务时,应结合具体业务场景出发,有效且合适的才是更好的,切忌生搬硬套,人云亦云。

事件监听模型通过少量额外工作,在保证开发效率的同时,解离了各个业务模块之间的耦合,提升了团队整体的代码质量。

但事件监听模型并非万能,对于一对一的调用关系,以及需要获取返回值的双向关系,接口调用的灵活性不可取代。  

 

另外,本文中包含大量作者的个人见解,如果有什么疑问或是不对的地方,欢迎指教,欢迎交流。

你可能感兴趣的:(游戏相关,server,c/c++)