Wstrzykiwanie Zależności

Wprowadzenie

Zacznijmy od prostego pytania. Czym jest wstrzykiwanie zależności (ang. dependency injection) i jak można zaimplementować ten koncept 🙂 ? Na początek warto wyjaśnić czym jest sama zależność. Kiedy projekt się rozrasta, mamy coraz więcej i więcej obiektów. Obiekty te muszą się ze sobą komunikować i wchodzić w interakcję. Innymi słowy jeśli obiekt A zależy od obiektu B to A musi wchodzić z B w jakąś interakcję. To jest właśnie zależność.

Wyobraźmy sobie, że mamy prostą scenę z trzema elementami: postacią, kamerą i interfejsem użytkownika.

Rys. 1. Prosta scena z postacią oraz interfejsem użytkownika pozwalającym zmienić pewne parametry.

Interfejs użytkownika pozwala obracać i przesuwać postacią oraz zmieniać parametry kamery. W tym przykładzie mamy kilka potencjalnych zależności:

  • interfejs użytkownika jest zależny od postaci – musi wyświetlić jej obecne ustawienia i zaktualizować model jeśli użytkownik wprowadzi nowe parametry,
  • interfejs użytkownika jest zależny od kamery – musi wyświetlać jej obecne ustawienia i zaktualizować widok jeśli użytkownik wprowadzi zmiany,
  • kamera jest zależna od postaci – musi być w stanie śledzić ruch postaci.

Jak możemy skonfigurować te zależności, żeby nasz projekt działał? Unity pozwala ustawić referencje do game object’ów oraz zasobów w inspektorze, jeśli klasa ma serializowane pola (ang. serialized field). W naszym prostym przykładzie byłoby to wystarczające ale czy tego typu rozwiązanie skaluje się na tyle dobrze, żeby stosować je w większym projekcie? Niestety to podejście ma pewne ograniczenia i byłoby kłopotliwe. Na przykład nie możemy ustawić referencji do obiektów, które się nie serializują. Innym problemem jest obsługa obiektów tworzonych dynamicznie podczas działania aplikacji. Tego typu game object’y mogą być znalezione metodami Find/FindObjectOfType ale są one kosztowne jeśli chodzi o wydajność.

Wstrzykiwanie Zależności

Nasz problem może być rozwiązany przez wstrzykiwanie zależności. To wzorzec, który opisuje jak dostarczyć zależność do obiektu, który jej potrzebuje. Jest wiele sposobów na zaimplementowanie tego rozwiązania. Najprostszy sposób to przekazywanie zależności przez konstruktor lub metodę inicjalizującą.

Niestety, przy takim rozwiązaniu będziemy musieli modyfikować te metody za każdym razem, gdy pojawi się nowa zależność. Dodatkowo musimy jakoś zebrać wszystkie potrzebne referencje, żeby móc je później przekazać do nowo utworzonego obiektu.

Dobrze byłoby mieć bardziej zautomatyzowany sposób zarządzania zależnościami. W tym celu możemy stworzyć prosty kontener wstrzykiwania zależności. Będzie służył jako swego rodzaju worek, gdzie można wrzucić wszystkie nasze obiekty, a potem pobierać referencje kiedy tylko będą potrzebne.

public class DIContainer
{
    protected Dictionary<Type, object> dictionary = new Dictionary<Type, object>();

    public void Register(object objToRegister, Type objType = null)
    {
        objType = objType == null ? objToRegister.GetType() : objType;
        if (!dictionary.ContainsKey(objType))
        {
            dictionary.Add(objType, objToRegister);
        }
        else
        {
            Debug.LogError(string.Format("[DIContainer] dependencies conflict, object with specific type ({0}) is already registered", objType.ToString()));
        }
    }

    public void Unregister(Type objType)
    {
        if (dictionary.ContainsKey(objType))
        {
            dictionary.Remove(objType);
        }
    }

    public T1 GetReference<T1>()
    {
        var foundObj = FindDependency(typeof(T1));
        return (T1)foundObj;
    }

    private object FindDependency(Type type)
    {
        if (dictionary.ContainsKey(type)) return dictionary[type];
        else return null;
    }
}

Ta prosta implementacja ma trzy metody:

  • Register – pozwala dodać nowy obiekt do kontenera,
  • Unregister – pozwala usunąć obiekt z kontenera,
  • GetReference<T1> – zwraca referencję do obiektu o określonym typie, jeśli taki był zarejestrowany w kontenerze.

Teraz wystarczy dostarczyć DIContainer do naszych obiektów i będą mogły pobierać referencje za jego pośrednictwem. Zmienimy klasę kontenera na MonoBehaviour i stwórzmy z niej Singleton, żeby trochę uprościć. W takiej wersji nie będzie trzeba dostarczać obiektom referencji do kontenera. Mogą same ją pobierać przez publiczny statyczny dostęp.

public class DIContainer : MonoBehaviour
{
    private static DIContainer cachedInstance = null;
    public static DIContainer Instance
    {
        get
        {
            if (cachedInstance == null)
            {
                var newObject = new GameObject("DIContainer");
                cachedInstance = newObject.AddComponent<DIContainer>();
            }
            return cachedInstance;
        }
        private set
        {
            cachedInstance = value;
        }
    }

    #region MONO BEHAVIOUR

    private void Awake()
    {
        if (cachedInstance != null)
        {
            if (cachedInstance != this) Destroy(gameObject);
        }
        else
        {
            cachedInstance = this;
            DontDestroyOnLoad(gameObject);
        }
    }

    #endregion

    ...
}

Czy można jeszcze jakoś poprawić to rozwiązanie? Nawet w przypadku łatwego dostępu do kontenera wciąż trzeba wywoływać GetReference dla każdej zależności. Używając refleksji możemy skrócić kod do wypełniania obiektów referencjami. Wystarczy stworzyć prosty atrybut do zaznaczania, które pola powinny być wypełnione przez DIContainer.

public class DIInject : System.Attribute { }

public class DIContainer : MonoBehaviour
{
    ...

    public void Fetch(object objToFill, bool forceFetch = false)
    {
        FieldInfo[] fields = objToFill.GetType().GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
        foreach (FieldInfo field in fields)
        {
            if (field.GetCustomAttribute<DIInject>(true) != null)
            {
                var currentValue = field.GetValue(objToFill);
                if (currentValue == null || forceFetch)
                {
                    var fieldValue = FindDependency(field.FieldType);
                    field.SetValue(objToFill, fieldValue);
                }
            }
        }
    }

    ...
}

Jak teraz będą wyglądać klasy korzystające z DIConatinera? Poniżej mamy prosty przykład. Klasy CharacterController i CameraController rejestrują sie w kontenerze na Awake. Wtedy CameraConfigurationView pobiera referencje za pomoca metody Fetch wywoływanej w Start. Istotne jest, żeby obiekty były zarejestrowane zanim będziemy próbowali pobrać referencje.

public class CharacterController : MonoBehaviour
{
    private void Awake()
    {
        DIContainer.Instance.Register(this);
    }
}

public class CameraController : MonoBehaviour
{
    private void Awake()
    {
        DIContainer.Instance.Register(this);
    }
}

public class CameraConfigurationView : MonoBehaviour
{
    [DIInject]
    private CameraController cameraController;
    [DIInject]
    private CharacterController characterController;

    private void Awake()
    {
        DIContainer.Instance.Register(this);
    }

    private void Start()
    {
        DIContainer.Instance.Fetch(this);
    }
}

Podsumowanie

Zaprezentowany DIContainer to bardzo prosta implementacja, żeby tylko pokazać koncept wstrzykiwania zależności i posiada pewne ograniczenia. Na przykład możemy zarejestrować tylko jeden obiekt określonego typu. Jeśli chcielibyśmy mieć więcej obiektów tego samego typu, najłatwiej jest stworzyć dodatkową klasę, która będzie trzymać kolekcję np. listę. Wtedy zamiast rejestrować pojedynczy obiekt w DIContainerze, rejestrujemy obiekt naszej nowej klasy (trzymającej kolekcję) i dodajemy obiekty do kolekcji.

Bardziej rozbudowana implementacja DIConatinera jest dostępna na moim koncie bitbucket i można ją dodać do projektu przez Package Manager: https://bitbucket.org/pdgames_net/dicontainer/src. Zawiera abstrakcyjną klasę do rozszerzenia, co pozwala na posiadanie wielu kontenerów w projekcie. Dodatkowo daje możliwość rejestrowania obiektów w określonym kontekście. Konteksty mogą być użyte, żeby rozwiązać problem rejestrowania wielu obiektów tego samego typu w kontenerze. Na przykład mając dwie postaci korzystające z tych samych klas możemy zarejestrować je w dwóch osobnych kontekstach.

Istnieją również bardziej zaawansowane rozwiązania dla Unity jak np. Zenject: https://github.com/modesttree/Zenject. Jest to framework, który pozwala tworzyć zasady wstrzykiwania, żeby zdefiniować jak zależności powinny być rozpowszechniane w projekcie. To bardzo popularne narzędzie i wiele dużych firm tworzących gry z niego korzysta. Z tego powodu warto je znać 🙂

Materiały

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *