4 min read

[Tutorial] Dependency Injection via Service Locator (Deutsch)

[Tutorial] Dependency Injection via Service Locator (Deutsch)

Eine der wichtigsten Aspekte beim Entwickeln eines Spiels (oder jeder anderen Applikation) ist die Kommunikation zwischen verschiedenen Komponenten.

Wie kann beispielsweise ein Textfeld auf den Spielernamen zugreifen?

Beliebte Ansätze sind das Singleton-Pattern oder Dependency Injection Frameworks wie zum Beispiel Zenject. Während Singletons nicht die Flexibilität für ein größeres Spiel bieten, sind Frameworks teilweise übermäßig komplex.

Eine einfach und flexible Lösung, die sich in mehreren Spielen seit Jahren bewährt hat, ist das Service-Locator-Pattern. Einfach erweiterbar, dynamisch und testbar sind die Vorteile.

Mit Hilfe eines Locator-Objekts kann eine beliebige Instanz für ihren Typ oder einer ihrer Basistypen registriert werden. Andere Komponenten können dann über den Registrierungstyp auf die Instanz zugreifen. Durch das Verstecken von konkreten Implementierungen hinter Interfaces können direkte Abhängigkeiten vermieden werden und ein Austausch oder das Testen einzelner Komponenten ist einfach möglich.

Der Locator selbst kann dabei widerum als Singleton implementiert werden. Vor allem in Unity wo wir die Erstellung von MonoBehaviour nicht selbst kontrollieren, ist das hilfreich.

Der Service

Als Beispiel nehmen wir die schon erwähnte Bereistellung des Spielernamens. Dabei hilft uns der IUserNameService mit zwei rudimenäten Implementierungen.

using UnityEngine;

namespace ProductionReady
{
    public interface IUserNameService
    {
        string GetUserName();
    }

    public class StaticUserNameService : IUserNameService
    {
        private readonly string _userName;

        public StaticUserNameService(string userName)
        {
            _userName = userName;
        }

        public string GetUserName()
        {
            return _userName;
        }
    }

    public class RandomUserNameService : IUserNameService
    {
        public string GetUserName()
        {
            return "Player " + Random.Range(0, 100);
        }
    }
}

Der Service-Locator

Unser Locator soll nun folgende 3 Funktionalitäten bereitstellen:

  • Registrieren einer Instanz für einen bestimmten Typ (Bind)
  • Entfernen einer Instanz (Unbind)
  • Abrufen einer Instanz (Get)

Interface

using System;
using System.Collections.Generic;

namespace ProductionReady.ServiceLocator
{
    public interface ILocator
    {
        /// <summary>
        /// Returns the bound instance of the bindingType if available.
        /// </summary>
        /// <returns>Instance if available or null</returns>
        TBindingType Get<TBindingType>();
        
        /// <summary>
        /// Registers an instance for a certain type. Afterwards the instance can be retrieved via the bindingType.
        /// </summary>
        /// <param name="instance">Instance of the binding which must derive from bindingType.</param>
        void Bind<TBindingType>(TBindingType instance);
        
        /// <summary>
        /// Removes the binding if available.
        /// If the instance implements IDisposable, Dispose is called. 
        /// </summary>
        void Unbind<TBindingType>();
    }
}
Locator Interface

Implementierung

using System;
using System.Collections.Generic;

namespace ProductionReady
{
	public class Locator : ILocator
    {
        private readonly Dictionary<Type, object> _bindings;

        public Locator()
        {
            _bindings = new Dictionary<Type, object>();
        }

        public TBindingType Get<TBindingType>()
        {
            return (TBindingType) _bindings.GetValueOrDefault(typeof(TBindingType));
        }

        public void Bind<TBindingType>(TBindingType instance)
        {
            if (instance == null)
            {
                throw new ArgumentNullException(nameof(instance));
            }

            if (_bindings.TryGetValue(typeof(TBindingType), out object existingInstance))
            {
                throw new Exception($"Type {typeof(TBindingType)} already bound to instance {existingInstance}! Unbind before binding a new instance!");
            }
            
            _bindings.Add(typeof(TBindingType), instance);
        }

        public void Unbind<TBindingType>()
        {
            if (_bindings.TryGetValue(typeof(TBindingType), out object instance))
            {
                DisposeInstance(instance);
                _bindings.Remove(typeof(TBindingType));
            }
        }

        private void DisposeInstance(object instance)
        {
            if (instance is IDisposable disposable)
            {
                disposable.Dispose();
            }
        }
    }
}
Locator Implementierung

Unsere Implementierung verwendet ein Dictionary um die Instanzen zu speichern und einen schnellen Zugriff zu gewährleisten.

Beim Entfernen (Unbind) von Instanzen, wird zusätzlich geprüft ob es sich um ein IDisposable handelt. In diesem Fall wird vor dem Entfernen Dispose aufgerufen um der Instanz die Möglichkeit des Aufräumens zu geben.

Globaler Zugriff

Um einen globalen Zugriff auf unseren Locator zu ermöglichen, verwenden wir eine statische Klasse:

using System;
using System.Collections.Generic;

namespace ProductionReady
{
    public static class Global
    {
         public static readonly ILocator Locator = new Locator();
    }
}
Globaler Locator

Verwendung

Wir können nun von überall via Global.Locator auf unseren Locator zugreifen, beispielsweise in einem MonoBehaviour:

using UnityEngine;

namespace ProductionReady
{
    public class LocatorUser : MonoBehaviour
    {
        private void Start()
        {
            // Either FixedUserNameService or RandomUserNameService
            IUserNameService userNameService = 
                   new FixedUserNameService("Production Ready");
            // Bind
            Global.Locator.Bind<IUserNameService>(userNameService);

            // Get
            IUserNameService instance = Global.Locator.Get<IUserNameService>();
            
            // Unbind
            Global.Locator.Unbind<IUserNameService>();
        }
    }
}

Zusammenfassung

Wir haben nun einen funktionstüchtigen Locator, auf den wir von überall zugreifen können. Instanzen können via Interfaces registriert und einfach ausgetauscht werden.

Instance Locator (Service Locator Pattern)
Instance Locator (Service Locator Pattern). GitHub Gist: instantly share code, notes, and snippets.
GitHub - Asorano/Unity-Locator: Service Locator for Dependency Injection in C# and Unity
Service Locator for Dependency Injection in C# and Unity - GitHub - Asorano/Unity-Locator: Service Locator for Dependency Injection in C# and Unity

Mögliche Verbesserungen

Anstatt die Sonderbehandlung von IDisposable direkt in der Implementierung zu handlen, könnte ein IInstanceDisposer via Constructor übergeben werden. Dieser kann dann entscheiden wie mit zu entsorgenden Instanzen verfahren werden soll.

Anstatt das die Global-Klasse den Locator erstellt, kann dieser auch mit einer Initialize-Funktion der Klasse übergeben werden.