Meilleures pratiques d'injection de dépendance d'ASP.NET Core, trucs et astuces

Dans cet article, je partagerai mes expériences et suggestions sur l'utilisation de Dependency Injection dans les applications ASP.NET Core. La motivation derrière ces principes sont;

  • Conception efficace des services et de leurs dépendances.
  • Prévention des problèmes de multi-threading.
  • Prévenir les fuites de mémoire.
  • Prévenir les bugs potentiels.

Cet article suppose que vous connaissez déjà Dependency Injection et ASP.NET Core à un niveau de base. Si ce n'est pas le cas, veuillez tout d'abord lire la documentation sur ASP.NET Core Dependency Injection.

Les bases

Injection de constructeur

L'injection de constructeur est utilisée pour déclarer et obtenir des dépendances d'un service sur la construction du service. Exemple:

classe publique ProductService
{
    privé en lecture seule IProductRepository _productRepository;
    public ProductService (IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }
    public void Delete (int id)
    {
        _productRepository.Delete (id);
    }
}

ProductService injecte IProductRepository en tant que dépendance dans son constructeur, puis l’utilise dans la méthode Delete.

Bonnes pratiques:

  • Définissez explicitement les dépendances requises dans le constructeur de service. Ainsi, le service ne peut pas être construit sans ses dépendances.
  • Affectez la dépendance injectée à un champ / propriété en lecture seule (pour éviter de lui attribuer accidentellement une autre valeur dans une méthode).

Injection de propriété

Le conteneur d'injection de dépendance standard d'ASP.NET Core ne prend pas en charge l'injection de propriété. Mais vous pouvez utiliser un autre conteneur supportant l'injection de propriété. Exemple:

using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
espace de noms MyApp
{
    classe publique ProductService
    {
        public ILogger  Logger {get; ensemble; }
        privé en lecture seule IProductRepository _productRepository;
        public ProductService (IProductRepository productRepository)
        {
            _productRepository = productRepository;
            Logger = NullLogger  .Instance;
        }
        public void Delete (int id)
        {
            _productRepository.Delete (id);
            Logger.LogInformation (
                $ "Suppression d'un produit avec id = {id}");
        }
    }
}

ProductService déclare une propriété de journalisation avec un setter public. Le conteneur d'injection de dépendance peut définir l'enregistreur s'il est disponible (enregistré auparavant dans le conteneur DI).

Bonnes pratiques:

  • Utilisez l'injection de propriété uniquement pour les dépendances facultatives. Cela signifie que votre service peut fonctionner correctement sans ces dépendances.
  • Utilisez un modèle d'objet nul (comme dans cet exemple) si possible. Sinon, vérifiez toujours la valeur null lorsque vous utilisez la dépendance.

Localisateur de service

Le modèle de localisation de service est un autre moyen d’obtenir des dépendances. Exemple:

classe publique ProductService
{
    privé en lecture seule IProductRepository _productRepository;
    lecture seule privée ILogger  _logger;
    public ProductService (IServiceProvider serviceProvider)
    {
        _productRepository = fournisseur de service
          .GetRequiredService  ();
        _logger = fournisseur de service
          .GetService > () ??
            NullLogger  .Instance;
    }
    public void Delete (int id)
    {
        _productRepository.Delete (id);
        _logger.LogInformation ($ "Suppression d'un produit avec id = = {id}");
    }
}

ProductService injecte IServiceProvider et résout les dépendances qui l'utilisent. GetRequiredService lève une exception si la dépendance demandée n'a pas été enregistrée auparavant. De l’autre côté, GetService renvoie simplement null dans ce cas.

Lorsque vous résolvez des services à l'intérieur du constructeur, ils sont publiés lors de la publication du service. Donc, vous ne vous souciez pas de libérer / supprimer des services résolus dans le constructeur (comme le constructeur et l’injection de propriété).

Bonnes pratiques:

  • N'utilisez pas le modèle de localisation de service dans la mesure du possible (si le type de service est connu pendant la période de développement). Parce que cela rend les dépendances implicites. Cela signifie qu’il est impossible de voir facilement les dépendances lors de la création d’une instance du service. Ceci est particulièrement important pour les tests unitaires où vous pouvez simuler certaines dépendances d'un service.
  • Résolvez les dépendances dans le constructeur de service si possible. Résoudre dans une méthode de service rend votre application plus compliquée et sujette aux erreurs. Je couvrirai les problèmes et les solutions dans les sections suivantes.

Durée de vie du service

Il existe trois durées de service dans ASP.NET Core Dependency Injection:

  1. Les services transitoires sont créés chaque fois qu'ils sont injectés ou demandés.
  2. Les services ciblés sont créés par étendue. Dans une application Web, chaque demande Web crée une nouvelle étendue de service séparée. Cela signifie que les services ciblés sont généralement créés par requête Web.
  3. Les services Singleton sont créés par conteneur DI. Cela signifie généralement qu'ils ne sont créés qu'une seule fois par application, puis utilisés pendant toute la durée de vie de l'application.

Le conteneur DI garde la trace de tous les services résolus. Les services sont libérés et éliminés à la fin de leur vie:

  • Si le service a des dépendances, elles sont également automatiquement libérées et éliminées.
  • Si le service implémente l'interface IDisposable, la méthode Dispose est automatiquement appelée à la publication du service.

Bonnes pratiques:

  • Enregistrez vos services comme transitoires autant que possible. Parce qu’il est simple de concevoir des services transitoires. En général, vous ne vous souciez pas du multi-threading et des fuites de mémoire et vous savez que le service a une durée de vie courte.
  • Utilisez avec précaution la durée de vie du service Scoped car cela peut être délicat si vous créez des étendues de service enfants ou utilisez ces services à partir d'une application non Web.
  • Utilisez soigneusement la durée de vie du singleton, car vous devez alors faire face à des problèmes de multi-threading et de fuites de mémoire potentielles.
  • Ne comptez pas sur un service transitoire ou limité d’un service singleton. Parce que le service transitoire devient une instance singleton lorsqu'un service singleton l'injecte, cela peut poser problème si le service transitoire n'est pas conçu pour prendre en charge un tel scénario. Le conteneur DI par défaut d’ASP.NET Core génère déjà des exceptions dans de tels cas.

Résolution de services dans un corps de méthode

Dans certains cas, vous devrez peut-être résoudre un autre service dans une méthode de votre service. Dans ce cas, assurez-vous de libérer le service après utilisation. Le meilleur moyen de s'en assurer est de créer une étendue de service. Exemple:

public class PriceCalculator
{
    privé en lecture seule IServiceProvider _serviceProvider;
    PriceCalculator public (IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    public float Calculer (produit produit, nombre entier,
      Type taxStrategyServiceType)
    {
        using (var scope = _serviceProvider.CreateScope ())
        {
            var taxStrategy = (ITaxStrategy) scope.ServiceProvider
              .GetRequiredService (taxStrategyServiceType);
            var prix = produit.Prix * nombre;
            prix de retour + taxStrategy.CalculateTax (price);
        }
    }
}

PriceCalculator injecte IServiceProvider dans son constructeur et l'assigne à un champ. PriceCalculator l'utilise ensuite dans la méthode Calculate pour créer une étendue de service enfant. Il utilise scope.ServiceProvider pour résoudre les services, au lieu de l'instance injectée _serviceProvider. Ainsi, tous les services résolus à partir de la portée sont automatiquement libérés / supprimés à la fin de l'instruction using.

Bonnes pratiques:

  • Si vous résolvez un service dans un corps de méthode, créez toujours une étendue de service enfant pour vous assurer que les services résolus sont correctement libérés.
  • Si une méthode obtient IServiceProvider en tant qu'argument, vous pouvez alors résoudre les services directement à partir de celle-ci sans se soucier de la publication / de la suppression. La création / gestion de la portée du service est une responsabilité du code appelant votre méthode. En suivant ce principe, votre code est plus propre.
  • Ne retenez pas une référence à un service résolu! Sinon, cela pourrait entraîner des fuites de mémoire et vous aurez accès à un service supprimé lorsque vous utiliserez la référence d'objet ultérieurement (sauf si le service résolu est un singleton).

Services Singleton

Les services Singleton sont généralement conçus pour conserver un état d'application. Un cache est un bon exemple d'états d'application. Exemple:

Classe publique FileService
{
    private en lecture seule ConcurrentDictionary  _cache;
    public FileService ()
    {
        _cache = new ConcurrentDictionary  ();
    }
    octet public [] GetFileContent (string filePath)
    {
        retourne _cache.GetOrAdd (chemin_fichier, _ =>
        {
            return File.ReadAllBytes (filePath);
        });
    }
}

FileService met simplement en cache le contenu du fichier pour réduire les lectures sur le disque. Ce service devrait être enregistré en tant que singleton. Sinon, la mise en cache ne fonctionnera pas comme prévu.

Bonnes pratiques:

  • Si le service détient un état, il doit y accéder de manière thread-safe. Parce que toutes les demandes utilisent simultanément la même instance du service. J'ai utilisé ConcurrentDictionary au lieu de Dictionary pour assurer la sécurité des threads.
  • N'utilisez pas de services étendus ou transitoires à partir de services singleton. Parce que les services transitoires peuvent ne pas être conçus pour être thread-safe. Si vous devez les utiliser, prenez en charge le multi-threading lorsque vous utilisez ces services (utilisez lock, par exemple).
  • Les fuites de mémoire sont généralement causées par des services singleton. Ils ne sont pas libérés / éliminés avant la fin de la demande. Ainsi, s’ils instancient des classes (ou s’injectent) mais ne les libèrent pas, ils resteront également en mémoire jusqu’à la fin de l’application. Assurez-vous de les libérer / les disposer au bon moment. Consultez la section Résolution de services dans un corps de méthode ci-dessus.
  • Si vous mettez des données en cache (contenu du fichier dans cet exemple), vous devez créer un mécanisme pour mettre à jour / invalider les données en cache lorsque la source de données d'origine est modifiée (lorsqu'un fichier en cache est modifié sur le disque pour cet exemple).

Services Scoped

Une durée de vie limitée semble tout d’abord un bon candidat pour stocker des données par requête Web. Parce qu'ASP.NET Core crée une étendue de service par requête Web. Ainsi, si vous enregistrez un service comme étant couvert, il peut être partagé lors d'une requête Web. Exemple:

Classe publique RequestItemsService
{
    private readonly Dictionnaire  _items;
    public RequestItemsService ()
    {
        _items = new Dictionary  ();
    }
    public void Set (nom de chaîne, valeur d'objet)
    {
        _items [name] = valeur;
    }
    objet public Get (nom de chaîne)
    {
        retourne _items [nom];
    }
}

Si vous enregistrez le RequestItemsService comme étant étendu et l'injectez dans deux services différents, vous pouvez obtenir un élément ajouté à partir d'un autre service car ils partageront la même instance RequestItemsService. C’est ce que nous attendons des services étendus.

Mais .. le fait peut ne pas être toujours comme ça. Si vous créez une étendue de service enfant et résolvez RequestItemsService à partir de celle-ci, vous obtiendrez une nouvelle instance de RequestItemsService et celle-ci ne fonctionnera pas comme prévu. Ainsi, service ciblé ne signifie pas toujours instance par requête Web.

Vous pouvez penser que vous ne commettez pas une erreur aussi évidente (résoudre une portée dans une portée enfant). Mais, ce n'est pas une erreur (un usage très régulier) et le cas peut ne pas être aussi simple. S'il existe un grand graphique de dépendance entre vos services, vous ne pouvez pas savoir si quelqu'un a créé une étendue enfant et résolu un service qui injecte un autre service… qui injecte finalement un service défini.

Bonnes pratiques:

  • Un service limité peut être considéré comme une optimisation dans laquelle il est injecté par trop de services dans une requête Web. Ainsi, tous ces services utiliseront une seule instance du service au cours de la même requête Web.
  • Les services étendus n’ont pas besoin d’être conçus pour être thread-safe. En effet, ils devraient normalement être utilisés par une seule requête / thread Web. Mais… dans ce cas, vous ne devez pas partager les étendues de service entre différents threads!
  • Soyez prudent si vous concevez un service défini pour partager des données entre d'autres services dans une requête Web (expliqué ci-dessus). Vous pouvez stocker des données par requête Web dans le HttpContext (injecter IHttpContextAccessor pour y accéder), ce qui est le moyen le plus sûr de le faire. La durée de vie de HttpContext n’est pas limitée. En fait, il n’est pas enregistré du tout (c’est pourquoi vous ne l’injectez pas, mais vous injectez plutôt IHttpContextAccessor). L'implémentation HttpContextAccessor utilise AsyncLocal pour partager le même HttpContext lors d'une requête Web.

Conclusion

L’injection de dépendance semble simple à utiliser au début, mais il existe des problèmes de multi-threading et de fuite de mémoire si vous ne respectez pas certains principes stricts. J'ai partagé quelques bons principes basés sur mes propres expériences lors du développement du cadre ASP.NET Boilerplate.