目錄

  • 致謝
  • 序
    • 架構,性能和遊戲
  • 重訪設計模式
    • 命令模式
    • 享元模式
    • 觀察者模式
    • 原型模式
    • 單例模式
    • 狀態模式
  • 序列模式
    • 雙緩衝模式
    • 遊戲循環
    • 更新方法
  • 行爲模式
    • 字節碼
    • 子類沙箱
    • 類型對象
  • 解耦模式
    • 組件模式
    • 事件隊列
    • 服務定位器
  • 優化模式
    • 數據局部性
    • 髒標識模式
    • 對象池模式
    • 空間分區
← 上一章     § Contents   ≡ 首頁   下一章 →  

服務定位器 Service Locator

遊戲設計模式 Game Programming PatternsDecoupling 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_;
};

這裏用的技術被稱爲依賴注入,一個簡單思路的複雜行話表示。 假設你有一個類依賴另一個。 在例子中,是我們的Locator類需要Audio的實例。 通常,定位器負責構造實例。 依賴注入與之相反,它指外部代碼負責向對象注入它需要的依賴。

靜態函數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_;
};

你也許注意到我們用引用而非指針返回服務。 由於C++中的引用(理論上)永遠不是NULL,返回引用是提示用戶:總可以期待獲得一個合法的對象。

另一件值得注意的事是我們在provide()而不是訪問者中檢查NULL。 那需要我們早早調用initialize(),保證定位器可以正確找到默認的空服務提供者。 作爲回報,它將分支移出了getAudio(),這在每次使用服務時節約了檢查開銷。

調用代碼永遠不知道“真正的”服務沒找到,也不必擔心處理NULL。 這保證了它永遠能獲得有效的對象。

這對故意找不到服務也很有用。 如果我們想暫時停用系統,現在有更簡單的方式來實現這點了: 很簡單,不要在定位器中註冊服務,定位器會默認使用空服務提供器。

在開發中能關閉音頻是很便利的。它釋放了一些內存和CPU循環。 更重要的是,當你使用debugger時正好爆發巨響,它能防止你的鼓膜爆裂。 沒有什麼東西比二十毫秒的最高音量尖叫循環更能讓你血液逆流的了。

日誌裝飾器

現在我們的系統非常強健了,讓我們討論這個模式允許的另一個好處——裝飾服務。 我會舉例說明。

在開發過程中,記錄有趣事情發生的小小日誌系統可助你查出遊戲引擎正處於何種狀態。 如果你在處理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()調用,這是我們能達到的最快方案。

    • 能保證服務是可用的。 由於定位器現在擁有服務,在編譯時就進行了定位,我們可以保證遊戲如果能完成編譯,就不必擔心服務不可用。

    • 無法輕易改變服務。 這是主要的缺點。由於綁定發生在編譯時,任何時候你想要改變服務,都得重新編譯並重啓遊戲。

  • 在運行時設置:

    企業級軟件中,如果你說“服務定位器”,他們腦中第一反應就是這個方法。 當服務被請求時,定位器在運行時做一些魔法般的事情來追蹤請求的真實實現。

    反射 是一些編程語言在運行時與類型系統打交道的能力。 舉個例子,我們可以通過名字找到類,找到它的構造器,然後創建實例。

    像Lisp,Smalltalk和Python這樣的動態類型語言自然有這樣的特性,但新的靜態語言比如C#和Java同樣支持它。

    通常而言,這意味着加載設置文件確認提供者,然後使用反射在運行時實例化這個類。這爲我們做了一些事情:

    • 我們可以更換服務而無需重新編譯。 這比編譯時綁定多了小小的靈活性,但是不像註冊那樣靈活,那裏你可以真正地在運行遊戲的時候改變服務。

    • 非程序員也可改變服務。 這對於設計師是很好的,他們想要開關某項遊戲特性,但修改源代碼並不舒服。 (或者,更可能的,編程者 對設計者介入感到不舒服。)

    • 同樣的代碼庫可以同時支持多種設置。 由於從代碼庫中完全移出了定位處理,我們可以使用相同的代碼來同時支持多種服務設置。

      這就是這個模型在企業網站上廣泛應用的原因之一: 只需要修改設置,你就可以在不同的服務器上發佈相同的應用。 歷史上看來,這在遊戲中沒什麼用,因爲主機硬件本身是好好標準化了的, 但是很多遊戲的目標是大雜燴般的移動設備,這點就很有關係了。

    • 複雜。 不像前面的解決方案,這個方案是重量級的。 你得創建設置系統,也許要寫代碼來加載和粘貼文件,通常要做些事情來定位服務。 花時間寫這些代碼,就沒法花時間寫其他的遊戲特性。

    • 加載服務需要時間。 現在你會眉頭緊蹙了。在運行時設置意味着你在消耗CPU循環加載服務。 緩存可以最小化消耗,但是仍暗示着在首次使用服務時,遊戲需要暫停花點時間完成。 遊戲開發者討厭消耗CPU循環在不能提高遊戲體驗的地方。

如果服務不能被定位怎麼辦?

  • 讓使用者處理它:

    最簡單的解決方案就是把責任推回去。如果定位器不能找到服務,只需返回NULL。這暗示着:

    • 讓使用者決定如何掌控失敗。 使用者也許在收到找不到服務的關鍵錯誤時應該暫停遊戲。 其他時候可能可以安全地忽視並繼續。 如果定位器不能定義全面的策略應對所有的情況,那麼就將失敗傳回去,讓每個使用者決定什麼是正確的迴應。

    • 使用服務的用戶必須處理失敗。 當然,這個的必然結果是每個使用者都必須檢查服務的失敗。 如果它們都以相同方式來處理,在代碼庫中就有很多重複的代碼。 如果一百個中有一個忘了檢查,遊戲就會崩潰。

  • 掛起遊戲:

    我說過,我們不能保證服務在編譯時總是可用的,但是不意味着我們不能聲明可用性是遊戲定位器運行的一部分。 最簡單的方法就是使用斷言:

    class Locator
    {
    public:
      static Audio& getAudio()
      {
        Audio* service = NULL;
    
        // Code here to locate service...
    
        assert(service != NULL);
        return *service;
      }
    };
    

    如果服務沒有被找到,遊戲停在試圖使用它的後續代碼之前。 這裏的assert()調用沒有解決無法定位服務的問題,但是它確實明確了問題是什麼。 通過這裏的斷言,我們表明,“無法定位服務是定位器的漏洞。”

    如果你沒見過assert()函數,單例模式一章中有解釋。

    那麼這爲我們做了什麼呢?

    • 使用者不必處理缺失的服務。 簡單的服務可能在成百上千的地方被使用,這節約了很多代碼。 通過聲明定位器永遠能夠提供服務,我們節約了使用者處理它的精力。

    • 如果服務沒有找到,遊戲會掛起。 在極少的情況下,服務真的找不到,遊戲就會掛起。 強迫我們解決定位服務的漏洞是好事(比如一些本該調用的初始化代碼沒有被調用), 但被阻塞的所有人都得等到漏洞修復時。與大型開發團隊工作時,當這種事情發生,會增加痛苦的停工時間。

  • 返回空服務:

    我們在樣例中實現中展示了這種修復。使用它意味着:

    • 使用者不必處理缺失的服務。 就像前面的選項一樣,我們保證了總是會返回可用的服務,簡化了使用服務的代碼。

    • 如果服務不可用,遊戲仍將繼續。 這有利有弊。讓我們在沒有服務的情況下依然能運行遊戲是很有用的。 在大團隊中,當我們工作依賴的其他特性或者依賴的其他系統還沒有就位時,這也是很有用的。

      缺點在於,較難查找無意缺失服務的漏洞。 假設遊戲用服務去獲取數據,然後基於數據做出決策。 如果我們無法註冊真正的服務,代碼獲得了空服務,遊戲也許不會像期望的那樣行動。 需要在這個問題上花一些時間,才能發現我們以爲可用的服務是不存在的。

      我們可以讓空服務被調用時打印一些debug信息來緩和這點。

在這些選項中,我看到最常使用的是會找到服務的簡單斷言。 在遊戲發佈的時候,它經歷了嚴格的測試,會在可信賴的硬件上運行。 無法找到服務的機會非常小。

在更大的團隊中,我推薦使用空服務。 這不會花太多時間實現,可以減少開發中服務不可用的缺陷。 這也給你了一個簡單的方式去關閉服務,無論它是有漏洞還是干擾到了現在的工作。

服務的服務範圍有多大?

到目前爲止,我們假設定位器給任何需要服務的地方提供服務。 當然這是這個模式的典型的使用方式,另一選項是服務範圍限制到類和它的依賴類中,就像這樣:

class Base
{
  // 定位和設置服務的代碼……

protected:
  // 派生類可以使用服務
  static Audio& getAudio() { return *service_; }

private:
  static Audio* service_;
};

通過這樣,對服務的訪問被收縮到了繼承Base的類。這兩種各有千秋:

  • 如果全局可訪問:

    • 鼓勵整個代碼庫使用同樣的服務。 大多數服務都被設計成單一的。 通過允許整個代碼庫接觸到相同的服務,我們可以避免代碼因不能獲取“真正的”服務而到處實例化提供者。

    • 我們失去了何時何地使用服務的控制權。 這是讓某物全局化的明顯代價——任何東西都能接觸它。單例模式一章講了全局變量是多麼的糟糕。

  • 如果接觸被限制在某個類中:

    • 我們控制了耦合。 這是主要的優點。通過顯式限制服務到繼承樹的一個分支上,應該解耦的系統保持瞭解耦。

    • 可能導致重複的付出。 潛在的缺點是如果一對無關的類確實需要接觸服務,每個類都要擁有服務的引用。 無論是誰定位或者註冊服務,它也需要在這些類之間重複處理。

      另一個選項是改變類的繼承層次,給這些類一個公共的基類,但這引起的麻煩也許多於收益。)

我的通用準則是,如果服務侷限在遊戲的一個領域中,那麼限制它的服務範圍在一個類上面。 舉個例子,獲取網絡接口的服務可能限制於在線聯網類中。 像日誌這樣應用更加廣泛的服務應該是全局的。

參見

  • 服務定位模式在很多方面是單例模式的兄弟,在應用前值得看看哪個更適合你的需求。

  • Unity框架在它的GetComponent()方法中使用這個模式,協調它的組件模式

  • 微軟的XNA遊戲開發框架在它的核心Game類中內建了這種模式。 每個實體都有一個GameServices對象可以用來註冊和定位任何種類的服務。

← 上一章     § Contents   ≡ 首頁   下一章 →  
© 2009-2015 Robert Nystrom