Hold the Framework - Esplorazione dei modelli di iniezione delle dipendenze

Pubblicato: 2022-03-11

Le opinioni tradizionali sull'inversione del controllo (IoC) sembrano tracciare una linea dura tra due diversi approcci: il localizzatore di servizi e i modelli di iniezione di dipendenza (DI).

Praticamente ogni progetto che conosco include un framework DI. Le persone ne sono attratte perché promuovono un accoppiamento libero tra i client e le loro dipendenze (di solito attraverso l'iniezione del costruttore) con un codice standard minimo o assente. Anche se questo è ottimo per uno sviluppo rapido, alcune persone scoprono che può rendere difficile il tracciamento e il debug del codice. La "magia dietro le quinte" di solito si ottiene attraverso la riflessione, che può portare tutta una serie di nuovi problemi.

In questo articolo, esploreremo un modello alternativo che ben si adatta alle basi di codice Java 8+ e Kotlin. Conserva la maggior parte dei vantaggi di un framework DI pur essendo semplice come un localizzatore di servizi, senza richiedere strumenti esterni.

Motivazione

  • Evita le dipendenze esterne
  • Evita la riflessione
  • Promuove l'iniezione del costruttore
  • Riduci al minimo il comportamento in fase di esecuzione

Un esempio

Nell'esempio seguente, modelleremo un'implementazione TV, in cui è possibile utilizzare diverse fonti per ottenere contenuti. Dobbiamo costruire un dispositivo in grado di ricevere segnali da varie sorgenti (es. terrestre, via cavo, satellitare, ecc.). Costruiremo la seguente gerarchia di classi:

Gerarchia di classi di un dispositivo TV che implementa una sorgente di segnale arbitraria

Ora iniziamo con un'implementazione DI tradizionale, quella in cui un framework come Spring sta cablando tutto per noi:

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

Notiamo alcune cose:

  • La classe TV esprime una dipendenza da un TvSource. Un framework esterno lo vedrà e inietterà un'istanza di un'implementazione concreta (terrestre o via cavo).
  • Il modello di iniezione del costruttore consente un facile test perché puoi facilmente creare istanze TV con implementazioni alternative.

Siamo partiti bene, ma ci rendiamo conto che introdurre un framework DI per questo potrebbe essere un po' eccessivo. Alcuni sviluppatori hanno segnalato problemi durante il debug di problemi di costruzione (tracce lunghe dello stack, dipendenze non rintracciabili). Il nostro cliente ha anche affermato che i tempi di produzione sono un po' più lunghi del previsto e il nostro profiler mostra rallentamenti nelle chiamate riflessive.

Un'alternativa sarebbe applicare il modello Service Locator. È semplice, non usa la riflessione e potrebbe essere sufficiente per la nostra piccola base di codice. Un'altra alternativa è lasciare le classi da sole e scrivere il codice della posizione delle dipendenze attorno ad esse.

Dopo aver valutato molte alternative, scegliamo di implementarlo come una gerarchia di interfacce del provider. Ogni dipendenza avrà un provider associato che avrà la responsabilità esclusiva di individuare le dipendenze di una classe e costruire un'istanza iniettata. Inoltre, renderemo il provider un'interfaccia interna per facilità d'uso. Lo chiameremo Mixin Injection perché ogni provider è mescolato con altri provider per individuare le sue dipendenze.

I dettagli del motivo per cui ho optato per questa struttura sono elaborati in Dettagli e Razionale, ma ecco la versione breve:

  • Segrega il comportamento della posizione di dipendenza.
  • L'estensione delle interfacce non rientra nel problema del diamante.
  • Le interfacce hanno implementazioni predefinite.
  • Le dipendenze mancanti impediscono la compilazione (punti bonus!).

Il diagramma seguente mostra come interagiscono le dipendenze e i provider e l'implementazione è illustrata di seguito. Aggiungiamo anche un metodo principale per dimostrare come possiamo comporre le nostre dipendenze e costruire un oggetto TV. Una versione più lunga di questo esempio può essere trovata anche su questo GitHub.

Interazioni tra provider e dipendenze

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

Alcune note su questo esempio:

  • La classe TV dipende da un TvSource, ma non conosce alcuna implementazione.
  • TV.Provider estende TvSource.Provider perché ha bisogno del metodo tvSource() per creare un TvSource e può usarlo anche se non è implementato lì.
  • Le sorgenti Terrestre e Cavo possono essere utilizzate in modo intercambiabile dal televisore.
  • Le interfacce Terrestrial.Provider e Cable.Provider forniscono implementazioni concrete di TvSource.
  • Il metodo main ha un'implementazione concreta MainContext di TV.Provider che viene utilizzata per ottenere un'istanza TV.
  • Il programma richiede un'implementazione di TvSource.Provider in fase di compilazione per creare un'istanza di una TV, quindi includiamo Cable.Provider come esempio.

Dettagli e motivazione

Abbiamo visto il modello in azione e alcuni dei ragionamenti dietro di esso. Potresti non essere convinto che dovresti usarlo ormai, e avresti ragione; non è esattamente una pallottola d'argento. Personalmente, credo che sia superiore al modello di localizzazione del servizio in molti aspetti. Tuttavia, rispetto ai framework DI, è necessario valutare se i vantaggi superano il sovraccarico dell'aggiunta di codice standard.

I provider estendono altri provider per individuare le loro dipendenze

Quando un provider ne estende un altro, le dipendenze sono legate insieme. Ciò fornisce le basi di base per la convalida statica che impedisce la creazione di contesti non validi.

Uno dei principali punti deboli del modello di localizzazione del servizio è che devi chiamare un GetService<T>() generico che risolverà in qualche modo la tua dipendenza. In fase di compilazione, non hai garanzie che la dipendenza verrà mai registrata nel locator e il tuo programma potrebbe non riuscire in fase di esecuzione.

Anche il modello DI non risolve questo problema. La risoluzione delle dipendenze viene in genere eseguita tramite la riflessione da parte di uno strumento esterno che è per lo più nascosto all'utente, che fallisce anche in fase di esecuzione se le dipendenze non vengono soddisfatte. Strumenti come CDI di IntelliJ (disponibile solo nella versione a pagamento) forniscono un certo livello di verifica statica, ma solo Dagger con il suo preprocessore di annotazione sembra affrontare questo problema in base alla progettazione.

Le classi mantengono l'iniezione tipica del costruttore del modello DI

Questo non è richiesto ma decisamente desiderato dalla comunità degli sviluppatori. Da un lato, puoi semplicemente guardare il costruttore e vedere immediatamente le dipendenze della classe. D'altra parte, consente il tipo di test unitario a cui molte persone aderiscono, ovvero costruendo il soggetto sottoposto a test con derisioni delle sue dipendenze.

Questo non vuol dire che altri modelli non siano supportati. In effetti, si potrebbe anche scoprire che Mixin Injection semplifica la costruzione di complessi grafici di dipendenza per i test perché è sufficiente implementare una classe di contesto che estenda il provider del soggetto. Il MainContext sopra è un esempio perfetto in cui tutte le interfacce hanno implementazioni predefinite, quindi può avere un'implementazione vuota. La sostituzione di una dipendenza richiede solo l'override del relativo metodo del provider.

Diamo un'occhiata al seguente test per la classe TV. Deve creare un'istanza di una TV, ma invece di chiamare il costruttore di classe, utilizza l'interfaccia TV.Provider. TvSource.Provider non ha un'implementazione predefinita, quindi dobbiamo scriverlo noi stessi.

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

Ora aggiungiamo un'altra dipendenza alla classe TV. La dipendenza CathodeRayTube fa la magia per far apparire un'immagine sullo schermo TV. È disaccoppiato dall'implementazione TV perché in futuro potremmo voler passare a LCD o LED.

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Se lo fai, noterai che il test che abbiamo appena scritto viene compilato e superato come previsto. Abbiamo aggiunto una nuova dipendenza alla TV, ma abbiamo anche fornito un'implementazione predefinita. Ciò significa che non dobbiamo deriderlo se vogliamo solo utilizzare l'implementazione reale e i nostri test possono creare oggetti complessi con qualsiasi livello di finta granularità che desideriamo.

Questo è utile quando vuoi prendere in giro qualcosa di specifico in una complessa gerarchia di classi (ad esempio, solo il livello di accesso al database). Il modello consente di impostare facilmente il tipo di test socievoli che a volte sono preferiti ai test solitari.

Indipendentemente dalle tue preferenze, puoi essere certo di poter ricorrere a qualsiasi forma di test più adatta alle tue esigenze in ogni situazione.

Evita le dipendenze esterne

Come puoi vedere, non ci sono riferimenti o menzioni a componenti esterni. Questa è la chiave per molti progetti che hanno vincoli di dimensioni o addirittura di sicurezza. Aiuta anche con l'interoperabilità perché i framework non devono impegnarsi in un framework DI specifico. In Java, ci sono stati sforzi come JSR-330 Dependency Injection per Java Standard che mitigano i problemi di compatibilità.

Evita la riflessione

Le implementazioni del localizzatore di servizi di solito non si basano sulla riflessione, ma le implementazioni DI lo fanno (con la notevole eccezione di Dagger 2). Questo ha i principali svantaggi di rallentare l'avvio dell'applicazione perché il framework deve scansionare i tuoi moduli, risolvere il grafico delle dipendenze, costruire in modo riflessivo i tuoi oggetti, ecc.

Mixin Injection richiede di scrivere il codice per creare un'istanza dei servizi, in modo simile alla fase di registrazione nel modello di localizzazione del servizio. Questo piccolo lavoro extra rimuove completamente le chiamate riflessive, rendendo il tuo codice più veloce e diretto.

Due progetti che di recente hanno attirato la mia attenzione e traggono vantaggio dall'evitare la riflessione sono Substrate VM di Graal e Kotlin/Native. Entrambi compilano in bytecode nativo e ciò richiede al compilatore di conoscere in anticipo qualsiasi chiamata riflessiva che verrà effettuata. Nel caso di Graal è specificato in un file JSON che è difficile da scrivere, non può essere controllato staticamente, non può essere facilmente rifattorizzato usando i tuoi strumenti preferiti. L'uso di Mixin Injection per evitare la riflessione in primo luogo è un ottimo modo per ottenere i vantaggi della compilazione nativa.

Riduci al minimo il comportamento di runtime

Implementando ed estendendo le interfacce richieste, costruisci il grafico delle dipendenze un pezzo alla volta. Ogni provider siede accanto all'implementazione concreta, che porta ordine e logica al tuo programma. Questo tipo di stratificazione ti risulterà familiare se hai già utilizzato il motivo Mixin o il motivo Torta.

A questo punto, potrebbe valere la pena parlare della classe MainContext. È la radice del grafico delle dipendenze e conosce il quadro generale. Questa classe include tutte le interfacce del provider ed è fondamentale per abilitare i controlli statici. Se torniamo all'esempio e rimuoviamo Cable.Provider dal suo elenco di attrezzi, lo vedremo chiaramente:

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

Quello che è successo qui è che l'app non ha specificato il TvSource concreto da usare e il compilatore ha rilevato l'errore. Con il localizzatore di servizi e le DI basate sulla riflessione, questo errore avrebbe potuto passare inosservato fino a quando il programma non si è arrestato in modo anomalo in fase di esecuzione, anche se tutti i test unitari sono stati superati! Credo che questi e gli altri vantaggi che abbiamo mostrato superino lo svantaggio di scrivere il boilerplate necessario per far funzionare il modello.

Cattura le dipendenze circolari

Torniamo all'esempio CathodeRayTube e aggiungiamo una dipendenza circolare. Diciamo che vogliamo che venga iniettata un'istanza TV, quindi estendiamo TV.Provider:

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Il compilatore non consente l'ereditarietà ciclica e non siamo in grado di definire questo tipo di relazione. La maggior parte dei framework fallisce in fase di esecuzione quando ciò accade e gli sviluppatori tendono a aggirarlo solo per far funzionare il programma. Anche se questo anti-modello può essere trovato nel mondo reale, di solito è un segno di cattivo design. Quando il codice non viene compilato, dovremmo essere incoraggiati a cercare soluzioni migliori prima che sia troppo tardi per cambiare.

Mantieni la semplicità nella costruzione degli oggetti

Uno degli argomenti a favore di SL rispetto a DI è che è semplice e facile da eseguire il debug. È chiaro dagli esempi che l'istanziazione di una dipendenza sarà solo una catena di chiamate al metodo del provider. Rintracciare l'origine di una dipendenza è semplice come entrare nella chiamata al metodo e vedere dove si finisce. Il debug è più semplice di entrambe le alternative perché puoi navigare esattamente dove vengono istanziate le dipendenze, direttamente dal provider.

Durata del servizio

Un lettore attento potrebbe aver notato che questa implementazione non risolve il problema della durata del servizio. Tutte le chiamate ai metodi del provider istanziano nuovi oggetti, rendendolo simile all'ambito del prototipo di Spring.

Questa e altre considerazioni sono leggermente fuori dallo scopo di questo articolo, poiché volevo semplicemente presentare l'essenza del modello senza distrarre i dettagli. L'utilizzo e l'implementazione completi in un prodotto dovrebbero, tuttavia, tenere conto della soluzione completa con supporto a vita.

Conclusione

Che tu sia abituato a framework di iniezione delle dipendenze o a scrivere i tuoi localizzatori di servizi, potresti voler esplorare questa alternativa. Prendi in considerazione l'utilizzo del modello di mixin che abbiamo appena visto e vedi se puoi rendere il tuo codice più sicuro e più facile da ragionare.

Correlati: Best practice JS: crea un bot Discord con TypeScript e Dependency Injection