16. Service Locator

16. Service Locator:通过定位器获得服务时仍显式处理注册、缺失、范围和替换,通过可复位因果实验和反例证据验收。

16. Service Locator

学习目标

  • 能画出服务注册与定位流程
  • 能解释null服务兜底
  • 能对比定位器与依赖注入

为什么"16. Service Locator"从问题证据开始

游戏里有些"全局服务"被所有系统使用:音频、渲染、网络。最直觉的做法是把服务实例做成单例或全局对象,各处直接访问。代码今天能跑,但两个问题浮现:换实现困难(想把音频从 OpenAL 换成 DirectSound,所有调用点都要改)、测试困难(全局服务无法在测试中替换为替身)。

本页把"无模式基线"定义为"全局对象直接访问"的代码,然后引入服务定位器作为候选机制,验证它是否真的让"换实现只改一处、测试可注入替身"。通过条件是客户端通过定位器按名获取服务,具体实现可随时替换——而不是客户端直接持有实现。

🔮 先预测:把音频实现从 OpenAL 换成 DirectSound,客户端代码要改吗?

来源、版本与独立重写边界

本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。

本章机制与术语

理解 Service Locator 需要四个核心概念:

  • :被多个系统使用的全局能力(音频、渲染、网络),有明确的接口定义。
  • :服务的具体实现——同一服务可有多套实现(真实版、null 版、日志版)。
  • :维护"服务名 → 服务实例"映射的中介——客户端通过它获取服务。
  • :客户端只认识服务接口与定位器,不认识具体实现——换实现只改注册。

四个概念共同约束"通过定位器获得服务时仍显式处理注册、缺失、范围和替换":任何结论都必须回到"客户端是否只见接口、缺失是否兜底、替换是否只改一处"三个可观察量。

Decoupling Pattern · Service Locator

Service Locator — 服务的电话簿

▷ 可交互
客户端只认服务名,实现是谁随时可换服务注册到定位器,客户端按名获取ServiceLocator服务注册表AudioServiceRenderServiceNetworkServiceSaveService客户端locator.get("Audio")请求返回实例AudioService 实现客户端不知道具体实现换实现(OpenAL→DirectSound)只改注册;null 服务兜底缺失,避免空指针

第 1 / 4 步 · ① 服务注册:音频/渲染/网络注册到定位器

定位器是服务的电话簿:客户端只认名字,具体实现可随时替换——但要管理缺失与范围。

💡 对着动画看:动画是"服务电话簿"——左侧 ServiceLocator 注册表(Audio/Render/Network/Save 四个服务),中间客户端 locator.get("Audio") 请求,右侧返回具体实现。注意客户端到实现之间隔了定位器——客户端不知道也不关心实现是谁。读"简单定位器"一节时,想想为什么换 OpenAL→DirectSound 只需改注册那一行。

官方结构逐项深读

16. Service Locator

Service Locator 的主张:用一层"定位"把客户端与服务实现解耦。服务注册到定位器(名字 → 实现),客户端通过定位器按名获取——客户端只见服务接口,不见具体实现。换实现 = 改注册表(运行时可换),测试 = 注册替身。它解决了全局服务的两个痛点(替换难、测试难),代价是引入一个全局中介(定位器本身)。

Intent

意图:提供"按名取服务"的统一入口,把服务使用与服务提供解耦。客户端代码只依赖两个东西:服务接口(类型)和定位器(取法)。服务实现如何构造、用哪个平台 API、是否真实存在——客户端全不知情。这让服务可替换、可代理、可 mock,是"依赖倒置"在全局服务场景的落地。

Motivation

音频系统的经典困境:游戏开发期用 OpenAL,发布期可能换 DirectSound/Wwise;测试时希望"假音频"(不发声音、只记录调用)。如果客户端直接持有 AudioSystem* 全局对象,三种实现(开发/发布/测试)意味着改客户端或改全局初始化——都麻烦。定位器把选择权收拢到一处:注册哪个实现,由启动配置决定,客户端代码一行不改。

The Pattern

模式结构:服务接口class Audio { virtual play(id) = 0; })→ 服务提供者OpenALAudio : AudioNullAudio : Audio)→ 定位器static Audio* audio; + static Audio& getAudio())→ 注册locator.provide(new OpenALAudio))。客户端只调 Audio::play() 通过定位器获得的服务——接口是唯一契约。

When to Use It

何时用服务定位器:有跨系统共享的服务(音频、日志、网络、存档)且实现可能替换/测试需要替身。何时不用:服务只有一个实现且不会换(定位器是多余间接层)、服务被少数模块使用(直接传引用即可)、依赖注入已足够(构造时显式传服务,比定位器更清晰——定位器是"全局注入"的简化版)。判断标准:换实现的频率 × 服务使用者数量。

Keep in Mind

两个注意点:服务必须能被定位(注册表里没有就报错或给 null——要有缺失处理);服务不知道谁在定位它(定位器是单向的:客户端找服务,服务不反向找客户端——保持方向清晰)。作者强调:定位器是"必要的全局",要用得克制——它是全局变量的正规化形态。

The service actually has to be located

定位失败的场景:服务没注册就被请求。处理策略:返回 null(客户端判空——把错误推给调用方)、返回 null 服务(见下节——静默空实现,避免判空)、抛异常/断言(快速失败——开发期暴露问题)。作者推荐:默认 null 服务(游戏不该因"音频没注册"崩溃),开发期用断言辅助。

The service doesn't know who is locating it

定位器的单向性:服务提供者不应该知道客户端是谁、被谁使用。音频服务就是"播放声音"——它不记录"谁在播"。这保持了解耦的干净:服务对使用方零假设,客户端可增可减,服务无感。如果服务需要回调使用者(如"播放完成通知"),走事件/回调接口而非反向依赖定位器。

Sample Code

示例代码:Audio 抽象接口 → ConsoleAudio(真实实现)→ NullAudio(空实现)→ AudioLocator(静态 provide/get)。启动时 AudioLocator::provide(new ConsoleAudio);客户端 AudioLocator::getAudio().play(...);测试时 provide(new NullAudio)。三行代码完成服务替换——这就是定位器的全部价值。

The service

服务接口设计:接口要小(只有客户端真正需要的方法——play(id)stop(id)setVolume()),语义要稳定(接口是契约,实现可以换,接口不变)。服务接口是定位器方案的基石——接口定得差,换实现就失去意义。设计时站在客户端视角问:"我调用音频服务时,需要哪些能力?"而非站在实现视角。

The service provider

服务提供者:每个实现类实现服务接口。真实实现(对接平台 API)、null 实现(空操作——测试/降级用)、日志实现(记录调用——调试/审计用,装饰器模式)。多个提供者按场景注册:开发注册真实版、CI 注册 null 版、排查注册日志版——客户端代码不变,注册表决定行为。这是策略模式在服务层的外壳。

A simple locator

最简单的定位器:静态成员 + 静态方法——class AudioLocator { static Audio* service; public: static Audio& getAudio(); static void provide(Audio*); }getAudio() 返回引用(null 服务保证非空),provide() 替换实现。全局唯一(所有客户端共享同一个注册表)。这个"简单到令人发指"的实现就是够用的 90% 场景——不要过早引入 DI 容器。

A null service

null 服务模式:空实现的 Audio——每个方法什么都不做。getAudio() 默认返回 null 服务,未注册时客户端照常调用但无效果。好处:零判空(客户端永远拿到可用服务)、优雅降级(缺音频系统也能跑)、测试天然替身(测试注册 null 或自定义替身)。null 服务是定位器"缺失兜底"的标准答案。

Logging decorator

日志装饰器:一个包装真实服务的提供者——转发调用给真实实现,同时打印调用日志。LoggingAudio : Audio 持真实 Audio*play(id)log("play: " + id); inner->play(id);。调试时注册日志版(provide(new LoggingAudio(new ConsoleAudio))),排查"谁在播什么"——不用改客户端一行代码。这是装饰器模式在服务层的应用。

Design Decisions

三个关键设计决策:服务如何定位(静态注册表 vs 按需构造 vs 分层定位)、找不到怎么办(null vs 报错 vs 断言)、服务的作用域(全局单例 vs 每场景不同)。前两者决定定位器形态,后者决定生命周期——下面三节展开。

How is the service located?

定位方式:静态注册表(map 存服务,provide/get——简单直接,游戏主流)、按需构造(get 时如果没注册就 new 默认实现——省去显式注册,但隐藏构造时机)、分层定位(先查本地再查全局——支持"每关卡替换音频"的覆盖机制)。作者建议:静态注册表起步,需要"局部覆盖"时再叠加分层。

What happens if the service can't be located?

找不到服务:返回 null 服务(静默空实现——游戏继续跑,推荐默认)、返回 null 指针(客户端判空——把错误显式化,但到处判空)、断言/异常(快速失败——开发期暴露,发布期危险)。作者建议:默认 null 服务(游戏不该因缺失服务崩溃),调试构建加断言辅助定位问题。

What is the scope of the service?

服务作用域:全局单例(一个服务全游戏共享——最简,但无法差异化:菜单音频与战斗音频要不同策略就难)、每场景/每状态不同(进入战斗注册战斗音频服务——灵活,但要管理切换时机与生命周期)。作者建议:默认全局单例,真正需要差异化时(不同模式不同音频策略)再引入作用域切换。

See Also

  • Singleton:定位器与单例的对比——定位器提供"可替换的全局服务",单例提供"唯一的全局对象";定位器在需要换实现时胜出。
  • Subclass Sandbox:沙箱把引擎能力收进基类,定位器把服务访问收进中介——一个管行为侧,一个管服务侧。
  • 依赖注入:注入是"显式传服务",定位器是"隐式取服务"——注入更清晰,定位器更省事,两者可结合。

可迁移实现或计算骨架

initial state -> 定义服务接口 -> 实现提供者(真实/null/日志)-> 启动时注册 -> 客户端 getAudio() 获取 -> 调用接口方法
fault injection -> 客户端直接持有全局服务实现
pass condition -> 换实现只改注册一行、缺失有 null 兜底、测试可注入替身
reset -> initial state

对应实现要点:服务接口小而稳定;定位器静态注册表 + provide/get;默认注册 null 服务保底;测试在 setUp 注册替身、tearDown 恢复。该骨架只保存实验合同;真实项目还要固定服务清单、注册时机(启动序)与作用域切换规则,并保留基线实现以便回退。

本章练习与节点验证矩阵

练习

问题 1:替换实验。

操作:在动画中把 Audio 从 ConsoleAudio 换成 LoggingAudio(只改注册)

问题 2:null 兜底。

操作:不注册任何服务直接 getAudio() 并调用

问题 3:测试替身。

操作:测试中注册一个"记录调用"的假音频,断言调用序列

问题 4:作用域对比。

操作:对比全局单例与每场景注册两种 scope 的实现复杂度

术语复核

名词解释

本章出现的专业名词,用大白话再讲一遍。

服务
服务提供者
定位器

术语复核与本章回顾

掌握"16. Service Locator"意味着能从"全局对象直接访问让换实现和测试都困难"出发,解释服务、服务提供者、定位器与解耦四者的关系,再用"客户端是否只见接口、缺失是否兜底、替换是否只改一处"三个可观察量推翻或保留实现。若三个判据不能同时满足,本章仍未通过。

一句话回顾:服务放进口电话簿(定位器),客户端只按名取——换实现改注册一行、缺服务有 null 兜底、测试注入替身,全局服务的三个痛点一次解决。

阅读导航

← 上一页:15. Event Queue · 下一页:VI. Optimization Patterns → ← 上一页:15. Event Queue · 下一页:VI. Optimization Patterns →

资料与写作方式声明

本章以Robert Nystrom《Game Programming Patterns》(游戏编程模式)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…