服务定位器
游戏设计模式Decoupling Patterns
意图
提供服务的全局接入点,避免使用者和实现服务的具体类耦合。
动机
游戏中的一些对象或者系统几乎跑遍代码库的每一个角落。 很难找到游戏中的哪一部分在某个时刻不需要内存分配器、日志或者随机数。 像这样的东西可以被视为整个游戏都需要的服务。
我们以音频为例。 它的触达范围不像内存分配器这类底层设施那么广,但仍然会触及一大堆游戏系统。 坠落的岩石轰然砸在地面上(物理)。 NPC狙击手扣动扳机,枪声响起(AI)。 用户选中菜单项时,伴随一声确认的哔哔声(用户界面)。
这些地方都需要能用下面这样的方式调用音频系统:
// 使用静态类?
AudioSystem::playSound(VERY_LOUD_BANG);
// 还是使用单例?
AudioSystem::instance()->playSound(VERY_LOUD_BANG);
这两种方式都能带我们到达目的地,但一路上我们会陷入一些棘手的耦合。
游戏中每一处调用音频系统的地方都直接引用了具体的AudioSystem类,以及访问它的机制——无论是作为静态类还是单例。
这些调用点当然需要耦合到某些东西才能播放声音, 但让它们直接摆弄具体的音频实现,就好像给了一百个陌生人你家的路线,只是为了让他们在门口放一封信。 这不只是有点过于私密;等你搬家时,还得挨个告诉每个人新的路线,那才叫真的麻烦。
有个更好的解决办法:一本电话簿。 需要联系我们的人可以按名字查找到我们,并获取我们当前的地址。 当我们搬家时,我们通知电话公司。 他们更新电话簿,每个人就都知道了新地址。 事实上,我们甚至无需给出真实的地址。 我们可以列一个邮政信箱或者其他“代表”我们的东西。 通过让调用者查询电话簿来找我们,我们就有了一个方便的单一地点,用来控制别人如何找到我们。
简而言之,这就是服务定位器模式——它把需要服务的代码与服务是谁(具体实现类型)以及服务在哪里(如何拿到它的实例)解耦开来。
模式
服务类定义了一组操作的抽象接口。 具体的服务提供者实现这个接口。 独立的服务定位器通过查找合适的提供者来提供对服务的访问,同时隐藏提供者的具体类型和定位它的过程。
何时使用
每当你让某物在程序的各处都能被访问时,你就是在自找麻烦。 这是单例模式的主要问题,这个模式也不例外。 我对何时使用服务定位器的最简单建议是:少用。
与其使用全局机制让某些代码访问它所需的对象,不如首先考虑把对象传给它。 这再简单不过了,而且它让耦合一目了然。这就能满足你大部分的需求。
但是…… 有时候手动把对象传来传去是多余的,甚至反而会让代码更难阅读。 有些系统,比如日志或内存管理,不该是模块公开API的一部分。 传给渲染代码的参数应该与渲染相关,而不是与日志之类的东西相关。
同样,还有些系统代表本质上就是独一无二的设施。 你的游戏很可能只有一个可以通信的音频设备或显示系统。 它是环境固有的属性,所以仅仅为了让一个深层嵌套的调用能用上它,就把它一路传过十层方法,只会给代码增加不必要的复杂度。
在这种情况下,这个模式能帮上忙。 正如我们将看到的,它就像单例模式的一个更加灵活、更加可配置的表亲。 如果用得好,它能让你的代码库更灵活,而运行时开销很小。
记住
使用服务定位器的核心难点在于,它把一项依赖——两块代码之间的一点耦合——推迟到运行时才进行连接。 这带来了灵活性,但代价是,仅靠阅读代码更难理解你的依赖是什么。
服务必须真的可定位
如果使用单例或者静态类,我们需要的实例不可能不可用。 调用代码可以想当然地认为它就在那里。但由于这个模式需要定位服务,我们也许要处理失败的情况。 幸运的是,我们之后会介绍一种处理它的策略,保证我们在需要时总能获得某个服务。
服务不知道谁在定位它
由于定位器是全局可访问的,游戏中的任何代码都可以请求服务,然后摆弄它。 这就意味着服务必须在任何环境下都能正确工作。 举个例子,如果一个类只能在游戏循环的模拟部分使用,而不能在渲染部分使用,那它可能不适合作为服务——它无法确保自己在正确的时间被使用。 所以,如果你的类只期望在特定上下文中使用,最安全的做法是避免用这个模式把它暴露给整个世界。
示例代码
让我们回到音频系统的问题,通过服务定位器把这个系统暴露给代码库的其余部分。
服务
我们从音频API开始。这是我们服务要暴露的接口:
class Audio
{
public:
virtual ~Audio() {}
virtual void playSound(int soundID) = 0;
virtual void stopSound(int soundID) = 0;
virtual void stopAllSounds() = 0;
};
当然,一个真实的音频引擎比这复杂得多,但这展示了基本的理念。 要点在于它是一个没有绑定任何实现的抽象接口类。
服务提供者
只靠它自己,我们的音频接口不是很有用。 我们需要具体的实现。本书不讨论如何为游戏主机编写音频代码,所以你得想象这些函数体中有实际的代码,了解原理就好:
class ConsoleAudio : public Audio
{
public:
virtual void playSound(int soundID)
{
// 使用主机音频API播放声音……
}
virtual void stopSound(int soundID)
{
// 使用主机音频API停止声音……
}
virtual void stopAllSounds()
{
// 使用主机音频API停止所有声音……
}
};
现在我们有接口和实现了。 剩下的部分是服务定位器——那个将两者绑在一起的类。
一个简单的定位器
下面的实现是你可以定义的最简单的服务定位器:
class Locator
{
public:
static Audio* getAudio() { return service_; }
static void provide(Audio* service)
{
service_ = service;
}
private:
static Audio* service_;
};
静态函数getAudio()完成了定位工作。
我们可以从代码库的任何地方调用它,它会给我们一个Audio服务实例使用:
Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);
它“定位”的方式十分简单——依靠一些外部代码在任何代码试图使用服务之前就注册了服务提供者。 当游戏启动时,它会调用一些这样的代码:
ConsoleAudio *audio = new ConsoleAudio();
Locator::provide(audio);
这里要注意的关键点是,调用playSound()的代码并不知道具体的ConsoleAudio类;
它只知道抽象的Audio接口。
同样重要的是,连定位器类都没有与具体的服务提供者耦合。
代码中唯一知道具体类的地方,就是提供服务的初始化代码。
这里还有一层解耦:
Audio接口并没有意识到,在大多数地方它是通过服务定位器被访问的。
就它自己而言,它只是一个普通的抽象基类。
这很有用,因为这意味着我们可以把这个模式应用到现有的类上,而那些类不必专门为它设计。
这与单例形成了对比,后者会影响“服务”类本身的设计。
一个空服务
我们现在的实现很简单,而且也很灵活。
但它有一个很大的缺点:如果我们在服务提供者注册之前就使用服务,它会返回NULL。
如果调用代码没有检查,游戏就崩溃了。
幸运的是,还有一种设计模式叫做“空对象”,我们可以用它来处理这个问题。
基本思路是,在那些找不到或创建不了对象、原本会返回NULL的地方,
改为返回一个特殊对象,它实现了与所需对象相同的接口。
它的实现基本上什么也不做,但能让收到该对象的代码安全地继续运行,就像收到的是“真的”对象一样。
为了使用它,我们定义另一个“空”服务提供者:
class NullAudio: public Audio
{
public:
virtual void playSound(int soundID) { /* 什么也不做 */ }
virtual void stopSound(int soundID) { /* 什么也不做 */ }
virtual void stopAllSounds() { /* 什么也不做 */ }
};
就像你看到的那样,它实现了服务接口,但没有实际做任何事情。 现在,我们把定位器改成这样:
class Locator
{
public:
static void initialize() { service_ = &nullService_; }
static Audio& getAudio() { return *service_; }
static void provide(Audio* service)
{
if (service == NULL)
{
// 退回空服务
service_ = &nullService_;
}
else
{
service_ = service;
}
}
private:
static Audio* service_;
static NullAudio nullService_;
};
调用代码永远不知道“真正的”服务没找到,也不必担心处理NULL。
这保证了它永远能获得有效的对象。
这对故意找不到服务也很有用。 如果我们想暂时停用某个系统,现在有了一个简单的办法: 不注册该服务的提供者,定位器就会默认使用空提供者。
日志装饰器
现在我们的系统相当健壮了,让我们讨论这个模式带来的另一种改进——装饰服务。 我会举例说明。
在开发过程中,当有趣的事件发生时记录一点日志,能帮你弄清楚游戏引擎内部发生了什么。 如果你在处理AI,你会想知道哪个实体改变了AI状态。 如果你是音频程序员,你也许想记录每个播放的声音,以便检查它们是否按正确的顺序触发。
通常的解决方案是向代码里到处散布对某个log()函数的调用。
不幸的是,这是用一个问题取代另一个——现在我们有太多日志了。
AI程序员不关心声音在什么时候播放,声音程序员也不在乎AI状态转换,但现在他们都得在对方的消息里跋涉。
理想情况下,我们应该能够只为关心的事物有选择地启用日志,而在最终的成品游戏里完全没有日志。 如果我们想有条件地记录日志的不同系统都被暴露为服务,那么我们就可以用装饰器模式来解决这个问题。 让我们定义另一个音频服务提供者的实现:
class LoggedAudio : public Audio
{
public:
LoggedAudio(Audio &wrapped)
: wrapped_(wrapped)
{}
virtual void playSound(int soundID)
{
log("play sound");
wrapped_.playSound(soundID);
}
virtual void stopSound(int soundID)
{
log("stop sound");
wrapped_.stopSound(soundID);
}
virtual void stopAllSounds()
{
log("stop all sounds");
wrapped_.stopAllSounds();
}
private:
void log(const char* message)
{
// 记录日志的代码……
}
Audio &wrapped_;
};
如你所见,它包装了另一个音频提供者,暴露同样的接口。 它将实际的音频行为转发给内部的提供者,但它也同时记录每个音频调用。 如果程序员需要启动音频日志,他们可以这样调用:
void enableAudioLogging()
{
// 装饰现有的服务
Audio *service = new LoggedAudio(Locator::getAudio());
// 将它换进来
Locator::provide(service);
}
现在,任何对音频服务的调用都会先被记录,然后照常继续。 而且,当然,这还能与我们的空服务很好地配合,所以你既可以禁用音频,同时仍然记录下如果音频启用时它本该播放的声音。
设计决策
我们已经讲了一种典型实现,但它可以基于几个核心问题的不同答案而有几种变化:
服务是如何被定位的?
-
外部代码注册:
这是样例代码中定位服务使用的机制,这也是我在游戏中最常见的设计方式:
-
简单快捷。
getAudio()函数简单地返回一个指针。它通常会被编译器内联,所以我们几乎没付出性能损失,就获得了一个很好的抽象层。 -
可以控制如何构建提供者。 考虑一个用于访问游戏控制器的服务。我们有两个具体的提供者:一个用于常规游戏,另一个用于在线游戏。 在线提供者通过网络传递控制器的输入,这样,对游戏的其他部分来说,远程玩家就好像在使用本地控制器。
为了能正常工作,在线的具体提供者需要知道其他远程玩家的IP地址。 如果定位器本身构建对象,它怎么知道该传入什么?
Locator类对在线的情况一无所知,更不用说其他用户的IP地址了。外部注册的提供者避开了这个问题。不再由定位器构造类,而是由游戏的网络代码实例化在线专用的服务提供者, 传入它需要的IP地址。然后把它交给定位器,而定位器只知道服务的抽象接口。
-
可以在游戏运行时改变服务。 我们也许不会在最终的游戏版本中用到这个,但这是开发过程中很实用的技巧。 举个例子,在测试时,即使游戏正在运行,我们也可以把音频服务换成早先提到的空服务,临时关闭声音。
-
定位器依赖外部代码。 这是缺点。任何访问服务的代码都假定某处的代码已经注册过它了。 如果没有进行初始化,要么游戏会崩溃,要么服务会莫名地不工作。
-
-
在编译时绑定:
这里的思路是,通过预处理器宏让“定位”过程实际发生在编译期。就像这样:
class Locator { public: static Audio& getAudio() { return service_; } private: #if DEBUG static DebugAudio service_; #else static ReleaseAudio service_; #endif };像这样定位服务意味着几件事:
-
快速。 所有真正的工作都在编译时完成,运行时无需再做任何事。 编译器很可能会内联
getAudio()调用,这给了我们一个能想到的最快的方案。 -
能保证服务是可用的。 由于现在定位器拥有服务,并在编译期选定它,我们可以确信,只要游戏能编译通过,就不必担心服务不可用。
-
无法轻易改变服务。 这是主要的缺点。由于绑定发生在构建时,任何时候你想要改变服务,都得重新编译并重启游戏。
-
-
在运行时配置:
在企业级商业软件那个满眼卡其裤的国度里,如果你说“服务定位器”,他们想到的就会是这个。 当服务被请求时,定位器会在运行时施展一些“魔法”,搜寻被请求的实际实现。
通常,这意味着加载一个标识提供者的配置文件,然后用反射在运行时实例化那个类。这为我们带来几点好处:
-
我们可以更换服务而无需重新编译。 这比编译时绑定的服务稍微灵活一点,但不如注册方式灵活,因为后者你可以在游戏运行中真正改变服务。
-
非程序员也可改变服务。 这对设计师很有用,当他们想要开关某些游戏特性、又不习惯在源代码里翻来翻去时。 (或者,更可能的是,程序员不放心让他们在源代码里翻来翻去。)
-
同样的代码库可以同时支持多种配置。 由于定位过程被完全移出了代码库,我们可以使用相同的代码来同时支持多种服务配置。
这就是这个模型在企业级Web领域具有吸引力的原因之一: 只需要修改一些配置,你就可以在不同的服务器环境上部署同一个应用。 从历史上看,这在游戏中用处较小,因为主机硬件相当标准化, 但随着越来越多游戏面向五花八门的移动设备,这一点正变得越来越重要。
-
复杂。 不像前面的解决方案,这个方案相当重量级。 你得创建某种配置系统,也许还要写代码来加载和解析文件,通常还得鼓捣一番才能定位服务。 花在写这些代码上的时间,就不能再花在其他的游戏特性上。
-
定位服务需要时间。 现在笑容真的要变成愁容了。采用运行时配置意味着你在消耗CPU周期来定位服务。 缓存可以把这点降到最低,但那仍然意味着首次使用服务时,游戏得花点时间去找到它。 游戏开发者讨厌把CPU周期消耗在不能提升玩家游戏体验的事情上。
-
如果服务不能被定位怎么办?
-
让使用者处理它:
最简单的解决方案就是把皮球踢回去。如果定位器不能找到服务,只需返回
NULL。这意味着:-
让使用者决定如何应对失败。 某些使用者可能认为找不到服务是应该中止游戏的关键错误。 其他使用者则可能可以安全地忽略它并继续。 如果定位器无法定义一条适用于所有情况的统一策略,那么把失败传下去,让每个调用点自己决定正确的响应是什么。
-
服务的使用者必须处理失败。 当然,随之而来的必然结果是每个调用点都必须检查是否没找到服务。 如果几乎所有调用点都以相同方式处理失败,那代码库里就会散布大量重复代码。 如果潜在的数百处使用服务的地方中有一处忘了检查,我们的游戏就会崩溃。
-
-
中止游戏:
我说过,我们无法证明服务在编译期总是可用,但这并不意味着我们不能声明可用性是定位器运行时契约的一部分。 最简单的方法就是使用断言:
class Locator { public: static Audio& getAudio() { Audio* service = NULL; // Code here to locate service... assert(service != NULL); return *service; } };如果服务没有被定位,游戏会在任何后续代码试图使用它之前停下来。 这里的
assert()调用没有解决无法定位服务的问题,但它确实把这是谁的问题说清楚了。 通过在这里断言,我们在说:“无法定位服务是定位器里的缺陷。”那么这为我们做了什么呢?
-
使用者不必处理缺失的服务。 单个服务可能被用在成百上千个地方,这能显著节省代码。 通过声明始终提供服务是定位器的职责,我们让服务的使用者不必再去填补这个空缺。
-
如果服务没有找到,游戏会中止。 在极少数情况下,服务真的找不到,游戏就会中止。 好处在于它迫使我们解决阻碍服务被定位的缺陷(很可能是某些初始化代码没有在应该调用时被调用), 但对其他所有被它卡住、直到修复为止的人来说,这真的很磨人。在大开发团队中,这类问题可能导致程序员痛苦的停工时间。
-
-
返回空服务:
我们在示例实现中展示过这种改进。使用它意味着:
-
使用者不必处理缺失的服务。 就像前面的选项一样,我们保证总是会返回一个有效的服务对象,从而简化使用服务的代码。
-
如果服务不可用,游戏仍将继续。 这既有利也有弊。好处在于,即使服务不在,我们也能继续运行游戏。 在大型团队里,当我们正在做的功能依赖的某个系统尚未就绪时,这会非常有帮助。
缺点在于,调试无意中缺失的服务会更难。 假设游戏用某个服务获取数据,然后基于这些数据做出决策。 如果我们没有注册真正的服务,那段代码拿到的是空服务,游戏可能不会按我们期望的方式运行。 需要花一些工夫,才能把问题追溯到“我们以为服务在,其实它并不在”这个事实上。
-
在这些选项里,我看到最常用的就是简单地断言服务一定能被找到。 等到游戏发布时,它已经被充分测试过了,而且很可能会运行在可靠的硬件上。 到那时,服务找不到的几率已经很小了。
在更大的团队中,我鼓励你加一个空服务。 它实现起来不费多少力气,还能让你在开发中服务不可用时少受一些停工之苦。 它也给了你一个简单的方式去关闭服务,无论它是有缺陷,还是只是干扰了你手头的工作。
服务的范围有多大?
到目前为止,我们一直假设定位器会向任何想要它的人提供服务的访问。 虽然这是这个模式的典型用法,但另一个选项是把访问限制到单个类及其子类,就像这样:
class Base
{
// 定位和设置服务的代码……
protected:
// 派生类可以使用服务
static Audio& getAudio() { return *service_; }
private:
static Audio* service_;
};
这样一来,对服务的访问就被限制在继承了Base的类中。两种方式各有利弊:
-
如果全局可访问:
-
鼓励整个代码库使用同样的服务。 大多数服务本来就该是独一无二的。 通过允许整个代码库访问同一个服务,我们可以避免代码里一些地方因为拿不到“真正的”服务而各自实例化自己的提供者。
-
我们失去了何时何地使用服务的控制权。 这是让某物全局化的明显代价——任何东西都能接触到它。单例一章为全局作用域可能引发的恐怖剧列出了全套角色。
-
-
如果访问被限制在某个类中:
-
我们控制了耦合。 这是主要的优点。通过把服务限制在继承树的某个分支上,我们可以确保本应解耦的系统保持解耦。
-
可能导致重复的付出。 潜在的缺点是,如果一对无关的类确实需要访问服务,它们各自都需要持有对服务的引用。 无论用什么流程来定位或注册服务,都不得不在这些类之间重复。
(另一个选项是调整类的继承层次,给这些类一个公共的基类,但那可能得不偿失。)
-
我的通用准则是,如果服务局限在游戏的某一个领域中,那就把它的范围限制在一个类上。 举个例子,用于访问网络的服务大概可以限制在联网类中。 像日志这样被更广泛使用的服务则应该是全局的。
参见
-
服务定位器模式在很多方面是单例模式的兄弟,因此值得两者都看看,以确定哪个最适合你的需求。
-
Unity框架在其
GetComponent()方法中配合组件模式使用了这个模式。 -
微软的XNA游戏开发框架在它的核心
Game类中内建了这种模式。 每个实例都有一个GameServices对象,可用来注册和定位任何类型的服务。