Patrones de diseño – Service Locator

El objetivo del Service locator es proporcionar un punto global desde el que se pueda obtener un servicio sin acoplarse a la implementación concreta, esto quiere decir que utiliza la interfaz y por lo tanto lo podremos testear.

Si conocéis los patrones Singelton y Monostate, el Service Locator es una gran alternativa a estos dos y en cierta manera hay menos cosas que tener en cuenta para poder testear los consumidores ya que otra funcionalidad de este patrón es desacoplar.

Acceso global al Service Locator

Como hemos dicho al principio, uno de los objetivos del ServiceLocator es proporcionar un acceso global a los servicios que contiene, esto se puede implementar de varias formas:

  • Con una clase estática, al ser estática será accesible por todos y no necesitaremos instanciarla.
  • Utilizando el patrón Monostate, esta opción es una variante de la clase estática ya que el Monostate trabaja con variables estáticas para mantener un solo estado.
  • Con el patrón Singleton.
  • Nos podemos inyectar la instancia en el constructor, aunque esto deja de ser un acceso global es una opción completamente válida.

En nuestro caso nos vamos a decantar por el Singleton ya que es una forma bastante sencilla de implementarse, y gracias a que el Service Locator utilizará interfaces en sus servicios también nos será muy fácil de testear.

Además en Unity las variables estáticas no se limpian entre test y test, ni cuando detenemos el juego, con lo que si no tenemos especial cuidado podríamos tener algunos problemitas.

Implementar un Service Locator patter con Unity

Primero vamos a ver un diagrama de este patrón:

service locator c#

A la izquierda tenemos el ServiceLocator en sí, tendrá un método para obtener servicios y otro para registrar servicios. Estos servicios los guardará en alguna colección que podría ser un diccionario de clave el tipo del servicio y como valor la instancia.

También podríamos añadir otros métodos para eliminar un servicio registrado y las funciones auxiliares que pudiésemos necesitar.

A la derecha tenemos un servicio cualquiera que registraremos en el Service Locator utilizando su interfaz. Es importante utilizar la interfaz si queremos poder testear a los consumidores.

Gracias a que estamos utilizando funciones templatizadas GetService<T> y RegisterService<T> podremos registrar y obtener cualquier servicio sin forzar a estos que tengan que implementar alguna interfaz. Esto también hará que el Service Locator no tenga ningún import de los servicios y por lo tanto no esté acoplado a ellos.

Por debajo tenemos es el Installer que será el encargado de registrar todos los servicios y es el único que conoce la implementación concreta.

Arriba de todo tenemos el consumir que solo se preocupará por utilizar la función GetService con la interfaz del servicio que necesite.

Antes de seguir me gustaría hacer una pequeña aclaración: si buscáis por internet encontraréis otra implementación de este patrón donde la clase ServiceLocator tendrá métodos concretos para obtener los servicios, por ejemplo tendríamos un GetDataSaver en lugar de un GetService genérico.

El problema de esta implementación es que nos acopla con todos los servicios que ofrezca el ServiceLocator y nos obliga a crear un método Get por cada servicio. Personalmente me parece mucho más versátil la versión que vamos a hacer aquí, pero la otra es más sencilla de implementarse.

Ejemplo del patrón Service Locator

Antes de entrar con el Service Locator en sí vamos a ver el servicio que vamos a registrar, es el mismo que vimos en el artículo del Singleton vs. Monostate.

public interface IDataSaver
{
    void SetString(string key, string value);
    string GetString(string key, string defaultValue = default);
    void SetInt(string key, int value);
    int GetInt(string key, int defaultValue = default);
}
public class PlayerPrefsAdapter : IDataSaver
{
    public void SetString(string key, string value)
    {
        PlayerPrefs.SetString(key, value);
        PlayerPrefs.Save();
    }

    public string GetString(string key, string defaultValue = default)
    {
        return PlayerPrefs.GetString(key, defaultValue);
    }

    public void SetInt(string key, int value)
    {
        PlayerPrefs.SetInt(key, value);
        PlayerPrefs.Save();
    }

    public int GetInt(string key, int defaultValue = default)
    {
        return PlayerPrefs.GetInt(key, defaultValue);
    }
}

Simplemente un servicio que nos proporciona funcionalidades para escribir y leer datos, utilizamos un servicio como Adapter porque no nos queremos acoplar a PlayerPrefs ya que mañana lo podríamos cambiar a un archivo binario o datos en la nube.

Vamos con la clase de ServiceLocator que como ya dijimos utilizaremos un Singleton para tener el acceso global a este y a los servicios que tiene registrados:

public class ServiceLocator
{
    public static ServiceLocator Instance => _instance ?? (_instance = new ServiceLocator());
    private static ServiceLocator _instance;

    private readonly Dictionary<Type, object> _services;

    private ServiceLocator()
    {
        _services = new Dictionary<Type, object>();
    }

    public void RegisterService<T>(T service)
    {
        var type = typeof(T);
        Assert.IsFalse(_services.ContainsKey(type), 
                       $"Service {type} already registered");
        
        _services.Add(type, service);
    }

    public T GetService<T>()
    {
        var type = typeof(T);
        if (!_services.TryGetValue(type, out var service))
        {
            throw new Exception($"Service {type} not found");
        }

        return (T) service;
    }
}

Como veis estamos utilizando un diccionario de tipo Dictionary<Type, object> que nos permitirá almacenar cualquier servicio sin preocuparnos de su clase, lo único que tendremos que hacer es un casting cuando lo devolvamos.

No estamos templatizando la clase porque entonces lo que tendríamos es un ServiceLocator por cada servicio de este template, en su lugar lo que templatizamos son los métodos.

En el método RegisterService obtenemos el tipo de T y será este el que utilicemos como clave para registrar el servicio y más tarde obtenerlo. Como medida de seguridad estamos asertando que este tipo no estuviese ya registrado ya que no podemos registrar dos servicios para el mismo tipo.

En el GetService lo que hacemos es obtener el tipo de T, ya que es la clave del servicio, y con esta clave obtenemos el servicio. Si el servicio no existe mostramos una excepción y si todo a ido bien lo devolvemos castiandolo al tipo T indicado.

Este casting que estamos haciendo no puede fallar ya que T es el mismo tipo que estamos utilizando para registrar el servicio.

Vamos a ver el Installer:

public class Installer : MonoBehaviour
{
    private void Awake()
    {
        var playerPrefsAdapter = new PlayerPrefsAdapter();
        ServiceLocator.Instance.RegisterService<IDataSaver>(playerPrefsAdapter);
    }
}

Fijaros en que estamos registrando el tipo de la interfaz en lugar de el tipo de la clase concreta, esto es súper importante si queremos poder testear a los consumidores. Gracias a esto si alguien pregunta por PlayerPrefsAdapter en lugar de por la interfaz lo que obtendrá es un error diciendo que ese servicio no está registrado.

Para consumirlo no tiene ningún misterio, solo tendremos que llamar a GetService con la interfaz del servicio:

ServiceLocator.Instance.GetService<IDataSaver>();

Como testear los consumidores de un ServiceLocator

Para testear los consumidores de este patrón lo único que tendremos que hacer es el set up del test registrar los servicios que nuestro consumidor necesite utilizando sustitutos, por ejemplo con NSubstitute lo haríamos así:

var dataSaverMock = Substitute.For<IDataSaver>();
ServiceLocator.Instance.RegisterService<IDataSaver>(dataSaverMock);

Con esto cuando el consumidor llame a GetService<IDataSaver>() estará obteniendo el sustituto que hemos creado en lugar de el servicio concreto. Lo cual nos dejará sustituir los métodos que necesitemos para que devuelvan y hagan lo que requiera nuestro test.

Conclusiones

El ServiceLocator es muy similar a utilizar un gestor de dependencias como Zenject, el proceso sería el mismo, en un installer tendríamos que registrar los servicios y luego consumirlos con la interfaz. Al final un gestor de dependencias es un ServiceLocator supervitaminado.

Pero aún me queda una duda, ¿es mejor utilizar un Service Locator en lugar de hacer mis servicios Singleton o Monostate?

Lo de mejor y peor en la programación es una fina línea, al final depende de tus recursos y tu objetivo, pero yo diría que utilizar un Service Locator es mejor porque lo podemos testear de forma muy fácil y nos evitaremos hacer Singleton a todos los servicios y acoplarnos a sus clases concretas.

Es testeable porque utilizamos la interfaz de los servicios, está claro que igual que estoy utilizando una interfaz podría utilizar una clase concreta, en ese caso no vas a poder testear y te estarás pasando por el forro el principio de Inversión de Dependencias (DIP).

Además esto nos permite que en el Installer podamos decidir qué implementación utilizar para una interfaz, incluso podríamos registrar un MonoBehaviour que cumpliese con la interfaz.

Si quieres trastear con el proyecto de Unity que hemos utilizado en este artículo lo podrás descargar desde este enlace.

Para darle un empujón a tu carrera como desarrollador te animo a que eches un vistazo al curso de SOLID y Clean Code que tenemos preparado aquí.

Otras entradas

Resumen
➤ Patrones de diseño - Service Locator
Nombre del artículo
➤ Patrones de diseño - Service Locator
Descripción
➤ El SERVICE LOCATOR proporciona un punto global desde el que se puede obtener un servicio sin acoplarse 🙅‍♀️ a la implementación concreta.
Autor
Publisher Name
The Power Ups - Learning
Publisher Logo