Design pattern: Service Locator

Objectif:

Proposer une implémentation simple de l’inversion de contrôle

Justification

Lorsqu’un objet doit utiliser une compétence implémentée dans un autre objet, la première approche est d’instancier cet autre objet et de l’utiliser au moyen de ces membres publiques.

Par exemple, si on prends la classe suivante:

public class ConsumingObject
{
    private ConsumedObject consumedObject;

    public ConsumingClass()
    {
        this.consumedObject = new ConsumedObject();
    }
}

Cette instanciation aura plusieurs conséquences:
– Une dépendance de l’objet consommateur ConsumingObject vers l’objet consommé ConsumedObject,
– Eventuellement d’autres dépendances peuvent être nécessaires si l’instanciation de l’objet consommé nécessite d’autres objets,
– Implicitement, l’objet consommateur doit gérer la durée de vie de l’objet consommé.

Une approche différente serait de vouloir limiter la dépendance et ainsi réduire le couplage entre l’objet consommateur et l’objet consommé de façon à:
– permettre une meilleure maintenabilité,
– avoir une implémentation plus flexible en permettant d’adapter plus facilement de nouvelles implémentations,
– être plus extensible en permettant d’étendre plus facilement les fonctionnalités d’une classe.

Par exemple:
Si on prends l’exemple d’une application d’achat d’articles implémentée suivant le pattern "Model-Vue-Controleur" (MVC):
– La "Vue" ShowArticlesView permet d’afficher des articles et de voir le stock correspondant,
– Le "Modèle" Article détaille les caractéristiques d’un article,
– Le "Controleur" ArticleController permet d’intérroger la base de données par l’intermédiaire des "Repositories" ArticleRepository et StockRepository pour récupérer respectivement les détails des articles et le stock correspondant.

Voici un exemple de l’implémentation:

public class ShowArticlesView
{
    private ArticleController articleController;

	public ShowArticlesView()
    {
        this.articleController = new ArticleController();
    }

    public IEnumerable<Article> GetArticles()
    { ... }
}

public class ArticleController
{
    private ArticleRepository articleRepository;
    private StockRepository stockRepository;

    public ArticleController()
    {
        this.articleRepository = new ArticleRepository();
        this.stockRepository = new StockRepository();
    }

    public IEnumerable<Article> GetArticlesWithStock()
    { ... }
}

public class ArticleRepository : Repository<ArticleDetail>
{ }

public class StockRepository : Repository<ArticleStock>
{ }

public class Repository<T>
{
    public virtual T Create() { ... }
    public virtual T GetItem(string id) { ... }
    public virtual T UpdateItem(T updatedItem) { ... }
    public virtual bool DeleteItem(string id) { ... }
    public virtual IEnumerable<T> GetItems() { ... }
}

Dans cet exemple, ShowArticlesView est très dépendante de ArticleController qui lui-même est dépendant de StockRepository et ArticleRepository. Si on modifie la signature des constructeurs de ArticleController ou des classes "repository", par exemple en rajoutant un "logger" commun, il faudra modifier l’instanciation dans la ou les classes consommatrices.
Toutes ces dépendances rendent le couplage trop fort. Ce couplage ira en augmenter à mesure que l’application va devenir fonctionnellement plus riche.

Inversion de contrôle

Une possibilité pour réduire le couplage entre les objets est le pattern d’inversion de contrôle ou "Inversion of Control" (i.e. IoC). Ce pattern considère que l’architecture abstraite d’une application caractérise des comportements généraux qui vont former un framework. Ce framework doit rester abstrait et son comportement doit rester général.

Ainsi le sens classique de consommation des dépendances se fait de la classe consommatrice vers la classe consommée. Ainsi la classe consommatrice instancie et "contrôle" la vie des objets qu’elle consomme. "Inversion de contrôle" préconise de casser cette dépendance en laissant le framework instancier et contrôler les objets consommés pour l’objet consommateur. La dépendance sera alors réduite puisque l’objet consommateur ne gère plus l’existence de l’objet consommé.

D’autre part, sachant que c’est le framework dont le comportement est général qui instancie et contrôle les objets consommés pour l’objet consommateur, le flux de contrôle se fait du framework abstrait vers l’objet consommateur qui est spécialisé d’où l’inversion de contrôle.

Service locator

Une des implémentations de "l’inversion de contrôle" est le pattern "Service Locator". Le principe de "Service Locator" est de regrouper au sein d’un unique objet tous les services dont l’application peut avoir besoin. Cet objet unique s’appelle le "Service locator".

Les objets consommateurs vont ainsi appeler le "service locator" pour obtenir les objets qu’ils souhaitent consommer.

Plus précisemment, "Service locator" est une classe statique qui permet de récupérer directement les objets consommés sans se soucier de leur instanciation et de leur durée de vie. Une implémentation simple de cette classe est la suivante:

public class ServiceLocator 
{
    private readonly IDictionary<Type, Func<object>> registeredServices = 
        new Dictionary<Type, Func<object>>();
 
    public ServiceLocator()
    {
        this.registeredServices = new Dictionary<Type, Func<object>>();
    }
 
    public void RegisterService<T>(Func<T> instanciateService)
    {
        this.registeredServices.Add(typeof(T)) = () => instanciateService();
    }

    public T GetRegisteredService<T>()
    {
        return (T)this.registeredServices[typeof(T)];
    }
}

Dans notre exemple, on peut enregistrer les services en faisant:

ServiceLocator serviceLocator = new ServiceLocator();
serviceLocator.RegisterService<ArticleRepository>(() => new ArticleRepository());
serviceLocator.RegisterService<StockRepository>(() => new StockRepository());
serviceLocator.RegisterService<ArticleController>(() => new ArticleController());

On peut consommer les services en faisant:

StockRepository stockRepository = serviceLocator.GetRegisteredService<StockRepository>();

Utilisation d’un "service locator" statique ou sous forme de singleton

On peut transformer ServiceLocator en classe statique ou en "singleton" pour faciliter les appels mais rendra plus difficile les tests.

Par exemple, en tant que singleton, l’implémentation de ServiceLocator sera:

public class ServiceLocator 
{
    private static readonly Lazy<ServiceLocator> instance;
    private readonly IDictionary<Type, Func<object>> registeredServices = 
        new Dictionary<Type, Func<object>>();
 
    private ServiceLocator()
    {
        this.registeredServices = new Dictionary<Type, Func<object>>();
    }
 
    public static ServiceLocator Instance
    {
        get
        {
            return this.instance.Value;
        }
    }

    public void RegisterService<T>(Func<T> instanciateService)
    {
        this.registeredServices.Add(typeof(T)] = () => instanciateService();
    }

    public T GetRegisteredService<T>()
    {
        return (T)this.registeredServices[typeof(T)];
    }
}

L’ajout de service devient plus direct:

ServiceLocator.Instance.RegisterService<ArticleRepository>(() => new ArticleRepository());

De même, pour récupérer un service enregistré:

ServiceLocator.Instance.GetRegisteredService<ArticleRepository>();

En reprenant l’exemple précédent et en utilisant la version "singleton", les objets consommateurs n’assurent plus l’instanciation des objets consommés:

public class ShowArticlesView
{
    private ArticleController articleController;

    public ShowArticlesView()
    {
        this.articleController = ServiceLocator.Instance.GetRegisteredService<ArticleController>();
    }

    public IEnumerable<Article> GetArticles()
    { ... }
}

public class ArticleController
{
    private ArticleRepository articleRepository;
    private StockRepository stockRepository;

    public ArticleController()
    {
        this.articleRepository = ServiceLocator.Instance.GetRegisteredService<ArticleRepository>();
        this.stockRepository = ServiceLocator.Instance.GetRegisteredService<StockRepository>();
    }

    public IEnumerable<Article> GetArticlesWithStock()
    { ... }
}

...

Comme on peut le voir dans l’exemple:
– Les objets consommateurs n’assurent plus l’instanciation des objets consommés,
– L’objet ServiceLocator qui fait office de framework, permet de contrôler les objets consommés par l’objet consommateur,
ServiceLocator assure la durée de vie des objets consommés.

Utilisation d’interfaces

On peut encore découpler davantages les objets en utilisant non pas leur type directement mais des interfaces. On peut adapter ServiceLocator pour qu’il référence le service par interface et non par le type des services.
L’intérêt d’utiliser des interfaces est de ne pas avoir de dépendances entre les objets à la compilation. Il est donc plus facile à l’exécution de choisir quels sont les objets qui vont être utilisés pour une interface donnée.

En utilisant des interfaces, l’implémentation de ServiceLocator change pour l’enregistrement des services:

public class ServiceLocator 
{
    private static readonly Lazy<ServiceLocator> instance;
    private readonly IDictionary<Type, Func<object>> registeredServices = 
        new Dictionary<Type, Func<object>>();
 
    private ServiceLocator()
    {
        this.registeredServices = new Dictionary<Type, Func<object>>();
    }
 
    public static ServiceLocator Instance
    {
        get
        {
            return this.instance.Value;
        }
    }

    public void RegisterService<TInterface, TObject>(Func<TObject> instanciateService)
       where TObject : class
    {
        this.registeredServices.Add(typeof(TInterface)] = () => instanciateService();
    }

    public T GetRegisteredService<T>()
    {
        return (T)this.registeredServices[typeof(T)];
    }
}

L’implémentation des classes change puisqu’elles doivent satisfaire des interfaces.
Par exemple pour ArticleController:

public interface IArticleController
{
    IEnumerable<Article> GetArticlesWithStock();
}

public class ArticleController : IArticleController
{
    ...    
}

On peut enregistrer les services en faisant:

ServiceLocator.Instance.RegisterService<IArticleController, ArticleController>(() => new ArticleRepository());

Pour récupérer un service enregistré:

ServiceLocator.Instance.GetRegisteredService<IArticleController>();
Ne pas utiliser cette implémentation de "Service Locator"

Cette inplémentation de "Service Locator" ne devrait pas être utilisée car:
– L’implémentation n’est pas thread-safe,
– A mesure que la complexité de l’application augmentera, ServiceLocator se transformera en une classe "fourre-tout" où tous les objets consommés seront instanciés.
– L’exemple utilisé est très simple mais dans la "vraie-vie", les liens entre les objets sont plus complexes et les dépendances sont plus nombreuses. Ce type d’implémentation s’avérera très peu robuste pour gérer l’ordre d’instanciation des objets consommés.
– Le pattern "Service Locator" a déjà été implémenté dans plusieurs frameworks d’injection de dépendances comme Unity. Il sera plus efficace d’utiliser ces frameworks plutôt que de réimplémenter ce pattern.

Implémentation de "Service Locator" avec Unity

Unity est un framework d’injection de dépendances. Une implémentation existe pour "Service Locator" même si le framework n’en propose pas une à la base.

En prenant l’exemple précédent, on peut utiliser Unity de la façon suivante:

UnityServiceLocator locator = new UnityServiceLocator(ConfigureUnityContainer());
ServiceLocator.SetLocatorProvider(() => locator);
var articleController = ServiceLocator.Current.GetInstance<IArticleController>();
var articleRepository = ServiceLocator.Current.GetInstance<IArticleRepository>();
var stockRepository = ServiceLocator.Current.GetInstance<IStockRepository>();

Avec:

private static IUnityContainer ConfigureUnityContainer()
{
    UnityContainer container = new UnityContainer();
    container.RegisterType<IArticleRepository, ArticleRepository>(
        new ContainerControlledLifetimeManager());
    container.RegisterType<IStockRepository, StockRepository>(
        new ContainerControlledLifetimeManager());
    container.RegisterType<IArticleController, ArticleController>(
        new ContainerControlledLifetimeManager());
    return container;
}

new ContainerControlledLifetimeManager() permet d’indiquer que la durée de vie du service enregistré est liée à celle du container. Il y aura donc une instance par container.

UnityServiceLocator s’implémente de la façon suivante:

using System;
using System.Collections.Generic;
using Microsoft.Practices.ServiceLocation;

namespace Microsoft.Practices.Unity.ServiceLocatorAdapter
{
    public class UnityServiceLocator : ServiceLocatorImplBase
    {
        private IUnityContainer container;

        public UnityServiceLocator(IUnityContainer container)
        {
            this.container = container;
        }

        /// <summary>
        /// When implemented by inheriting classes, this method will do the actual work of resolving
        /// the requested service instance.
        /// </summary>
        /// <param name="serviceType">Type of instance requested.</param>
        /// <param name="key">Name of registered service you want. May be null.</param>
        /// <returns>
        /// The requested service instance.
        /// </returns>
        protected override object DoGetInstance(Type serviceType, string key)
        {
            return container.Resolve(serviceType, key);
        }

        /// <summary>
        /// When implemented by inheriting classes, this method will do the actual work of
        /// resolving all the requested service instances.
        /// </summary>
        /// <param name="serviceType">Type of service requested.</param>
        /// <returns>
        /// Sequence of service instance objects.
        /// </returns>
        protected override IEnumerable<object> DoGetAllInstances(Type serviceType)
        {
            return container.ResolveAll(serviceType);
        }
    }
}
Détails de l’instanciation du "Service Locator"

Il faut faire attention à l’instanciation de:

UnityServiceLocator locator = new UnityServiceLocator(ConfigureUnityContainer());
ServiceLocator.SetLocatorProvider(() => locator);

D’autres implémentations ressemblantes ne sont pas équivalentes.
Par exemple:

ServiceLocator.SetLocatorProvider(() => new UnityServiceLocator(ConfigureUnityContainer()));

ou

UnityContainer container = new UnityContainer();
container.RegisterType<IFoo, Foo>(new ContainerControlledLifetimeManager());
ServiceLocator.SetLocatorProvider(() => new UnityServiceLocator(container));

La différence avec l’implémentation proposée plus haut est qu’on instancie un nouveau "Service Locator" en exécutant le délégué SetLocatorProvider() à chaque exécution de ServiceLocator.Current. La conséquence est que les instances des services récupérées par ServiceLocator.Current.GetInstance<...>() seront différentes à chaque exécution de cette ligne.

Cette implémentation est proposée par Chris Tavares: CommonServiceLocator.
Cette implémentation utilise ServiceLocatorImplBase et la classe statique ServiceLocator qui font partie de l’assembly Microsoft.Practices.ServiceLocation: disponible avec le package nuget "CommonServiceLocation".

Inconvénients de "Service Locator"

"Service Locator" ne résouds pas vraiment le problème des dépendances entre objets car il fait croire que les dépendances entre les objets ont été diminuées. Ce n’est pas tout-à-fait vrai car toutes les dépendances ont été regroupées dans le "Service Locator" qui possède désormais un lien avec tous les autres services.

"Service Locator" risque de diverger vers une classe "fourre-tout"

Etant donné que "Service Locator" possède des références vers tous les services, rien n’empêche de l’utiliser pour les mettre en relation. A terme, l’objet ServiceLocator risque de devenir un sac de noeud qui peut vite devenir inextricable. Il faut donc empêcher l’utilisation de références spécialisées dans le "Service Locator":
– Une façon de le garantir est d’implémenter le "Service Locator" dans un projet séparé où il n’existera aucune référence vers un projet contenant des implémentations spécialisées.
– Une autre méthode est d’utiliser un framework d’injection de dépendances.

"Service Locator" casse l’encapsulation

Le gros inconvénient de "Service Locator" est que, vu de l’extérieur, il est difficile de savoir quels sont les objets consommés par l’objet consommateur.
Par exemple, dans l’exemple précédent, si on souhaite utiliser ArticleController sans avoir enregistré ArticleRepository:

public class ArticleController
{
    private ArticleRepository articleRepository;
    private StockRepository stockRepository;

    public ArticleController()
    {
        this.articleRepository = ServiceLocator.Instance
            .GetRegisteredService<ArticleRepository>();
        this.stockRepository = ServiceLocator.Instance
            .GetRegisteredService<StockRepository>();
    }
}

La ligne this.articleRepository = ServiceLocator.Instance.GetRegisteredService(); va provoquer une exception car ArticleRepository n’est pas enregistrée.
Si quelqu’un utilise la classe ArticleController sans en connaître l’implémentation, il sera impossible de prévoir qu’il est nécessaire d’avoir enregistrer ArticleRepository. Le problème apparaîtra à l’exécution si toutefois il a été testé.

La solution à ce problème est l’injection de dépendances par le constructeur qui permet de montrer clairement les dépendances de l’objet consommateur.

Les dépendances sont plus difficiles à maintenir

Plus généralement, sachant que les objets consommés n’apparaissent que dans le corps des fonctions de la classe consommatrice, ils n’apparaitront plus dans la signature de ces fonctions. En cas de modification de l’implémentation, il est plus difficile de prévoir quelles sont les dépendances qui ont changées.
Cette difficulté rends la maintenance des dépendances plus délicates puisqu’elle nécessite d’aller systématiquement vérifier l’implémentation des classes et d’être attentif aux modifications de dépendances.

Références:

Leave a Reply