“为某服务提供一个全局访问入口来避免使用者与该服务具体实现类之间产生耦合。”
动机
使用音频作为例子。
每一处需要播放音乐的场景都需要类似如下代码去调用音频系统:1
2
3
4
5// Use a static class?
AudioSystem::playSound(VERY_LOUD_BANG);
// Or maybe a singleton?
AudioSystem::instance()->playSound(VERY_LOUD_BANG);
尽管我们实现了想要的目的,但整个过程中却带来了很多耦合。游戏中每一处调用音频系统的地方,都直接引用了具体的AudioSystem类和访问AudioSystem类的机制——使用静态类或者单例。
这里有个更好的解决方法:电话簿。每一个想要联系我们的人能够查找名字来得到我们当前的地址。当我们搬家时,我们告诉电话公司,他们更新电话簿,这样每个人都能得到新的地址了。实际上,我们甚至不必给出我们真正的地址。我们能够列出一个邮政信箱,或者其他能够“代表”我们的东西。通过让访问者查询电话簿来找到我们,我们便有了一个方便的可以控制如何查找我们的地方。
这就是服务定位器模式的简单介绍——它将一个服务的“是什么”(具体实现类型)和“在什么地方”(我们如何得到它的实例)与需要使用这个服务的代码解耦了。
服务定位器模式
一个服务类为一系列操作定义了一个抽象的接口。一个具体的服务提供器实现这个接口。一个单独的服务定位器通过查找一个合适的提供器来提供这个服务的访问,它同时屏蔽了提供器的具体类型来定位这个服务的过程。
使用情境
每当你将东西变得全局都能访问的时候,你就是在自找麻烦。对于何时使用服务定位器,我的简单建议就是:谨慎使用。
与其给需要使用的地方提供一个全局机制来访问一个对象,不如首先考虑将这个对象传递进去。这极其简单易用,而且将耦合变得直观。这可以满足绝大部分需求。
但是有时手动将一个对象传来传去显得毫无理由或者使得代码难以阅读。有些系统,比如日志系统或内存管理系统,不应该是某个模块公开API的一部分。渲染代码的参数必须和渲染有关,而不是像日志系统那样的东西。
同样地,它也适用于一些类似功能的单一系统。你的游戏可能只有一个音频设备或者显示系统让玩家与之打交道。传递的参数是一项环境属性,所以将它传递10层函数以便让一个底层的函数能够访问,为代码增加了毫无意义的复杂度。
在这些情况下,这个模式能够起到作用。它用起来像一个更灵活、更可配置的单例模式。当被合理地使用时,它能够让你的代码更有弹性,而且几乎没有运行时的损失。(相反使用不当时,它会带来单例模式的所有缺点还有糟糕的运行时开销。)
使用须知
服务定位器的关键困难在于,它要有所依赖(连接两份代码),并且在运行时才连接起来。这给予了你弹性,但付出的代价就是阅读代码时比较难以理解依赖的是什么。
服务必须被定位
这个模式需要定位服务,那么我们可能需要处理定位失败的情况。幸运的是,我们将讨论一个策略来处理这个问题,并且保证我们在使用的时候始终能得到某个服务。
服务不知道被谁定位
定位器是全局可访问的,那么游戏中的任何代码都可可能请求一个服务然后操作它。这意味着这个服务在任何情况下都必须能正确工作。因此如果一个类值希望在某个特定的上下文中被使用,那么避免用这种模式将它暴露给全局是最安全的。
示例代码
服务
我们从音频API开始。这就是我们服务将要暴露的接口:1
2
3
4
5
6
7
8class Audio
{
public:
virtual ~Audio() {}
virtual void playSound(int soundID) = 0;
virtual void stopSound(int soundID) = 0;
virutal void stopAllSounds() = 0;
};
服务提供器
我们需要一份具体的实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17class ConsoleAudio : public Audio
{
public:
virtual void playSound(int soundID)
{
// Play sound using console audio api...
}
virtual void stopSound(int soundID)
{
// Stop sound using console audio api...
}
virtual void stopAllSounds()
{
// Stop all sounds using console audio api...
}
};
现在我们有了一个接口和一份实现。剩下的这部分就是服务定位器了——这个类将两者绑在一起。
简单的定位器
下面的实现是你能够定义的最简单的服务定位器:1
2
3
4
5
6
7
8
9
10
11class Locator
{
public:
static Audio* getAudio() { return service_; }
static void provide(Audio* service)
{
service_ = service;
}
private:
static Audio* service_;
};
静态函数getAudio()负责定位工作。我们能在代码的任何地方调用它,它能返回一个Audio服务的实例供我们使用。
(这里使用的技术叫依赖注入——外部代码负责对这个对象注入它所需要的这个依赖实例)1
2Audio *audio = Locator::getAudio();
audio->playSound(VERY_LOUD_BANG);
它”定位”的方法十分简单——在使用这个服务之前它依赖一些外部代码来注册一个服务提供器。当游戏启动时,它调用类似下面的代码:1
2ConsoleAudio* audio = new ConsoleAudio();
Locator::provide(audio);
关键需要注意的地方是调用playSound()的代码对ConsoleAudio具体实现并不知情。它只知道Audio的抽象接口,同样重要的是,甚至定位器本身和具体服务器也没有耦合。
这里还有更深一层的解耦——通过服务定位器,Audio接口在绝大多数地方并不知道自己正在被访问。一旦它知道了,它就是一个普通的抽象基类了。这意味着我们可以将这个模式应用到一些已经存在的但并不是围绕这个类设计的类上。和单例不同的是,后者影响了“服务”类本身的设计。(这也叫“时序耦合”——两份单独的代码必须按正确的顺序来调用来保证程序正确工作)
空服务
它有一个较大的缺陷:如果我们尝试在一个服务器注册之前使用它,那么它会返回一个NULL。如果调用代码没有检查这一点,游戏就会崩溃。
这里有一个称之为“空对象(NULL Object)”的设计模式来解决这个问题。基本的思想是当我们查找或者创建对象失败需要返回“NULL”时,会返回一个实现同样接口的特殊对象作为代替。它的实现就是什么也不做,但是它能让获得这个对象的代码正确运行下去,就好像它获得了一个“真正的”对象一样。
为了使用它,我们定义另外一个“null”服务提供器。1
2
3
4
5
6
7class NULLAudio : public Audio
{
public:
virtual void playSound(int soundID)
virtual void stopSound(int soundID)
virtual void stopAllSounds()
};
它实现了服务接口,但是实际上什么也不做,你可能注意到,我们返回一个引用而不是一个指针,因为在Cpp中,一个引用(理论上)永远不可能为NULL,返回一个引用可以提示使用者它可以期望任何时候都返回一个有效的对象。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Locator
{
public:
static void initialize()
{
service_ = &nullService_;
}
static Audio& getAudio { return *service_; }
static void provide(Audio* service)
{
// Recert to null service.
if( service == NULL) service = &nullService_;
service_ = service;
}
private:
static Audio* service_;
static NullAudio nullService_;
};
调用代码永远也不会知道一个“真”的服务器没有被找到,它也不必担心处理NULL。它保证始终返回一个有效的对象。
这也可以用在希望查找服务失败的情况下。如果我们想要暂时禁用一个系统,那么限制能轻易地做到:很简单,不为这个服务注册服务提供器,然后定位器将默认返回一个空服务提供器。
日志装饰器
让我们来讨论另外一项这个模式的优雅之处——装饰的服务。
在开发中,一小段有价值的事件日志能够让你估摸出在游戏引擎外表之下发生了什么。典型的解决方法是调用一些log()函数。遗憾的是,它用另一个问题替代了前一个问题——现在我们有太多日志了。
理想状态下,我们能够选择性开启要关心的事件日志,并在游戏最终构建时,没有任何日志。如果将不同系统的条件日志作为服务暴露出去,那么我们可以使用装饰器模式来解决这个问题。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29class 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)
{
// Code to log message...
}
Audio &wrapped_;
};
它包装了另外一个音频提供器并暴露了同样的接口。它将实际的音频操作转发给内联的服务提供器,但是它同时记录了每次音频调用。如果一个程序需要开启音频日志,它可以这样调用代码:1
2
3
4
5
6
7
8
9void enableAudioLogging()
{
// Decorate the existing service.
Audio *service = new LoggedAudio(
Locator::getAudio());
// Swap it in.
Locator::provide(service);
}
设计决策
服务是如何被定位的
外部代码注册
- 它简单快捷。getAudio()函数简单地返回一个指针,它通常被编译内联,所以我们得到了一个良好的抽象层而且几乎没有性能损失。
- 我们控制提供器如何被构建。在外部构建代码可以根据当前上下文环境控制应该构建怎么样的注册代码。
- 我们可以在游戏运行的时候更换服务器。我们可能在最终的游戏中不会使用到这点,但是在开发中这是一个很贴心的技巧。
- 定位器依赖外部代码。这是个缺点,访问服务的代码都假设其他代码已经注册过这个服务了,如果没有执行初始化,游戏要么崩溃,要么服务会神秘地无法工作。
在编译时绑定
这里的想法是使用预编译处理宏,使得“定位”这个工作实际上发生在编译器。像这样:1
2
3
4
5
6
7
8
9
10
11class Locator
{
public:
static Audio& getAudio() { return service_; }
private:
#if DEBUG
static DebugAudio service_;
#else
static ReleaseAudio service_;
#endif
};
像这样定位服务器意味着:
- 它十分快速。
- 你能保证服务可用。既然定位器现在拥有服务并在编译期选择它,我们就能保证如果游戏编译,则不必担心服务不可用。
- 你不能方便地更改服务提供器。这是主要的缺点。因为绑定发在在编译期,所以任何时候你想要变动服务,就必须重新编译并且重启游戏。
在运行时配置
当服务被请求时,定位器通过一些运行时的操作来捕获被请求服务的真实实现。
通常来说,这表示加载一份配置文件来标志服务提供器,然后使用反射来在运行期实例化这个类。这为我们做了一些事情。
- 我们不需重编译就能切换服务提供器。这要比编译期绑定更具有弹性。但是比不上一个注册的服务提供器,后者实际上能在游戏运行的时候更换服务提供器。
- 非程序员能够更换服务提供器。
- 一份代码能够同时支持多份配置。因为定位过程完全移出代码库,所以能够使用同样的代码同时支持多个服务配置文件。
- 不像前几个解决方案,这方案比较复杂且十分重量级。你必须创建某个配置系统,很可能会写代码去加载解析文件,并通常做某些操作来定位服务。花在写这些代码上的时间就不能用来写别的游戏特性了。
- 定位服务需要时间。使用运行期配置意味着你在定位服务时耗费CPU周期。
当服务不能被定位时发生了什么
让使用者处理
最简单的解决办法是转移责任。如果定位器找不到服务,那它就返回NULL。这意味着:
- 它让使用者决定如何处理查找失败,让调用者来决定正确的对应方法。
- 服务使用者必须处理查找失败。如果潜在的地方有一次没有做错误检测,我们的游戏就可能会崩溃。
终止游戏
我们不能证明服务在编译期能始终有效,但这并不意味着我们不能声明可用性是定位器运行的一部分。要做到这一点,最简单的方法是使用一个断言:1
2
3
4
5
6
7
8
9
10
11
12class Locator
{
public:
static Audio& getAudio()
{
Audio* service = NULL;
// Code here to locate service...
assert(service != NULL);
return *service;
}
};
assert调用并没有解决服务查找失败的问题,但是它明确了这是谁的问题。通过在这里使用断言,我们认为,”定位服务失败是定位器的一个bug“。
那么,这对我们有什么用呢?
- 使用者不需要处理一个丢失的服务。我们使服务使用者免除了很多不必要的麻烦。
- 如果服务没有被找到,游戏将会中断。在极少的情况下,如果服务真的没有被找到,则游戏会关闭,在bug修复之前,这对任何人来说都是一个拖累,对于大型开发团队来说,当这样的错误出现时,你会增加一些痛苦的程序员的停工时间。
返回一个空服务
我们在简单代码中展示了这种优雅的实现。使用它意味着:
- 使用者不需要处理丢失的服务,简化了使用服务的代码。
- 当服务不可用时,游戏还能继续。好处是允许游戏在没查找到服务的时候也能运行,它的缺点就是,在非特意的丢失服务时难以跟踪。
在大点的团队中,推荐使用空服务。
服务的作用域多大
另外一种选择是限制它的访问到某个类和它的依赖类中,比如:1
2
3
4
5
6
7
8
9
10class Base
{
// Methods to locate service and set service...
protect:
// Derived classes can use service
static Audio& getAudio() { return *service_; }
private:
static Audio* service_;
};
通过这点,访问服务被定向到继承了Base的类中。它们各自都有几点优势:
如果是全局访问
- 它鼓励整个代码库使用同一个服务。大部分服务都趋向是独立的。通过允许整个代码库访问同一个服务,我们能够避免在代码中因为得不到一个“真正”的服务而随机初始化它们各自的提供器。
- 我们对何时何地使用服务完全失去了控制。这是将事物全局化付出的代价——任何人都能访问。
如果访问被限制到类中
- 我们控制了耦合。这是主要的优势。通过将服务限制到继承树的一个分支上,我们能确保系统该解耦的地方解耦了。
- 它可能导致重复的工作。如果有好几个不相干的类确实需要访问服务,那么它们需要有各自的引用。任何定位和注册服务的工作在这些类都要重复地处理。
我的一般原则是,如果服务被限制在游戏的一个单独域中,那么就把服务的作用域限制到类。比如,获取网络访问的服务就可能被限制在联网的类中。而更广泛使用的服务,比如日志服务应该是全局的。