Dependency Injection

Come utilizzare la Dependency Injection in applicazioni .NET core.

il
Sviluppatore software, Collaboratore di IProgrammatori

La dependency injection (DI) è un design pattern utilizzato nella programmazione ad oggetti, utile per semplificare la scrittura del codice e la testabilità dei programmi.

Il pattern prevede che un oggetto dichiari nel proprio costrutture le dipendenze, lasciando al motore della dependency injection il compito di istanziarle e gestirne il ciclo di vita.

Schema Dependency Injection

Applicazione della DI in .NET core

Supponiamo di voler implementare un servizio in grado di calcolare l’importo totale delle fatture emesse suddividendolo fra clienti appartenenti alla CEE e non.

Creiamo quindi un nuovo progetto di tipo console:

dotnet new console -n LearningDI

Successivamente creiamo le classi necessarie per la definizione delle fatture:

public class Customer
{

   public int Id { get; set; }

   public string Name { get; set; }

   public CustomerRegion Region { get; set;}

}

public enum CustomerRegion
{
   Cee,
   ExtraCee
}

public class Invoice
{

   public int Id { get; set; }

   public Customer Customer { get; set; }

   public decimal Amount { get; set; }

}

A questo punto concentriamoci sulle classi necessarie alla nostra procedura:

  • Un lettore delle fatture presenti a sistema
  • La nostra business che esegue i calcoli

Osservandole dal punto delle dipendenze, possiamo dire che la classe di business ha bisogno (dipende) dalla classe che si occupa della lettura delle fatture.

Secondo le best-practice della programmazione ad oggetti le dipendenze è meglio definirle come interfaccia: al nostro servizio di business non interessa la classe concreta che leggerà i dati, purché abbia un metodo che ritorni l’elenco delle fatture.

Creiamo quindi l’interfaccia e la relativa implementazione che torna dei dati fissi caricati in memoria:

public interface IInvoiceReader
{
   public IEnumerable<Invoice> Get();
}

public class MemoryInvoiceReader : IInvoiceReader
{
   public IEnumerable<Invoice> Get()
   {
      for (int i = 0; i < 100; i++)
      {
         var customerId = i % 2 == 0 ? 1 : 2;
         var customerRegion = i % 2 == 0 ? CustomerRegion.Cee : CustomerRegion.ExtraCee;
         yield return new Invoice {
            Id = i,
            Customer = new Customer
            {
               Id = customerId,
               Name = $"Customer {customerId}",
               Region = customerRegion
            },
            Amount = i * 1.25M
         };
      }
   }
}


A questo punto non ci resta che creare la classe relativa al nostro servizio di business che dichiara le proprie dipendenze nel costruttore, come richiesto dal pattern della dependency injection:

public class InvoiceService
{

   public InvoiceService(IInvoiceReader invoiceReader)
   {
      InvoiceReader = invoiceReader ?? throw new ArgumentNullException(nameof(invoiceReader));
   }

   private IInvoiceReader InvoiceReader { get; }

   public decimal GetTotalForCee()
   {
      return InvoiceReader.Get()
                          .Where(x => x.Customer.Region == CustomerRegion.Cee)
                          .Sum(x => x.Amount);
   }

   public decimal GetTotalForExtraCee()
   {
      return InvoiceReader.Get()
                          .Where(x => x.Customer.Region == CustomerRegion.ExtraCee)
                          .Sum(x => x.Amount);
   }

}


Sviluppata la business (InvoiceService) e le relative dipendenze (InvoiceReader), non ci resta che utilizzare un motore di dependency injection e fare le dovute configurazioni.
In questo esempio useremo la libreria fornita da Microsoft che aggiungeremo al nostro progetto mediante apposito comando:

dotnet add package Microsoft.Extensions.DependencyInjection


Per terminare il progetto dobbiamo:

  • Configurare il motore della dependency injection per dirgli di istanziare un oggetto di tipo InvoiceReader per tutte le classi che nel costruttore richiedono un IInvoiceReader
  • Dire al motore della dependency injection che esiste la classe InvoiceService (è compito suo istanziarla) e che, quando richiesta, ne crei sempre una nuova istanza

Per comodità mettiamo tutto nel metodo Main del nostro progetto:

static void Main(string[] args)
{
   // Registriamo il provider
   var serviceProvider = new ServiceCollection()
                             .AddTransient<IInvoiceReader, MemoryInvoiceReader>()
                             .AddTransient<InvoiceService>()
                             .BuildServiceProvider();

   // Chiediamo al motore della DI di darci il servizio/classe richiesta
   var invoiceService = serviceProvider.GetService<InvoiceService>();

   // Visualizziaomo i dati
   Console.WriteLine($"Totale fatture CEE: {invoiceService.GetTotalForCee()}");
   Console.WriteLine($"Totale fatture Extra CEE: {invoiceService.GetTotalForExtraCee()}");

   // Chiediamo un invio per uscire
   Console.WriteLine("Premere invio per uscire");
   Console.ReadLine();
}

Il codice è abbastanza chiaro e, come possiamo notare, non abbiamo mai istanziato alcuna classe direttamente, escluse quelle per la gestione della dependency injection.


Ciclo di vita degli oggetti

Uno dei compiti del motore di dependency injection è quello di gestire il ciclo di vita degli oggetti che lui stesso istanzia. Microsoft prevede a questo scopo tre opzioni:

  1. Transient: viene sempre creata una nuova istanza della classe richiesta. Due oggetti che richiedono le stesse dipendenze avranno sempre oggetti distinti
  2. Scoped: viene creata una nuova istanza della classe per ogni scope. Due oggetti che richiedono le stesse dipendenze avranno gli stessi oggetti se appartengono allo stesso scope, oggetti diversi se appartengono a scope diversi
  3. Singleton: viene creata una sola istanza della classe per tutta l’applicazione. Due oggetti che richiedono le stesse dipendenze condivideranno sempre lo stesso oggetto

Vediamo con un esempio la differenza fra le tre tipologie sopra descritte:

var scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();

using (var scope = scopeFactory.CreateScope())
{
   var service1 = scope.ServiceProvider.GetService<InvoiceService>();
   var service2 = scope.ServiceProvider.GetService<InvoiceService>();
}
var service3 = serviceProvider.GetService<InvoiceService>();

In base al metodo usato per aggiungere InvoiceService al provider, le istanze sarebbero le seguenti:

  • AddTransient: tutte istanze diverse
  • AddScoped: service1 e service2 sarebbero la stessa istanza (condividono lo scope), mentre service 3 sarebbe una istanza diversa
  • AddSingleton: essendo singleton, service1-2-3 sarebbero la stessa istanza dell’oggetto