Cos'è la Dependency Injection?
Questo capitolo vi introdurrà alle pratiche di programmazione di base che dovreste seguire quando scrivete qualsiasi applicazione. Si tratta delle basi necessarie per scrivere codice pulito, comprensibile e manutenibile.
Se adotterete queste regole e le seguirete, Nette vi supporterà in ogni passo. Si occuperà dei compiti di routine per voi e vi fornirà la massima comodità, così potrete concentrarvi sulla logica stessa.
I principi che vi mostreremo qui sono piuttosto semplici. Non c'è nulla di cui preoccuparsi.
Ricordi il tuo primo programma?
Non sappiamo in quale linguaggio lo hai scritto, ma se fosse stato PHP, probabilmente sarebbe stato simile a questo:
Poche righe di codice banali, ma contengono così tanti concetti chiave. Che esistono le variabili. Che il codice è diviso in unità più piccole, come le funzioni. Che passiamo loro argomenti di input e restituiscono risultati. Mancano solo le condizioni e i cicli.
Il fatto che passiamo dati di input a una funzione e questa restituisca un risultato è un concetto perfettamente comprensibile, utilizzato anche in altri campi, come la matematica.
Una funzione ha la sua firma, che consiste nel suo nome, un elenco di parametri e i loro tipi, e infine il tipo di valore di ritorno. Come utenti, ci interessa la firma; di solito non abbiamo bisogno di sapere nulla dell'implementazione interna.
Ora immagina che la firma della funzione fosse così:
Una somma con un solo parametro? Strano… E che ne dici di questo?
Questo è davvero molto strano, vero? Come si usa la funzione?
Guardando un codice del genere, saremmo confusi. Non solo un principiante non lo capirebbe, ma nemmeno un programmatore esperto capirebbe un codice del genere.
Ti stai chiedendo come sarebbe effettivamente una funzione del genere all'interno? Dove prenderebbe gli addendi? Probabilmente se li procurerebbe in qualche modo da sola, forse così:
Nel corpo della funzione, abbiamo scoperto dipendenze nascoste verso altre funzioni globali o metodi statici. Per scoprire da dove provengono effettivamente gli addendi, dobbiamo indagare ulteriormente.
Non da questa parte!
Il design che abbiamo appena mostrato è l'essenza di molte caratteristiche negative:
- la firma della funzione fingeva di non aver bisogno di addendi, il che ci confondeva
- non sappiamo affatto come far sommare alla funzione altri due numeri
- abbiamo dovuto guardare nel codice per scoprire dove prendeva gli addendi
- abbiamo scoperto dipendenze nascoste
- per una comprensione completa, è necessario esaminare anche queste dipendenze
Ed è compito della funzione di somma procurarsi gli input? Ovviamente no. La sua responsabilità è solo la somma stessa.
Non vogliamo incontrare codice del genere, e certamente non vogliamo scriverlo. La correzione è semplice: tornare alle basi e usare semplicemente i parametri:
Regola n. 1: fatti passare le dipendenze
La regola più importante è: tutti i dati di cui le funzioni o le classi hanno bisogno devono essere passati loro.
Invece di inventare modi nascosti attraverso i quali potrebbero ottenerli da soli, passa semplicemente i parametri. Risparmierai tempo necessario per inventare percorsi nascosti, che sicuramente non miglioreranno il tuo codice.
Se seguirai sempre e ovunque questa regola, sarai sulla strada per un codice senza dipendenze nascoste. Verso un codice comprensibile non solo per l'autore, ma anche per chiunque lo leggerà dopo di lui. Dove tutto è comprensibile dalle firme delle funzioni e delle classi e non c'è bisogno di cercare segreti nascosti nell'implementazione.
Questa tecnica è tecnicamente chiamata dependency injection. E questi dati sono chiamati dipendenze. In realtà, si tratta semplicemente di passare parametri, niente di più.
Per favore, non confondete la dependency injection, che è un design pattern, con il “dependency injection container”, che è invece uno strumento, cioè qualcosa di diametralmente diverso. Ci occuperemo dei container più avanti.
Dalle funzioni alle classi
E come si relaziona questo con le classi? Una classe è un'unità più complessa di una semplice funzione, ma la regola n. 1 si applica pienamente anche qui. Ci sono solo più opzioni per passare gli argomenti. Ad esempio, in modo abbastanza simile al caso di una funzione:
Oppure usando altri metodi, o direttamente il costruttore:
Entrambi gli esempi sono pienamente conformi alla dependency injection.
Esempi reali
Nel mondo reale, non scriverai classi per sommare numeri. Passiamo a esempi pratici.
Abbiamo una classe Article
che rappresenta un articolo di blog:
e l'utilizzo sarà il seguente:
Il metodo save()
salva l'articolo in una tabella del database. Implementarlo usando Nette Database sarebbe un gioco da ragazzi, se non fosse per un intoppo: dove prende
Article
la connessione al database, cioè l'oggetto della classe Nette\Database\Connection
?
Sembra che abbiamo molte opzioni. Può prenderla da qualche variabile statica. O ereditare da una classe che fornisce la connessione al database. O utilizzare il cosiddetto singleton. O le cosiddette facades, che vengono utilizzate in Laravel:
Fantastico, abbiamo risolto il problema.
O no?
Ricordiamo la Regola n. 1: fatti passare le dipendenze: tutte le dipendenze di cui la classe ha bisogno devono essere passate ad essa. Perché se violiamo la regola, abbiamo intrapreso la strada verso un codice sporco pieno di dipendenze nascoste, incomprensibilità, e il risultato sarà un'applicazione che sarà doloroso mantenere e sviluppare.
L'utente della classe Article
non ha idea di dove il metodo save()
salvi l'articolo. In una tabella
del database? In quale, quella di produzione o di test? E come si può cambiare?
L'utente deve guardare come è implementato il metodo save()
e trova l'uso del metodo DB::insert()
.
Quindi deve indagare ulteriormente su come questo metodo ottiene la connessione al database. E le dipendenze nascoste possono
formare una catena piuttosto lunga.
Nel codice pulito e ben progettato, non ci sono mai dipendenze nascoste, facades di Laravel o variabili statiche. Nel codice pulito e ben progettato, si passano argomenti:
Ancora più pratico, come vedremo più avanti, sarà tramite il costruttore:
Se sei un programmatore esperto, potresti pensare che Article
non dovrebbe affatto avere un metodo
save()
, dovrebbe rappresentare puramente un componente dati e il salvataggio dovrebbe essere gestito da un repository
separato. Questo ha senso. Ma ci porterebbe molto lontano dall'argomento, che è la dependency injection, e dallo sforzo di
fornire esempi semplici.
Se scrivi una classe che richiede, ad esempio, un database per funzionare, non inventare da dove ottenerlo, ma fattelo passare. Ad esempio, come parametro del costruttore o di un altro metodo. Riconosci le dipendenze. Riconoscile nell'API della tua classe. Otterrai un codice comprensibile e prevedibile.
E che ne dici di questa classe, che registra i messaggi di errore:
Cosa ne pensi, abbiamo rispettato la Regola n. 1: fatti passare le dipendenze?
Non l'abbiamo rispettata.
L'informazione chiave, cioè la directory con il file di log, la classe se la procura da sola da una costante.
Guarda l'esempio di utilizzo:
Senza conoscere l'implementazione, saresti in grado di rispondere alla domanda su dove vengono scritti i messaggi? Ti verrebbe
in mente che per funzionare è necessaria l'esistenza della costante LOG_DIR
? E saresti in grado di creare una
seconda istanza che scriva altrove? Certamente no.
Correggiamo la classe:
La classe è ora molto più comprensibile, configurabile e quindi più utile.
Ma questo non mi interessa!
„Quando creo un oggetto Article e chiamo save(), non voglio occuparmi del database, voglio semplicemente che venga salvato in quello che ho impostato nella configurazione.“
„Quando uso Logger, voglio semplicemente che il messaggio venga scritto, e non voglio preoccuparmi di dove. Che venga utilizzata l'impostazione globale.“
Queste sono osservazioni corrette.
Come esempio, mostreremo una classe che invia newsletter e registra come è andata:
Il Logger
migliorato, che non utilizza più la costante LOG_DIR
, richiede nel costruttore di
specificare il percorso del file. Come risolvere questo problema? La classe NewsletterDistributor
non si preoccupa
affatto di dove vengono scritti i messaggi, vuole solo scriverli.
La soluzione è di nuovo la Regola n. 1: fatti passare le dipendenze: tutti i dati di cui la classe ha bisogno, glieli passiamo.
Quindi significa che passiamo il percorso del log tramite il costruttore, che poi usiamo quando creiamo l'oggetto
Logger
?
Non così! Il percorso infatti non appartiene ai dati di cui la classe NewsletterDistributor
ha bisogno;
questi li necessita Logger
. Percepisci la differenza? La classe NewsletterDistributor
ha bisogno del
logger come tale. Quindi glielo passiamo:
Ora è chiaro dalle firme della classe NewsletterDistributor
che parte della sua funzionalità è anche il
logging. E il compito di sostituire il logger con un altro, ad esempio per i test, è del tutto banale. Inoltre, se il
costruttore della classe Logger
dovesse cambiare, ciò non avrebbe alcun impatto sulla nostra classe.
Regola n. 2: prendi ciò che è tuo
Non lasciarti confondere e non farti passare le dipendenze delle tue dipendenze. Fatti passare solo le tue dipendenze.
Grazie a ciò, il codice che utilizza altri oggetti sarà completamente indipendente dalle modifiche ai loro costruttori. La sua API sarà più veritiera. E soprattutto, sarà banale sostituire queste dipendenze con altre.
Nuovo membro della famiglia
Nel team di sviluppo è stata presa la decisione di creare un secondo logger, che scrive nel database. Creeremo quindi la
classe DatabaseLogger
. Quindi abbiamo due classi, Logger
e DatabaseLogger
, una scrive su
file, l'altra nel database… non ti sembra ci sia qualcosa di strano in questa denominazione? Non sarebbe meglio rinominare
Logger
in FileLogger
? Certamente sì.
Ma lo faremo in modo intelligente. Sotto il nome originale, creeremo un'interfaccia:
… che entrambi i logger implementeranno:
E grazie a ciò, non sarà necessario cambiare nulla nel resto del codice dove viene utilizzato il logger. Ad esempio, il
costruttore della classe NewsletterDistributor
sarà ancora soddisfatto del fatto che come parametro richiede
Logger
. E starà solo a noi decidere quale istanza passargli.
Per questo motivo non diamo mai ai nomi delle interfacce il suffisso Interface
o il prefisso
I
. Altrimenti non sarebbe possibile sviluppare il codice in modo così elegante.
Houston, abbiamo un problema
Mentre in tutta l'applicazione possiamo accontentarci di una singola istanza del logger, sia esso basato su file o database, e
semplicemente passarlo ovunque si registri qualcosa, la situazione è completamente diversa nel caso della classe
Article
. Le sue istanze, infatti, le creiamo secondo necessità, anche più volte. Come gestire la dipendenza dal
database nel suo costruttore?
Come esempio può servire un controller che, dopo l'invio di un form, deve salvare l'articolo nel database:
Una possibile soluzione si offre direttamente: ci facciamo passare l'oggetto database tramite il costruttore in
EditController
e usiamo $article = new Article($this->db)
.
Proprio come nel caso precedente con Logger
e il percorso del file, questa non è la procedura corretta. Il
database non è una dipendenza di EditController
, ma di Article
. Passare il database va quindi contro la
Regola n. 2: prendi ciò che è tuo. Se il costruttore della classe
Article
cambia (viene aggiunto un nuovo parametro), sarà necessario modificare anche il codice in tutti i punti in
cui viene creata un'istanza. Ufff.
Houston, cosa suggerisci?
Regola n. 3: lascia fare alla factory
Eliminando le dipendenze nascoste e passando tutte le dipendenze come argomenti, abbiamo ottenuto classi più configurabili e flessibili. E quindi abbiamo bisogno di qualcos'altro che crei e configuri per noi queste classi più flessibili. Lo chiameremo factory.
La regola è: se una classe ha dipendenze, lascia la creazione delle sue istanze a una factory.
Le factory sono un sostituto più intelligente dell'operatore new
nel mondo della dependency injection.
Per favore, non confondete con il design pattern factory method, che descrive un modo specifico di utilizzare le factory e non è correlato a questo argomento.
Factory
Una factory è un metodo o una classe che produce e configura oggetti. La classe che produce Article
la
chiameremo ArticleFactory
e potrebbe assomigliare ad esempio a questo:
Il suo utilizzo nel controller sarà il seguente:
Se in questo momento la firma del costruttore della classe Article
cambia, l'unica parte del codice che deve
reagire è la factory stessa ArticleFactory
. Tutto il resto del codice che lavora con gli oggetti
Article
, come ad esempio EditController
, non ne sarà minimamente influenzato.
Forse ora ti stai chiedendo se ci siamo davvero aiutati. La quantità di codice è aumentata e tutto inizia a sembrare sospettosamente complicato.
Non preoccuparti, tra poco arriveremo al Nette DI container. E quello ha molti assi nella manica che semplificheranno
enormemente la costruzione di applicazioni che utilizzano la dependency injection. Ad esempio, invece della classe
ArticleFactory
, basterà scrivere una semplice
interfaccia:
Ma stiamo anticipando, resisti ancora un po' :-)
Riepilogo
All'inizio di questo capitolo, abbiamo promesso di mostrarvi un metodo per progettare codice pulito. Basta alle classi
- passare le dipendenze di cui hanno bisogno
- e al contrario non passare ciò di cui non hanno direttamente bisogno
- e che gli oggetti con dipendenze si producono meglio nelle factory
Potrebbe non sembrare così a prima vista, ma queste tre regole hanno conseguenze di vasta portata. Portano a una visione radicalmente diversa della progettazione del codice. Ne vale la pena? I programmatori che hanno abbandonato le vecchie abitudini e hanno iniziato a usare costantemente la dependency injection considerano questo passo un momento fondamentale nella loro vita professionale. Si è aperto loro il mondo delle applicazioni chiare e manutenibili.
Ma cosa succede se il codice non utilizza costantemente la dependency injection? Cosa succede se è basato su metodi statici o singleton? Porta a qualche problema? Sì, e molto fondamentali.