游戏编程模式-服务定位器

  “为某服务提供一个全局访问入口来避免使用者与该服务具体实现类之间产生耦合。”

动机

  在游戏编程中,某些对象或系统几乎出现在程序的每个角落,比如内存分和日志记录。我们把类似这样的系统认为是游戏中需要被随时访问的服务。我们使用音频作为例子,虽然不像内存分配那么底层,但也是有很多的游戏模块会涉及。比如石块掉落地面,发出撞击声(物理系统);一个NPC狙击手开枪,发出短促的枪声;用户选择一个菜单,会有一个确认的音效(用户交互系统)。

  然后在每一处这些场景中都会用类似如下的代码去调用:

// Use a static class
AudioSystem::playSound(VERY_LOUD_BANG);

//or maybe a singleton
AudioSystem::instance()->playSound(VERY_LOUD_BANG);

  尽管我们实现了想要的目的,但整个过程中却带来了很多耦合。游戏中每一处需要音频播放的地方,都要引用具体的AudioSystem类和访问AudioSystem类的机制——使用静态类或单例。这就好比因为陌生人要给你投递信件,然后你就要把你的地址告诉他一样,而当搬家的时候,你必须告诉每个人你新的地址,这是在是太痛苦了。

  这里有个解决方法,就是电话簿。每个需要知道我们地址的人都通过电话不来查找我们的地址。一旦我们搬了新家,我们只需要告诉电话公司我们新的地址,这样其它的人还是通过电话簿就能知道我们新的地址了。这就是服务定位器的简单介绍——它将一个服务的“是什么”(具体服务类型)和“在什么地方”(我们如何得到它的示例)与需要使用这个服务的代码解耦了。

服务定位器模式

  一个服务类为一系列操作定义一个抽象接口。一个具体的服务提供器实现这个接口。一个单独的服务定位器通过查找一个合适的提供器来提供这个服务的访问,它同时屏蔽了提供器的具体类型和定位这个服务的过程。

使用情境

  每当你将东西变得能全局都能访问的时候,你就是在自找麻烦。这就是单例模式存在的主要问题。而这个模式存在的问题也没什么不同。对于何时使用服务定位器,简单的建议就是:谨慎使用。与其给需要使用的地方提供一个全局变量,不如首先考虑把这个对象出入到对象中。这样简单易用,还让耦合变得直观。这满足了绝大部分的需求。

  但有时候把对象传来传去显得毫无理由或者使代码变得难以阅读。有些系统,比如日志系统或内存系统,,不应该是某个模块的公开API的一部分。渲染代码的参数应该必须和渲染相关,而不是像日志系统那样的东西。

  但同样的,它也适用于一些类似功能的单一系统。你的游戏可能只有一个音频设备或者显示系统让玩家与打交道。传递的参数是一项环境属性,所以将它传递10层函数以便让一个底层函数能够访问,为代码增加了毫无意义的复杂度。在这些情况下,这个模式能够起到作用。它用起来像一个更灵活、更可配置的单例模式。当被合理的使用时,它能够让你的代码更有弹性,而且几乎没有运行时的损失。

使用须知

  服务定位器的关键困难在于,它要有所依赖(连接两份代码),并且在运行时才能来连接起来。这给与了你弹性,但付出的代价就是阅读代码时比较难以理解依赖的是什么。

服务必须被定位

  当使用单例或者一个静态类时,我们需要的实例不可能变得不可用。但服务定位器模式不同,因为服务要被定位,理所当然的我们需要处理定位失败的情况。后面我们会讨论一种策略来处理这个问题,保证我们在使用的时候总能得到一个服务。

服务不知道被谁定位

  既然定位器是全局可访问的,那么游戏中的任何代码都有可能请求一个服务然后操作它。所以我们要保证这个服务在任何情况下都能正常的工作。而对于一些类,它只能在游戏的某些模块中使用,那么就应该避免让它称为一个服务。因为它不能保证自身能在正确的时机使用。因此,如果一个类希望只在某个特定的上下文中被使用,那么避免用这种模式将它暴露给全局是最安全的。

实例代码

  我们还是使用音频播放代码来做示例。如下是我们要暴露的API:

class Audio
{
public:
    virtual ~Audio(){}
    virtual void playSound(int soundId)=0;
    virtual void stopSound(int soundId)=0;
    virtual void stopAllSound()=0;
};

  在这里,Audio是一个抽线接口类,没有具体的实现。下面我们实现一个具体的Audio实现类。

class ConsoleAudio:public Audio
{
public:
    virtual void playSound(int soundId)
    {
        //play sound
    }

    virtual void stopSound(int soundId)
    {
        //stop the sound
    }

    virtual void stopAllSound(int soundId)
    {
        //stop all sound

    }

};

  这个就是我们的一个服务提供器。现在我们有了一个接口和一份实现,剩下的就是服务定位器了——这个类把两者绑到一起。

简单的定位器

  这个应该是最简单的一个服务定位器实现了。

class Locator
{
public:
    static Audio* getAudio(){return services_;}

    static void provide(Audio* service)
    {
        service_ = service;
    }

private:
    static Audio* service_;
};

  与单例模式的实现非常像,但这里的service是由外部传入的。使用时:

//construct
ConsoleAudio* audio = new ConsoleAudio();
Locator::provide(audio);

//call
Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);

   这里关键需要注意的地方调用playSound的代码对于ConsoleAudio完全不知情。它只知道Audio的抽象接口,同样重要的是,甚至是定位器本身和具体服务提供器也没有耦合。代码中唯一知道具体实现类的地方就是提供这个服务的初始化代码。

  这里还有更深一层的解耦——通过服务定位器,Audio接口在绝大多数地方并不知道自己正在被访问。一旦它知道了,它就是一个普通的抽象基类了。这十分有用,因为这意味着我们可以将这个模式应用到一些已经存在但并不是围绕这个来设计的类上。这个单例有个对比,后者影响了“服务”类本身的设计。

空服务

  目前位置,我们的实现还很简单,不过也十分的灵活。但是它有一个较大的缺陷:如果我们尝试在一个服务提供器注册之前使用它,我们会得到一个空对象,如果我们的代码没有检测到这一点,那么游戏将会崩溃。这里我们可以使用要给“空对象(NULL Object)”的设计模式来解决这个问题。原理就是实现一个特殊的空服务,也即是这个空服务实现左右的接口,但什么都不做。当服务定位器返回服务的时候,如果当前没有注册服务,那么返回这个“空服务”。

  这里我们先定义“空服务”提供器。

class NullAudio:public Audio
{
public:
    void playSound(int soundId){}
    void stopSound(int soundId){}
    void stopAllSound(){}
};

  接着修改服务定位器返回服务的代码:

class Locator
{
public:
    static void initialize()
    {
        service_ = &nullService_;
    }

    static Audio& getAudio()
    {
        return &service_;
    }

    static void provide(Audio* service)
    {
        if(service == nullptr)
        {
            service = &nullService_;
        }

        service_ = service;
    }
private:
    Audio* service_;
    NullAudio nullService_;
};

  通过这样的处理,我们能保证一定可以获取到一个有效的服务对象。这种方式还带来一个好处,就是如果我们像要暂时禁用一个系统,很简单,不给服务定位器注册服务提供器,这样它将返回默认的空服务提供器。

日志装饰器

  现在我们的系统十分的强健,让我们讨论另外一项这个模式的优雅之处——装饰的服务。我们举个例子来说明。

  在游戏中,通常会有多个系统。比如AI系统和音频系统,我们会用日志记录AI单位的状态,也会记录每次声音的播放情况,这些日志会帮助我们检测系统是否在正常的运行。典型的解决方法就是调用log函数。但这里会有另外一个问题——我们有太多的日志了。AI程序员不关心什么时候播放声音,而音频程序员也不想知道AI的状态切换。但是现在他们都必须过滤各自的日志信息。理想状态下,我们能够选择性的开启要关心的事件日志,并在游戏最终构建时,没有任何日志。如果将不同系统的条件作为日志服务暴露出去,那么我们可以使用装饰器模式解决这个问题。我们像这样定义另外一个音频服务提供器的实现:

class LoggedAudio:public Audio
{
public:
    LoggedAudio(Audio& wrapped) : wrapped_(wrapped){}
    
    virtual playSound(int soundId)
    {
        log("play sound");
        wrapped_.playSound(soundId);
    }

    virtual stopSound(int soundId)
    {
        log("stop sound");
        wrapped_.stopSound(soundId);
    }

    virtual stopAllSounds()
    {
        log("stop all sound");
        wrapped_.stopAllSounds();
    }
private:
    void log(const char* message)
    {
         //log code..
    }

     Audio& wrapped_; 
};

  如你所见,它包装了另外一个音频提供器并暴露了同样的接口。它将实际的音频提供器转发给内嵌的服务提供器,这样就能记录每次音频调用的情况。我们可以这样使用它:

void enableAudioLoggin()
{
    Audio* service = new LoggedAudio(Locator::getAudio());
    
    //swap it in
    Locator::provide(service);
}

  现在,任何音频服务的调用都会被日志记录下来。同时,它可以和空服务合作良好,也就是你可以既关闭音频又仍然开启声音日志,如果声音开启,它将会播放声音。

设计决策

  上面我们讨论了一个典型的实现,对一些核心问题,不同的答案会有不同的实现。

服务如何被定位的

  • 外部代码注册

  这个是我们例子中使用的方法,同时这也是游戏中最常见的设计。它的特点是:

  简单快捷。getAudio函数简单的返回一个指针,它通常被编译器内联,所以我们得到了一个良好的抽象层而且几乎没有性能损失。

  我们控制提供器如何被创建。假设当我们的服务提供器需要一些外部代码的参数时(比如网络IP地址),如果在定位器中构建这个服务,那么这些参数如何传递了?使用这个方式我们就不同头疼这个问题了,因为服务提供器的构建由我们自己决定,所以只需要在合适的位置构建一个提供器实例再传递给服务定位器即可。比如网络相关的服务,我们可以在网络代码部分构建这样一个提供器即可。

  我们可以在运行的时候更换服务提供器。我们在最终的游戏中不会使用这一点,但是在开发过程中,这是一个很贴心的技巧,我们可以切换服务。比如之前装饰器一节中我们使用空服务的音频服务提供器,这样在游戏运行期间暂时禁止播放音频。

  定位器依赖外部代码,这是个缺点。访问该服务的代码都假定服务已经注册过,如果定位器没有初始化,游戏要么崩溃,要么神秘的无法工作。

  • 在编译时绑定

  这里的想法时使用预处理宏。使得“定位”实际发生在编译器。像这样:

class Locator
{
public:
    static Audio& getAudio()
    {
        return audio_;
    }

private:
    #if DEBUG
        static DebugAudio service_;
    #else
        static ReleaseAudio service_;
    #endif
};

  我们使用一个宏来确定使用哪个服务提供器,这种方式的特点:

    •   它十分的快速。因为编译期就已经确定了服务提供器,所以getAudio很可能被编译器内联实现,这是我们能达到的最快速度;
    •   你能保证服务可用;
    •   但缺点就是不能方便的更改服务提供器。因为绑定发生在编译器,除非重新编译并重启游戏。
  • 在运行时配置

  在企业级软件中,如果你说“服务定位器”,运行时配置就能立马浮现在开发工程师脑中。当服务被请求时,定位器通过一些运行时的操作来捕获被请求服务的真实实现。通常来说,这表示加载一份配置文件来标示服务提供器,然后使用反射来运行器实例化这个类。这为我们做了一些事情。

    •   我们不需要重新编译就能切换服务提供器。这比编译期绑定更具弹性,但比不上注册服务提供器,因为后者能在运行时更换服务提供器;
    •   非程序员能够更换服务提供器。这在设计人员想要开关游戏的某项特性,但不能够自信的摆弄代码时非常有用;
    •   一份代码库能够同时支持多份配置。因为定位过程被完全移出代码库,所以能够使用同样的代码同时支持多个服务配置文件。

  这也是这个模式在企业其web开发中使用的原因:你能够发布单个app就能在不同的服务器上工作,而这只需要修改几个配置就可以。历史上,这在游戏中没有没什么用处,因为游戏终端硬件都十分标准。但随着更多的游戏开始瞄向杂乱的移动设备,这个模式变得越来越有意义。但这个模式也有一些需要我们思考的地方。

    •   不像前几个方案,这个方案比较复杂且十分重量级。你必须创建一个配置系统,很可能会些代码去加载解析配置文件,同时做某些操作来定位服务;而花在这上面的时间,你就不能去开发游戏的其它特性了;
    •   定位服务器需要时间。你可以使用缓存来减缓这一点,但这仍然意味着你第一次使用服务的时候需要花时间来创建它。

当服务不能定位的时候发生什么

  • 让使用者处理

  最简单的方法就是转移责任。如果定位器找不到服务,它不做处理,简单的返回NULL。这意味着

  它让使用者决定如何处理查找失败。不同的使用者可能由不同的需求,有些可能认为不能定位服务是严重的错误,而有些则认为可以安全忽略。所以如果一个定位器不能定义一个全面的策略来处理每种情况,那么将失败传递给调用者,让调用者来决定如何应对;

  服务使用者必须处理查找失败。这是当然的,在每一处使用服务的地方都需要检测服务使用查找是否失败,否则游戏很可能就崩溃,而副作用就是带来了许多重复代码,程序员在使用服务时必须时刻注意。

  • 终止游戏

  我们之前讲到,我们不能证明在编译期始终有效,但这并不意味着我们不能声明可用性是定位服务器运行的一部分,要做到这一点,最简单的就是使用一个断言:

class Location
{
public:
    static Audio& getAudio()
    {
        Audio* service = NULL;
        //code here to locate service...

        assert(service != NULL);
        return NULL;
    }
};

  如果服务没有被定位到,那么游戏在任何后续代码使用之前就会停止。assert的调用并没有解决服务定位失败的问题,但是它明确了这是谁的问题。通过在这里使用断言,我们认为“定位服务失败是定位器的一个bug。”

  那么这么做由什么用了?

  使用者不需要处理一个丢失的服务。因为一个服务可能会在很多处被使用,所以通过声明定位器总是能够正常提供服务,可以为服务使用者免除很多不必要的麻烦;

  如果服务没有被找到,游戏将会中断。这样会让我们不得不去寻找服务定位失败的原因,当然,在这个bug被修复之前,会给程序员增加一个痛苦的停工时间。

  • 返回一个空服务

  在我们之前的例子中就展示了这种优雅的实现,使用这种实现意味着:

    •   使用者不需要处理丢失服务的情况。因为这种实现保证永远返回一个可用的服务;
    •   当服务不可用的时候,游戏还能继续。这有利有弊,好处就是当服务不可用的时候也能运行,这一点对大型团队间的合作非常有利,因为这允许其它一个依赖的特性还没开发出来时,不影响后续开发工作的继续进行。但缺点就是如果遇到非特意丢失服务的情况时,比较难以察觉,这需要多花一些时间去定位错误。

  在这些选项中,被使用最多的时断言能够找到服务。因为当游戏发布时,它已经被频繁的测试过了,并会在一个可靠的硬件上运行。届时服务没有被找到的机会十分渺小。而在大一点的团队中,推荐你使用空服务。这种实现简单有效,而且即使这个服务有bug,它也会提供便利的方式来关闭服务。

服务的作用域多大

  到目前为止,我们假设定位器为每个想要使用它的代码提供访问。它是这个模式典型的使用方式,另外一种选择是限制它的访问到单个类和它的依赖类中,比如:

class Base
{
public:
    //Mothods to locate service and set service..

protected:
    static Audio& getAudio()
    {
        return *service_;
    }

private:
    static Audio* service_;
};

  这样,访问服务被定向到继承了Base的类中。它们都有各自的优势:

  • 如果是全局访问

  它鼓励整个代码库使用同一个服务。大部分服务都趋向独立,通过允许整个代码库访问同一个服务,我们能够避免在代码中因为得不到一个“真正”的服务而随机初始化它们各自的提供器;

  我们对何时使用服务完全失去了控制。这是将事物全局化的代价——任何人都能访问。关于全局作用域的副作用之前在单例模式中已讨论过,无需赘述。

  • 如果访问被限制到类中

  我们控制了耦合。这是主要的优点。通过将服务限制到继承树的一个分支上,我们能确保系统该解耦的地方都解耦了;缺点就是导致重复的工作,如果有几个不相干的类确实需要访问服务,那么它们需要各自的引用。任何定位和注册服务的工作在这些类中都需要重复的处理。(另一个选择就是修改类的继承,给予这些类一个公共的基类,但是相比它的价值而言这会导致更多的问题)。

  我们的一般原则是,如果服务被限制在游戏的一个单域中,那么就把服务的作用域限制到类中。比如,获取网络访问的服务就可能被限制在联网的类中,而更广泛使用的服务,比如日志服务应该是全局的。

 

posted @ 2019-10-27 22:37  北冥有鱼其名为鲲  阅读(328)  评论(0编辑  收藏  举报