Cos'è l'iniezione di dipendenza?
Questo capitolo introduce le pratiche di programmazione di base da seguire nella scrittura di qualsiasi applicazione. Si tratta dei fondamenti necessari per scrivere codice pulito, comprensibile e manutenibile.
Se imparate e seguite queste regole, Nette vi assisterà in ogni momento. Gestirà per voi le attività di routine e vi offrirà il massimo comfort, in modo che possiate concentrarvi sulla logica.
I principi che illustreremo qui sono piuttosto semplici. Non dovete preoccuparvi di nulla.
Ricordate il vostro primo programma?
Non sappiamo in quale linguaggio sia stato scritto, ma se si tratta di PHP, potrebbe avere un aspetto simile a questo:
function addition(float $a, float $b): float
{
return $a + $b;
}
echo addition(23, 1); // francobollo 24
Poche righe di codice banali, ma con tanti concetti chiave nascosti. Che ci sono variabili. Che il codice è suddiviso in unità più piccole, come ad esempio le funzioni. Che si passano loro degli argomenti in ingresso e che restituiscono dei risultati. Mancano solo le condizioni e i cicli.
Il fatto che una funzione prenda dei dati in ingresso e restituisca un risultato è un concetto perfettamente comprensibile, utilizzato anche in altri campi, come la matematica.
Una funzione ha una firma, che consiste nel suo nome, in un elenco di parametri e dei loro tipi e, infine, nel tipo del valore di ritorno. Come utenti, siamo interessati alla firma e di solito non abbiamo bisogno di sapere nulla dell'implementazione interna.
Immaginiamo ora che la firma della funzione abbia questo aspetto:
function addition(float $x): float
Un'aggiunta con un solo parametro? È strano… Che ne dite di questo?
function addition(): float
È davvero strano, vero? Come viene utilizzata la funzione?
echo addition(); // cosa stampa?
Guardando questo codice, saremmo confusi. Non solo un principiante non lo capirebbe, ma anche un programmatore esperto non lo capirebbe.
Vi state chiedendo che aspetto avrebbe una funzione del genere? Dove troverebbe i sommatori? Probabilmente li otterrebbe in qualche modo da sola, forse in questo modo:
function addition(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Si scopre che nel corpo della funzione ci sono legami nascosti con altre funzioni (o metodi statici) e per scoprire da dove provengono effettivamente gli addendi, dobbiamo scavare ulteriormente.
Non in questo modo!
Il design appena mostrato è l'essenza di molte caratteristiche negative:
- la firma della funzione fingeva di non aver bisogno dei sommatori, il che ci confondeva
- non abbiamo idea di come far calcolare la funzione con altri due numeri
- abbiamo dovuto esaminare il codice per scoprire da dove provenissero i sommatori
- abbiamo trovato dipendenze nascoste
- una comprensione completa richiede l'esame anche di queste dipendenze
E il compito della funzione di addizione è anche quello di procurarsi gli input? Ovviamente no. La sua responsabilità è solo quella di aggiungere.
Non vogliamo incontrare codice di questo tipo e certamente non vogliamo scriverlo. Il rimedio è semplice: tornare alle origini e usare solo i parametri:
function addition(float $a, float $b): float
{
return $a + $b;
}
Regola n. 1: Lascia che ti venga passato
La regola più importante è: tutti i dati di cui hanno bisogno le funzioni o le classi devono essere passati a loro.
Invece di inventare modi nascosti per accedere ai dati, basta passare i parametri. Si risparmierà tempo che sarebbe stato speso per inventare percorsi nascosti che certamente non miglioreranno il codice.
Se si segue sempre e ovunque questa regola, si è sulla strada per un codice senza dipendenze nascoste. Un codice comprensibile non solo per l'autore, ma anche per chiunque lo legga in seguito. Dove tutto è comprensibile dalle firme delle funzioni e delle classi e non c'è bisogno di cercare segreti nascosti nell'implementazione.
Questa tecnica è chiamata professionalmente dependency injection. E questi dati sono chiamati dipendenze. È solo un normale passaggio di parametri, niente di più.
Non bisogna confondere l'iniezione di dipendenze, che è un modello di progettazione, con un “contenitore di iniezione di dipendenze”, che è uno strumento, qualcosa di diametralmente diverso. Ci occuperemo dei contenitori più avanti.
Dalle funzioni alle classi
E come sono collegate le classi? Una classe è un'unità più complessa di una semplice funzione, ma la regola n. 1 si applica interamente anche in questo caso. Ci sono solo più modi per passare gli argomenti. Per esempio, in modo del tutto simile al caso di una funzione:
class Math
{
public function addition(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->addition(23, 1); // 24
Oppure attraverso altri metodi o direttamente attraverso il costruttore:
class Addition
{
public function __construct(
private float $a,
private float $b,
) {
}
public function calculate(): float
{
return $this->a + $this->b;
}
}
$addition = new Addition(23, 1);
echo $addition->calculate(); // 24
Entrambi gli esempi sono completamente conformi alla dependency injection.
Esempi reali
Nel mondo reale, non scriverete classi per l'aggiunta di numeri. Passiamo agli esempi pratici.
Abbiamo una classe Article
che rappresenta un post del blog:
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// salvare l'articolo nel database
}
}
e l'utilizzo sarà il seguente:
$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()
salva l'articolo in una tabella del database. Implementarlo utilizzando Nette Database sarà un gioco da ragazzi, se non fosse per un problema: dove
Article
ottiene la connessione al database, cioè un oggetto della classe Nette\Database\Connection
?
Sembra che ci siano molte opzioni. Può prenderla da una variabile statica da qualche parte. Oppure ereditare da una classe che fornisce una connessione al database. Oppure sfruttare un singleton. Oppure utilizzare le cosiddette facciate, utilizzate 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],
);
}
}
Bene, abbiamo risolto il problema.
O forse sì?
Ricordiamo la regola numero 1: Let It Be Passed to You: tutte le dipendenze di cui la classe ha bisogno devono essere passate ad essa. Perché se infrangiamo questa regola, abbiamo intrapreso la strada del codice sporco, pieno di dipendenze nascoste, incomprensibile e il risultato sarà un'applicazione dolorosa da mantenere e sviluppare.
L'utente della classe Article
non ha idea di dove il metodo save()
memorizzi l'articolo. In una
tabella del database? Quale, quella di produzione o quella di test? E come può essere modificata?
L'utente deve guardare a come è implementato il metodo save()
e trova l'uso del metodo DB::insert()
.
Quindi, deve cercare ulteriormente per scoprire come questo metodo ottiene una 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, facciate di Laravel o variabili statiche. Nel codice pulito e ben progettato, gli argomenti vengono passati:
class Article
{
public function save(Nette\Database\Connection $db): void
{
$db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Un approccio ancora più pratico, come vedremo in seguito, sarà quello del 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, potreste pensare che Article
non dovrebbe avere un metodo
save()
; dovrebbe rappresentare un componente puramente di dati e un repository separato dovrebbe occuparsi del
salvataggio. Questo ha senso. Ma questo ci porterebbe ben oltre lo scopo dell'argomento, che è l'iniezione di dipendenza, e lo
sforzo di fornire semplici esempi.
Se scrivete una classe che richiede, per esempio, un database per il suo funzionamento, non inventate dove prenderlo, ma fatelo passare. Come parametro del costruttore o di un altro metodo. Ammettete le dipendenze. Ammettetele nell'API della vostra classe. Otterrete un codice comprensibile e prevedibile.
E che dire di questa classe, che registra i messaggi di errore:
class Logger
{
public function log(string $message)
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Cosa ne pensate, abbiamo rispettato la regola n. 1: lascia che ti venga passato?
Non l'abbiamo fatto.
L'informazione chiave, cioè la directory con il file di log, viene ottenuta dalla classe stessa dalla costante.
Guardate l'esempio di utilizzo:
$logger = new Logger;
$logger->log('The temperature is 23 °C');
$logger->log('The temperature is 10 °C');
Senza conoscere l'implementazione, potreste rispondere alla domanda su dove vengono scritti i messaggi? Si potrebbe ipotizzare
che l'esistenza della costante LOG_DIR
sia necessaria per il suo funzionamento? E si potrebbe creare una seconda
istanza che scriva in una posizione diversa? Certamente no.
Correggiamo 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('The temperature is 15 °C');
Ma non mi interessa!
“Quando creo un oggetto Articolo e chiamo save(), non voglio avere a che fare con il database; voglio solo che sia salvato in quello che ho impostato nella configurazione.”
“Quando uso Logger, voglio solo che il messaggio venga scritto e non voglio occuparmi di dove. Lasciate che vengano utilizzate le impostazioni globali.”
Questi sono punti validi.
A titolo di esempio, analizziamo una classe che invia newsletter e registra come è andata:
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 the sending');
throw $e;
}
}
}
La versione migliorata di Logger
, che non utilizza più la costante LOG_DIR
, richiede di specificare
il percorso del file nel costruttore. Come risolvere questo problema? Alla classe NewsletterDistributor
non interessa
dove vengono scritti i messaggi, vuole solo scriverli.
La soluzione è ancora una volta la regola n. 1: Let It Be Passed to You: passare tutti i dati di cui la classe ha bisogno.
Questo significa che passiamo il percorso del log attraverso il costruttore, che poi usiamo quando creiamo l'oggetto
Logger
?
class NewsletterDistributor
{
public function __construct(
private string $file, // NON IN QUESTO MODO!
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
No, non così! Il percorso non fa parte dei dati di cui ha bisogno la classe NewsletterDistributor
; infatti, la
classe Logger
ne ha bisogno. Vedete la differenza? La classe NewsletterDistributor
ha bisogno del logger
stesso. Quindi è questo che passeremo:
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 the sending');
throw $e;
}
}
}
Ora è chiaro dalle firme della classe NewsletterDistributor
che anche il logging fa parte delle sue
funzionalità. E il compito di scambiare il logger con un altro, magari per i test, è del tutto banale. Inoltre, se il
costruttore della classe Logger
cambia, questo non influisce sulla nostra classe.
Regola n. 2: prendere ciò che è vostro
Non fatevi ingannare e non lasciate passare le dipendenze delle vostre dipendenze. Passate solo le vostre dipendenze.
In questo modo, 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.
Un nuovo membro della famiglia
Il team di sviluppo ha deciso di creare un secondo logger che scrive sul database. Abbiamo quindi creato una classe
DatabaseLogger
. Abbiamo quindi due classi, Logger
e DatabaseLogger
, una scrive su un file,
l'altra su un database… non vi sembra strano come nome? Non sarebbe meglio rinominare Logger
in
FileLogger
? Decisamente sì.
Ma facciamolo in modo intelligente. 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
// ...
Per questo motivo, non sarà necessario modificare nulla nel resto del codice in cui viene utilizzato il logger. Per esempio,
il costruttore della classe NewsletterDistributor
si accontenterà di richiedere Logger
come parametro.
E dipenderà da noi quale istanza passare.
Ecco perché non aggiungiamo mai il suffisso Interface
o il prefisso I
ai nomi delle
interfacce. Altrimenti, non sarebbe possibile sviluppare il codice in modo così gradevole.
Houston, abbiamo un problema
Mentre per l'intera applicazione si può utilizzare una singola istanza del logger, sia essa basata su file o su database, e
passarla semplicemente ogni volta che si deve registrare qualcosa, per la classe Article
è molto diverso. Creiamo le
sue istanze secondo le necessità, anche più volte. Come gestire la dipendenza dal database nel suo costruttore?
Un esempio può essere un controllore che deve salvare un articolo nel database dopo aver inviato un modulo:
class EditController extends Controller
{
public function formSubmitted($data)
{
$article = new Article(/* ... */);
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Una possibile soluzione è ovvia: passare l'oggetto database al costruttore EditController
e utilizzare
$article = new Article($this->db)
.
Come nel caso precedente con Logger
e il percorso del file, questo non è l'approccio giusto. Il database non è
una dipendenza di EditController
, ma di Article
. Passare il database va contro la regola n. 2: prendi ciò che è tuo. Se il costruttore della classe Article
cambia (viene aggiunto un nuovo parametro), sarà necessario modificare il codice ovunque vengano create le istanze. Ufff.
Houston, cosa suggerisci?
Regola n. 3: lasciare che se ne occupi la fabbrica
Eliminando le dipendenze nascoste e passando tutte le dipendenze come argomenti, abbiamo ottenuto classi più configurabili e flessibili. Pertanto, abbiamo bisogno di qualcos'altro per creare e configurare queste classi più flessibili. Lo chiameremo “fabbriche”.
La regola generale è: se una classe ha delle dipendenze, lasciare la creazione delle sue istanze al factory.
Le fabbriche sono un sostituto più intelligente dell'operatore new
nel mondo della dependency injection.
Non bisogna confondersi con il design pattern factory method, che descrive un modo specifico di usare i factory e non è correlato a questo argomento.
Fabbrica
Un factory è un metodo o una classe che crea e configura oggetti. Chiameremo la classe che produce Article
come
ArticleFactory
, 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 utilizzo nel controllore sarà il seguente:
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// lasciare che la fabbrica crei un 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 del codice che deve
reagire è la stessa ArticleFactory
. Tutto il resto del codice che lavora con gli oggetti Article
, come
ad esempio EditController
, non ne risentirà.
Ci si potrebbe chiedere se abbiamo effettivamente migliorato le cose. La quantità di codice è aumentata e tutto inizia a sembrare sospettosamente complicato.
Non preoccupatevi, presto arriveremo al contenitore Nette DI. Questo contenitore ha diversi assi nella manica, che
semplificheranno notevolmente la costruzione di applicazioni che utilizzano l'iniezione di dipendenze. Per esempio, invece della
classe ArticleFactory
, sarà sufficiente scrivere una
semplice interfaccia:
interface ArticleFactory
{
function create(): Article;
}
Ma stiamo correndo troppo; abbiate pazienza :-)
Riassunto
All'inizio di questo capitolo abbiamo promesso di mostrare un processo per progettare codice pulito. Tutto ciò che serve è che le classi:
- passino le dipendenze di cui hanno bisogno
- viceversa, non passino ciò di cui non hanno direttamente bisogno
- e che gli oggetti con dipendenze siano meglio creati nei factory
A prima vista, queste tre regole non sembrano avere conseguenze di vasta portata, ma portano a una prospettiva radicalmente diversa sulla progettazione del codice. Ne vale la pena? Gli sviluppatori che hanno abbandonato le vecchie abitudini e hanno iniziato a usare coerentemente la dependency injection considerano questo passo un momento cruciale della loro vita professionale. Ha aperto loro il mondo delle applicazioni chiare e manutenibili.
Ma cosa succede se il codice non utilizza costantemente l'iniezione di dipendenza? E se si basa su metodi statici o singleton? Questo causa qualche problema? Sì, e sono molto importanti.