Loupe

Résoudre des services tardivement pour corriger les dépendances circulaires en .NET Core

Le problème des dépendances circulaires

Quand on développe une application, les bonnes pratiques de conception recommandent d'éviter les dépendances circulaires entre les services. Une dépendance circulaire, c'est quand des composants dépendent mutuellement l'un de l'autre, directement ou indirectement. Par exemple A dépend de B qui dépend de C qui dépend de A :

Dépendance circulaire

Il est communément admis qu'il vaut mieux éviter cela. Je ne rentrerai pas dans le détail des raisons conceptuelles et théoriques, le sujet a déjà été largement couvert par d'autres personnes.

Mais les dépendances circulaires ont aussi un effet très concret. Si vous introduisez accidentellement une dépendance circulaire dans une application .NET Core qui utilise l'injection de dépendance, vous le saurez tout de suite, car la résolution d'un composant impliqué dans un cycle de dépendance échouera. Par exemple, si vous avez les composants suivants :

  • A, qui implémente l'interface IA et dépend de IB
  • B, qui implémente l'interface IB et dépend de IC
  • C, qui implémente l'interface IC et dépend de IA

Quand vous essayez de résoudre IA, le conteneur d'injection de dépendance va essayer de créer une instance de A ; pour cela, il va devoir résoudre IB, donc il va essayer de créer une instance de B ; pour cela, il va devoir résoudre une instance de IC, donc il va essayer de créer une instance de C ; et pour cela, il a besoin de résoudre IA… qui est justement le type qu'il était en train de résoudre. Et voilà : dépendence circulaire. La résolution de IA ne peut pas être complétée ; en fait, le conteneur IoC par défaut de .NET Core détecte cela, et lance une exception avec un message explicite :

System.InvalidOperationException: A circular dependency was detected for the service of type 'Demo.IA'.

Donc, clairement, il faut éviter cette situation.

Contournement du problème

Cependant, quand une application réelle atteint un certain niveau de complexité, il peut parfois être difficile d'éviter d'introduire une dépendance circulaire. Un jour, on ajoute innocemment une dépendance à un service, et tout nous explose à la figure. On est alors face à un choix : refactoriser une partie non négligeable de l'application pour éviter la dépendance circulaire, ou "tricher".

Même si, idéalement, on opterait pour le refactoring, ce n'est pas toujours faisable. Il y a des délais à respecter, et on n'a peut-être pas le temps de refactoriser le code et de le retester en profondeur pour s'assurer qu'il n'y a pas de régression.

Heureusement, si on accepte l'idée de prendre un peu de dette technique, il y a un workaround simple qui fonctionne dans la plupart des cas (ce que j'appelais "tricher" un peu plus haut). L'astuce est de résoudre tardivement l'une des dépendances du cycle, c'est-à-dire la résoudre le plus tard possible, juste au moment où on a besoin de l'utiliser.

Un moyen de faire ça est d'injecter le IServiceProvider dans notre classe, et d'utiliser services.GetRequiredService<T>() quand on a besoin d'utiliser T. Par exemple, la classe C mentionnée plus haut ressembait initialement à ceci :

class C : IC
{
    private readonly IA _a;

    public C(IA a)
    {
        _a = a;
    }

    public void Bar()
    {
        ...
        _a.Foo()
        ...
    }
}

Pour éviter le cycle de dépendance, on peut la réécrire comme cela :

class C : IC
{
    private readonly IServiceProvider _services;

    public C(IServiceProvider services)
    {
        _services = services;
    }

    public void Bar()
    {
        ...
        var a = _services.GetRequiredService<IA>();
        a.Foo();
        ...
    }
}

Parce qu'il n'est plus nécessaire de résoudre IA pendant la construction de C, le cycle est rompu (du moins pendant la construction), et le problème résolu.

Cependant, je n'aime pas beaucoup cette approche, car elle se rapproche fortement du pattern Service Locator, qui est un anti-pattern bien connu. J'y vois deux problèmes principaux :

  • La classe dépend explicitement du service provider. C'est une mauvaise chose, car la classe ne devrait pas avoir à connaître quoi que ce soit sur le mécanisme d'injection de dépendance utilisé ; après tout, l'application pourrait très bien utiliser le Pure DI (injection de dépendances "pure"), c'est-à-dire ne pas utiliser de conteneur IoC du tout.
  • Cela cache les dépendances de la classe. Au lieu d'avoir les dépendances clairement déclarées au niveau du constructeur, on a maintenant juste un IServiceProvider qui ne nous dit rien sur les dépendances réelles de la classe. Il faut parcourir tout le code pour les trouver.

Une solution plus propre

L'approche que j'utilise dans cette situation tire parti de la classe Lazy<T>. Pour appliquer cette approche, on a besoin de la méthode d'extension et de la classe suivantes :

public static IServiceCollection AddLazyResolution(this IServiceCollection services)
{
    return services.AddTransient(
        typeof(Lazy<>),
        typeof(LazilyResolved<>));
}

private class LazilyResolved<T> : Lazy<T>
{
    public LazilyResolved(IServiceProvider serviceProvider)
        : base(serviceProvider.GetRequiredService<T>)
    {
    }
}

On appelle cette nouvelle méthode sur la collection de services lors de l'enregistrement des services :

services.AddLazyResolution();

Cela permet la résolution d'un Lazy<T> qui va résoudre tardivement un T à partir du service provider.

Dans la classe qui dépend de IA, on va injecter un Lazy<IA> à la place de IA. Quand on a besoin d'utiliser IA, on accède simplement à la valeur du Lazy :

class C : IC
{
    private readonly Lazy<IA> _a;

    public C(Lazy<IA> a)
    {
        _a = a;
    }

    public void Bar()
    {
        ...
        _a.Value.Foo();
        ...
    }
}

Remarque : Il ne faut SURTOUT PAS accéder à la valeur dans le constructeur, mais seulement stocker le Lazy lui-même. Accéder à la valeur dans le constructeur causerait la résolution immédiate de IA, ce qui nous ramènerait au problème qu'on essaie justement de résoudre.

Cette solution n'est pas parfaite, mais elle résoud le problème initial sans trop de complications, et les dépendances restent clairement déclarées au niveau du constructeur.

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus