Che cos'è la dependency injection?
Questo capitolo presenta le pratiche di programmazione di base che dovreste seguire scrivendo qualsiasi applicazione. Sono i fondamenti necessari per scrivere codice pulito, comprensibile e manutenibile.
Se adotterete e seguirete queste regole, Nette vi sosterrà a ogni passo. Svolgerà per voi i compiti di routine e vi offrirà il massimo della comodità, permettendovi di concentrarvi sulla logica vera e propria.
I principi che mostreremo qui sono piuttosto semplici. Non c'è nulla da temere.
Vi ricordate il vostro primo programma?
Non sappiamo in quale linguaggio l'avete scritto, ma se fosse stato PHP avrebbe probabilmente avuto più o meno questo aspetto:
function addition(float $a, float $b): float
{
return $a + $b;
}
echo addition(23, 1); // stampa 24
Poche righe di codice banali, eppure nascondono tanti concetti chiave. Che esistono le variabili. Che il codice è diviso in unità più piccole, come le funzioni. Che passiamo loro argomenti in ingresso ed esse restituiscono risultati. Mancano solo le condizioni e i cicli.
Il fatto che passiamo dati in ingresso a una funzione e che essa restituisca un risultato è un concetto perfettamente comprensibile, usato anche in altri campi, come la matematica.
Una funzione ha la propria firma, composta dal nome, dall'elenco dei parametri e dei loro tipi e, infine, dal tipo del valore di ritorno. A noi utenti interessa la firma; di norma non abbiamo bisogno di sapere nulla dell'implementazione interna.
Immaginate ora che la firma della funzione fosse questa:
function addition(float $x): float
Una somma con un solo parametro? Strano… E questa?
function addition(): float
Ora è davvero strano, no? Come si usa la funzione?
echo addition(); // cosa stamperà?
Guardando un codice del genere resteremmo perplessi. Non solo un principiante non lo capirebbe: nemmeno un programmatore esperto capirebbe un codice così.
Vi state chiedendo che aspetto avrebbe una funzione del genere all'interno? Da dove prenderebbe i numeri da sommare? Probabilmente se li procurerebbe in qualche modo da sola, magari così:
function addition(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Nel corpo della funzione abbiamo scoperto dipendenze nascoste da altre funzioni globali o da metodi statici. Per scoprire da dove arrivino davvero i numeri dobbiamo indagare oltre.
Non così!
Il progetto che abbiamo appena mostrato è l'essenza di molte caratteristiche negative:
- La firma della funzione fingeva di non aver bisogno dei numeri da sommare, il che ci ha confusi.
- Non abbiamo idea di come far sommare alla funzione due numeri diversi.
- Abbiamo dovuto guardare nel codice per scoprire da dove prende i numeri.
- Abbiamo scoperto dipendenze nascoste.
- Per capire tutto bisogna esaminare anche queste dipendenze.
Ed è davvero compito della funzione di somma procurarsi gli input? Certo che no. La sua responsabilità è solo la somma.
Non vogliamo incontrare codice del genere, e di sicuro non vogliamo scriverlo. La soluzione è semplice: tornare alle basi e usare semplicemente i parametri:
function addition(float $a, float $b): float
{
return $a + $b;
}
Regola n. 1: fatevelo passare
La regola più importante è: tutti i dati di cui le funzioni o le classi hanno bisogno devono essere forniti loro.
Invece di inventare modi nascosti perché se li procurino da sole, fornite semplicemente i parametri. Risparmierete il tempo speso a inventare percorsi nascosti che di sicuro non miglioreranno il vostro codice.
Se seguite sempre e ovunque questa regola, siete sulla strada di un codice senza dipendenze nascoste. Di un codice comprensibile non solo all'autore, ma anche a chiunque lo legga in seguito. Dove tutto si capisce dalle firme delle funzioni e delle classi, e non serve cercare dettagli nascosti nell'implementazione.
Questa tecnica si chiama tecnicamente dependency injection. E i dati si chiamano dipendenze. È solo un semplice passaggio di parametri, nulla di più.
Non confondete la dependency injection, che è un design pattern, con il “container di dependency injection”, che è uno strumento, una cosa radicalmente diversa. Dei container parleremo più avanti.
Dalle funzioni alle classi
E come si applica tutto questo alle classi? Una classe è un'entità più complessa di una semplice funzione, ma anche qui la regola n. 1 vale in pieno. Ci sono solo più modi di passare gli argomenti. Per esempio, in modo abbastanza simile al caso della funzione:
class Math
{
public function sum(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->sum(23, 1); // 24
Oppure con altri metodi, o direttamente con il costruttore:
class Sum
{
public function __construct(
private float $a,
private float $b,
) {
}
public function calculate(): float
{
return $this->a + $this->b;
}
}
$sum = new Sum(23, 1);
echo $sum->calculate(); // 24
Entrambi gli esempi sono pienamente conformi alla dependency injection.
Esempi dalla vita reale
Nel mondo reale non scriverete classi per sommare numeri. Passiamo a esempi pratici.
Prendiamo una classe Article, che rappresenta un articolo di blog:
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// salva l'articolo nel database
}
}
e l'uso sarà questo:
$article = new Article;
$article->title = '10 Things You Need to Know About Losing Weight';
$article->content = 'Every year millions of people in ...';
$article->save();
Il metodo save() salverà l'articolo in una tabella del database. Implementarlo con Nette Database sarebbe semplice, se non fosse per un intoppo: da dove prende
Article la connessione al database, cioè un oggetto della classe Nette\Database\Connection?
Sembra che le possibilità siano molte. Potrebbe prenderla da una variabile statica. Oppure ereditando da una classe che fornisce la connessione al database. Oppure usare un singleton. Oppure le cosiddette facade, come si usano in Laravel:
use Illuminate\Support\Facades\DB;
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
DB::insert(
'INSERT INTO articles (title, content) VALUES (?, ?)',
[$this->title, $this->content],
);
}
}
Ottimo, abbiamo risolto il problema.
Oppure no?
Ricordiamo la Regola n. 1: fatevelo passare: tutte le dipendenze di cui la classe ha bisogno devono esserle passate. Perché se infrangiamo la regola, ci siamo incamminati verso un codice disordinato, pieno di dipendenze nascoste e poco chiaro, e il risultato sarà un'applicazione difficile da mantenere e da sviluppare.
Chi usa la classe Article non ha idea di dove il metodo save() salvi l'articolo. In una tabella di
database? Quale, quello di produzione o quello di test? E come si può cambiare?
Deve guardare come è implementato il metodo save() e trova l'uso del metodo DB::insert(). Deve
quindi indagare oltre per capire come questo metodo ottenga la connessione al database. E le dipendenze nascoste possono formare
una catena piuttosto lunga.
In un codice pulito e ben progettato non ci sono mai dipendenze nascoste, facade di Laravel o variabili statiche. In un codice pulito e ben progettato gli argomenti vengono forniti:
class Article
{
public function save(Nette\Database\Connection $db): void
{
$db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Ancora più pratico, come vedremo più avanti, è usare il costruttore:
class Article
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function save(): void
{
$this->db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Se siete programmatori esperti, penserete forse che Article non dovrebbe avere affatto un metodo
save(): dovrebbe rappresentare una pura struttura dati, e del salvataggio dovrebbe occuparsi un repository separato.
Ha senso. Ma ci porterebbe ben oltre l'ambito dell'argomento, che è la dependency injection, e dell'obiettivo di fornire esempi
semplici.
Se scrivete una classe che per funzionare ha bisogno, per esempio, di un database, non inventatevi da dove prenderlo, ma fatevelo passare. Magari come parametro del costruttore o di un altro metodo. Riconoscete le dipendenze. Riconoscetele nell'API della vostra classe. Otterrete un codice comprensibile e prevedibile.
E questa classe, che registra i messaggi di errore?
class Logger
{
public function log(string $message): void
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Cosa ne dite, abbiamo seguito la Regola n. 1: fatevelo passare?
No.
L'informazione chiave, la directory che contiene il file di log, se la procura la classe stessa da una costante.
Guardate l'esempio d'uso:
$logger = new Logger;
$logger->log('Temperature is 23 °C');
$logger->log('Temperature is 10 °C');
Senza conoscere l'implementazione, sapreste dire dove vengono scritti i messaggi? Vi verrebbe in mente che per funzionare è
necessaria l'esistenza della costante LOG_DIR? E sapreste creare una seconda istanza che scriva altrove? Di
certo no.
Sistemiamo la classe:
class Logger
{
public function __construct(
private string $file,
) {
}
public function log(string $message): void
{
file_put_contents($this->file, $message . "\n", FILE_APPEND);
}
}
La classe è ora molto più comprensibile, configurabile e quindi più utile.
$logger = new Logger('/path/to/log.txt');
$logger->log('Temperature is 15 °C');
Ma a me non interessa!
“Quando creo un oggetto Article e chiamo save(), non voglio occuparmi del database; voglio solo che venga salvato in quello che ho configurato.”
“Quando uso Logger, voglio solo che il messaggio venga scritto e non voglio occuparmi di dove. Che vengano usate le impostazioni globali.”
Sono obiezioni legittime.
Come esempio, mostriamo una classe che distribuisce newsletter e registra l'esito:
class NewsletterDistributor
{
public function distribute(): void
{
$logger = new Logger(/* ... */);
try {
$this->sendEmails();
$logger->log('Emails have been sent out');
} catch (Exception $e) {
$logger->log('An error occurred during sending');
throw $e;
}
}
}
Il Logger migliorato, che non usa più la costante LOG_DIR, richiede il percorso del file nel
costruttore. Come si risolve? La classe NewsletterDistributor non si occupa di dove vengano scritti i messaggi:
vuole solo registrarli.
La soluzione è di nuovo la Regola n. 1: fatevelo passare: passiamo tutti i dati di cui la classe ha bisogno.
Significa quindi che passiamo il percorso del log attraverso il costruttore, per usarlo poi nella creazione dell'oggetto
Logger?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ NON COSÌ!
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Non così! Perché il percorso non è un dato di cui ha bisogno la classe NewsletterDistributor: ne ha
bisogno il Logger. Cogliete la differenza? La classe NewsletterDistributor ha bisogno del logger stesso.
Passeremo quindi il logger stesso:
class NewsletterDistributor
{
public function __construct(
private Logger $logger, // ✅
) {
}
public function distribute(): void
{
try {
$this->sendEmails();
$this->logger->log('Emails have been sent out');
} catch (Exception $e) {
$this->logger->log('An error occurred during sending');
throw $e;
}
}
}
Ora dalla firma della classe NewsletterDistributor è chiaro che il logging fa parte delle sue funzioni. E
sostituire il logger con un altro, magari per i test, è del tutto immediato. Per di più, se il costruttore della classe
Logger cambiasse, non avrebbe alcun impatto sulla nostra classe.
Regola n. 2: prendete ciò che è vostro
Non fatevi confondere e non accettate le dipendenze delle vostre dipendenze. Accettate solo le vostre.
Grazie a questo, il codice che usa altri oggetti sarà completamente indipendente dalle modifiche ai loro costruttori. La sua API sarà più precisa. E soprattutto, sostituire queste dipendenze con altre sarà immediato.
Un nuovo membro della famiglia
Il team di sviluppo ha deciso di creare un secondo logger, che scrive nel database. Creiamo quindi una classe
DatabaseLogger. Ora abbiamo due classi, Logger e DatabaseLogger; una scrive su file,
l'altra nel database… la denominazione non sembra un po' strana? Non sarebbe meglio rinominare Logger in
FileLogger? Certamente.
Ma facciamolo con astuzia. Creiamo un'interfaccia con il nome originale:
interface Logger
{
function log(string $message): void;
}
… che entrambi i logger implementeranno:
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
E grazie a questo non ci sarà bisogno di modificare nulla nel resto del codice in cui il logger viene usato. Per esempio il
costruttore della classe NewsletterDistributor continuerà tranquillamente a richiedere Logger come
parametro. E sta a noi decidere quale istanza fornirgli.
Ecco perché non aggiungiamo mai il suffisso Interface o il prefisso I ai nomi delle
interfacce. Altrimenti non sarebbe possibile estendere il codice in modo così elegante.
Houston, abbiamo un problema
Mentre in tutta l'applicazione ce la caviamo con una sola istanza del logger, sia esso su file o su database, e ci basta
passarla ovunque avvenga il logging, la situazione è ben diversa con la classe Article. Ne creiamo istanze a seconda
della necessità, anche più volte. Come gestiamo la dipendenza dal database nel suo costruttore?
Un esempio può essere un controller che deve salvare un articolo nel database dopo l'invio di un form:
class EditController extends Controller
{
public function formSubmitted($data)
{
$article = new Article(/* ... */);
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Una possibile soluzione sembra ovvia: facciamoci passare l'oggetto database nel costruttore di EditController e
usiamo $article = new Article($this->db).
Come nel caso precedente di Logger e del percorso del file, non è l'approccio corretto. Il database non è una
dipendenza di EditController, ma di Article. Passare il database viola quindi la Regola n. 2: prendete ciò che è vostro. Se il costruttore della classe
Article cambia (viene aggiunto un nuovo parametro), dovrete modificare il codice in tutti i punti in cui vengono
create le istanze. Uff.
Houston, cosa proponi?
Regola n. 3: lasciate fare alla factory
Eliminando le dipendenze nascoste e passando tutte le dipendenze come argomenti, abbiamo ottenuto classi più configurabili e flessibili. Ci serve quindi qualcosa in più che crei e configuri per noi queste classi più flessibili. Le chiameremo factory.
La regola è: se una classe ha delle dipendenze, delegate la creazione delle sue istanze a una factory.
Le factory sono un'alternativa più intelligente all'operatore new nel mondo della dependency injection.
Non confondetela con il design pattern factory method, che descrive un modo specifico di usare le factory e non ha nulla a che vedere con questo argomento.
Factory
Una factory è un metodo o una classe che crea e configura oggetti. Chiameremo ArticleFactory la classe che
produce Article, e potrebbe avere questo aspetto:
class ArticleFactory
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function create(): Article
{
return new Article($this->db);
}
}
Il suo uso nel controller sarà questo:
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// lascia che sia la factory a creare l'oggetto
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
A questo punto, se la firma del costruttore della classe Article cambia, l'unica parte di codice che deve reagire
è la ArticleFactory stessa. Tutto il resto del codice che lavora con gli oggetti Article, come
EditController, resterà intatto.
Vi starete forse grattando la testa, chiedendovi se abbiamo davvero migliorato la situazione. La quantità di codice è cresciuta e il tutto comincia ad apparire sospettosamente complesso.
Niente paura, arriveremo presto al container DI di Nette. E ha diversi assi nella manica, che semplificheranno enormemente la
costruzione di applicazioni con la dependency injection. Per esempio, al posto della classe ArticleFactory basterà
scrivere solo un'interfaccia:
interface ArticleFactory
{
function create(): Article;
}
Ma stiamo correndo troppo, restate sintonizzati :-)
Riepilogo
All'inizio di questo capitolo avevamo promesso di mostrare un procedimento per progettare codice pulito. Basta assicurarsi che alle classi:
- vengano passate le dipendenze di cui hanno bisogno
- e, al contrario, non venga passato ciò di cui non hanno bisogno direttamente
- e che gli oggetti con dipendenze si creino al meglio nelle factory
A prima vista può non sembrare, ma queste tre regole hanno conseguenze di vasta portata. Portano a una prospettiva radicalmente diversa sulla progettazione del codice. Ne vale la pena? I programmatori che hanno abbandonato le vecchie abitudini e hanno iniziato a usare sistematicamente la dependency injection considerano questo passo un momento decisivo della propria carriera professionale. Ha aperto loro un mondo di applicazioni chiare e manutenibili.
E se il codice non usa sistematicamente la dependency injection? Se è costruito su metodi statici o su singleton? Questo porta a dei problemi? Sì, e molto seri.