[C#/Unity] 這不叫 Singleton,叫 Service Locator

過去在[C#/Unity] 更多 Singleton – More Singleton in Unity這篇文章中,實作了一個名為 Singleton manager 的物件來做 Singleton 的註冊與使用。

但最近在研讀 相依性注入 (Dependency Injection) 相關文章時,才發現有個叫做 Service Locator 的設計模式,基本上就是我過去所實作的 Singleton manager。再加上一些關於抽象化的觀念又比當初更成熟了,因此便想趁機改善當初的設計,同時正名為 Service Locator。

Service Locator 簡介

典型 Service Locator 模式的主體是一個 static class,內含有一個私有 Dictionary 儲存 Type 與物件本體 的關聯,以及兩個公開方法:註冊 (Register) 與 取得 (GetService)。被存在其中的物件被稱為 Service。

  • 註冊 (Register) 是個將物件存入 Dictionary 的方法,會檢查避免有相同 Type 的物件被存入,是使用 取得 前的必要動作。
  • 取得 (GetService) 是以 Type 為區別,直接取得存於 Service Locator 的 Service 的方法。
    因為是 static 方法,所以透過 Service Locator 可以在任何地方存取已經註冊的 Service;加上使用 Dictionary 來儲存,所以每個 Type 只能有一個物件。綜合起來說,Service 具有唯一、容易存取的特性,這跟 Singleton 相似。

不同於 Singleton 的地方,Service 本身可以適用 interface 作為 key 值的情況,只有在 註冊 時被 注入 實體,所以 Service Locator 也被歸類為 相依性注入 的一種實作模式。

雖然可以解除 被呼叫對象 跟 呼叫者 之間的實作關聯,改為抽象關聯,降低耦合。但是卻會使 呼叫者 跟 Service Locator 產生耦合,不算是完全的解除耦合,是這個模式的主要缺點。

更詳細的優缺點可以參考維基百科:Service locator pattern

Service Locator 實作

為避免不必要的篇幅,這邊只有程式碼的節錄配合說明,完整的程式碼再最後另外附上。

1
2
3
4
5
6
7
public sealed class ServiceLocator : SingletonMono<ServiceLocator> {
private Dictionary<Type, IService> m_serviceDictionary = new Dictionary<Type, IService> ();
private List<IUpdatable> m_updatableList = new List<IUpdatable> ();
private List<IFixedUpdatable> m_FixedUpdatableList = new List<IFixedUpdatable> ();
private List<ILateUpdatable> m_lateUpdatableList = new List<ILateUpdatable> ();
// ...
}

這個實作希望可以跟 Unity 的 MonoBehaviour 有互動,所以這邊我不是用單純的 static class,而是 Singleton MonoBehaviour 來作為基底,SingletonMono 的程式碼可以在參考連結找到。

因應變化,儲存的 Dictionary 不再需要作為 static 成員。同時增加數個跟 MonoBehaviour Update 相關的 interface List。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void Register<T> (T service, InitialzeArgs initialzeArgs = null) where T : IService {
Type serviceType_ = typeof(T);
if (Instance.m_serviceDictionary.ContainsKey (typeof (T))) {
throw new InvalidOperationException (string.Format("[ServiceLocator] There was a service <{0}>", serviceType_.Name));
}
// ...
IUpdatable updatable_ = service as IUpdatable;
if (updatable_ != null) {
Instance.m_updatableList.Add (updatable_);
}
// ...
}

public static void Register<T> (InitialzeArgs initialzeArgs = null) where T : IService, new () {
// ...
ServiceLocator.Register<T> (new T(), initialzeArgs);
}

public static void RegisterMono<T> (InitialzeArgs initialzeArgs = null) where T : MonoBehaviour, IService {
// ...
T service_ = Instance.gameObject.AddComponent<T>();
ServiceLocator.Register<T> (service_, initialzeArgs);
}

註冊相關的方法保持 static。內容會在加入 Dictionary 前檢查是否有重複的 Type,同時也會檢查是否有 IUpdatable 等介面,若有則要另外儲存一份副本到 List 之中作為備用。

除了典型的注入實體進行註冊,另外實作了會自動用 new () 來進行實體化的版本,以及用於 MonoBehaviour 的版本。

先看到 MonoBehaviour 的版本 RegisterMono。不同於要將注入實體作為參數傳入,這個方法是在作為 Singleton 的 GameObject 上 AddComponent 來進行實體化,如此才能符合 Unity 的運作機制。

相對用 new () 來主動進行實體化,必須確認使用的類別不是 MonoBehaviour 的子類別,來避免錯誤發生。這個主動實體化的方法一般 Service Locator 中並不存在,只是相對於 RegisterMono 另外增加的實作。

1
2
3
4
5
6
7
public static T GetService<T> () where T : class, IService {
if (Instance.m_serviceDictionary.ContainsKey (typeof (T))) {
return Instance.m_serviceDictionary[typeof (T)] as T;
} else {
return null;
}
}

然後是普通的 GetService 方法。除了要透過 Singleton 的 Instance 來存取,跟普通的實作並沒有太大的差異。

1
2
3
4
5
6
void Update () {
for (int i = m_updatableList.Count - 1; i >= 0; i--) {
m_updatableList[i].OnUpdate ();
}
}
// ...

最後是將 IUpdatable 這個介面串聯到 MonoBehaviour event 上。這樣即使沒有繼承 MonoBehaviur,普通的 Service 物件也能共同進行 Update 週期,也就是為何一開始要另外準備 List 儲存副本的原因所在。

後話與完整程式碼連結

1
2
ServiceLocator.Register <ITestService> (new TestServiceA());
ServiceLocator.GetService <ITestService> ().num = 5;

如果要使用 Service Locator,大概就會是這個樣子。在 Unity 比較需要注意的是如何確保註冊跟取得之間的順序不會有錯,跨場景或腳本時要特別注意。

這次不同於之前將 Singleton 分成有無 MonoBehaviour 的兩個管理器,透過將所有欄位抽象介面化,實現了共用管理器的結構。

雖然相對犧牲了部分功能,不能直接註冊已經先配置好的場景物件,但是目前我會比較喜歡這個做法。之後有計畫再做更多相依性注入的探討,現在的做法更單純、容易銜接其他模式。

最後附上完整程式碼給大家參考:
https://gist.github.com/douduck08/9fec275e9539d9be970ef8989ac62df6

參考連結