Ce este Injecția de dependență?

Acest capitol vă va prezenta practicile de programare de bază pe care trebuie să le urmați atunci când scrieți orice aplicație. Acestea sunt elementele fundamentale necesare pentru a scrie un cod curat, ușor de înțeles și de întreținut.

Dacă învățați și urmați aceste reguli, Nette vă va fi alături la fiecare pas. Se va ocupa de sarcinile de rutină în locul dumneavoastră și vă va oferi un confort maxim, astfel încât să vă puteți concentra asupra logicii propriu-zise.

Principiile pe care le vom arăta aici sunt destul de simple. Nu trebuie să vă faceți griji pentru nimic.

Vă amintiți primul program?

Nu știm în ce limbaj ați scris-o, dar dacă a fost PHP, ar fi putut arăta cam așa:

function addition(float $a, float $b): float
{
	return $a + $b;
}

echo addition(23, 1); // amprente 24

Câteva linii de cod banale, dar atât de multe concepte cheie ascunse în ele. Că există variabile. Că codul este împărțit în unități mai mici, care sunt funcții, de exemplu. Că le trecem argumente de intrare și că ele returnează rezultate. Tot ceea ce lipsește sunt condițiile și buclele.

Faptul că o funcție primește date de intrare și returnează un rezultat este un concept perfect inteligibil, care este utilizat și în alte domenii, cum ar fi matematica.

O funcție are o semnătură, care constă în numele său, o listă de parametri și tipurile acestora și, în final, tipul valorii de returnare. În calitate de utilizatori, suntem interesați de semnătură și, de obicei, nu trebuie să știm nimic despre implementarea internă.

Acum imaginați-vă că semnătura funcției arată astfel:

function addition(float $x): float

O adiție cu un singur parametru? Asta e ciudat… Ce zici de asta?

function addition(): float

Asta e foarte ciudat, nu? Cum este folosită funcția?

echo addition(); // ce imprimă?

Privind un astfel de cod, am fi confuzi. Nu numai că un începător nu l-ar înțelege, dar nici măcar un programator experimentat nu ar înțelege un astfel de cod.

Vă întrebați cum ar arăta de fapt o astfel de funcție în interior? De unde ar lua summenele? Probabil că le-ar obține într-un fel de la sine, poate în felul următor:

function addition(): float
{
	$a = Input::get('a');
	$b = Input::get('b');
	return $a + $b;
}

Se pare că există legături ascunse cu alte funcții (sau metode statice) în corpul funcției, iar pentru a afla de unde provin de fapt adunările, trebuie să săpăm mai departe.

Nu pe aici!

Designul pe care tocmai l-am arătat este esența multor caracteristici negative:

  • semnătura funcției se pretindea că nu are nevoie de sumanți, ceea ce ne-a derutat.
  • nu avem nicio idee cum să facem funcția să calculeze cu alte două numere
  • a trebuit să ne uităm în cod pentru a afla de unde provin sumatorii
  • am găsit dependențe ascunse
  • pentru o înțelegere completă este necesar să examinăm și aceste dependențe

Și chiar este treaba funcției de adunare să procure intrări? Bineînțeles că nu este. Responsabilitatea sa este doar de a adăuga.

Nu dorim să întâlnim un astfel de cod și cu siguranță nu dorim să-l scriem. Remediul este simplu: reveniți la elementele de bază și folosiți doar parametri:

function addition(float $a, float $b): float
{
	return $a + $b;
}

Regula nr. 1: Lăsați să vi se transmită

Cea mai importantă regulă este: toate datele de care au nevoie funcțiile sau clasele trebuie să le fie transmise.

În loc să inventați modalități ascunse de accesare a datelor de către ei înșiși, pur și simplu transmiteți parametrii. Veți economisi timp pe care l-ați petrece inventând căi ascunse care cu siguranță nu vă vor îmbunătăți codul.

Dacă urmați întotdeauna și peste tot această regulă, sunteți pe drumul către un cod fără dependențe ascunse. Spre un cod care este inteligibil nu numai pentru autor, ci și pentru oricine îl citește ulterior. În care totul este de înțeles din semnăturile funcțiilor și claselor și nu este nevoie să căutați secrete ascunse în implementare.

Această tehnică se numește în mod profesional injecție de dependență. Iar aceste date se numesc dependențe. Este doar o simplă trecere de parametri obișnuită, nimic mai mult.

Vă rugăm să nu confundați injectarea dependențelor, care este un model de proiectare, cu un “container de injectare a dependențelor”, care este un instrument, ceva diametral diferit. Ne vom ocupa de containere mai târziu.

De la funcții la clase

Și cum sunt legate clasele? O clasă este o unitate mai complexă decât o simplă funcție, dar regula nr. 1 se aplică în întregime și aici. Există doar mai multe moduri de a transmite argumente. De exemplu, destul de asemănător cu cazul unei funcții:

class Math
{
	public function addition(float $a, float $b): float
	{
		return $a + $b;
	}
}

$math = new Math;
echo $math->addition(23, 1); // 24

Sau prin alte metode, sau direct prin constructor:

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

Ambele exemple sunt în deplină conformitate cu injecția de dependență.

Exemple din viața reală

În lumea reală, nu veți scrie cursuri pentru adunarea numerelor. Să trecem la exemple practice.

Să avem o clasă Article care reprezintă o postare pe blog:

class Article
{
	public int $id;
	public string $title;
	public string $content;

	public function save(): void
	{
		// salvați articolul în baza de date
	}
}

iar utilizarea va fi următoarea:

$article = new Article;
$article->title = '10 Things You Need to Know About Losing Weight';
$article->content = 'Every year millions of people in ...';
$article->save();

Metoda save() va salva articolul într-un tabel din baza de date. Implementarea acesteia folosind Nette Database va fi floare la ureche, dacă nu ar exista o singură problemă: de unde obține Article conexiunea la baza de date, adică un obiect din clasa Nette\Database\Connection?

Se pare că avem o mulțime de opțiuni. Poate să o ia de la o variabilă statică undeva. Sau să moștenească dintr-o clasă care oferă o conexiune la baza de date. Sau să profite de un singleton. Sau să folosească așa-numitele facade, care sunt folosite în 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],
		);
	}
}

Minunat, am rezolvat problema.

Sau am făcut-o?

Să ne amintim regula nr. 1: Să ți se transmită: toate dependențele de care clasa are nevoie trebuie să îi fie transmise. Pentru că, dacă încălcăm regula, am pornit pe drumul spre un cod murdar, plin de dependențe ascunse, incomprehensibil, iar rezultatul va fi o aplicație care va fi dureros de întreținut și dezvoltat.

Utilizatorul clasei Article nu are nicio idee despre locul în care metoda save() stochează articolul. Într-un tabel din baza de date? În care, în cea de producție sau în cea de testare? Și cum poate fi modificat?

Utilizatorul trebuie să se uite la modul în care este implementată metoda save() și găsește utilizarea metodei DB::insert(). Deci, el trebuie să caute mai departe pentru a afla cum obține această metodă o conexiune la baza de date. Iar dependențele ascunse pot forma un lanț destul de lung.

În codul curat și bine conceput, nu există niciodată dependențe ascunse, fațade Laravel sau variabile statice. În codul curat și bine conceput, argumentele sunt transmise:

class Article
{
	public function save(Nette\Database\Connection $db): void
	{
		$db->query('INSERT INTO articles', [
			'title' => $this->title,
			'content' => $this->content,
		]);
	}
}

O abordare și mai practică, după cum vom vedea mai târziu, va fi prin intermediul constructorului:

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,
		]);
	}
}

Dacă sunteți un programator experimentat, ați putea crede că Article nu ar trebui să aibă o metodă save(); ar trebui să reprezinte o componentă pur de date, iar un depozit separat ar trebui să se ocupe de salvare. Acest lucru are sens. Dar acest lucru ne-ar duce cu mult dincolo de scopul subiectului, care este injectarea dependențelor, și de efortul de a oferi exemple simple.

Dacă scrieți o clasă care are nevoie, de exemplu, de o bază de date pentru funcționarea sa, nu inventați de unde să o luați, ci să o aveți trecută. Fie ca parametru al constructorului, fie ca parametru al unei alte metode. Admiteți dependențele. Admiteți-le în API-ul clasei dumneavoastră. Veți obține un cod ușor de înțeles și previzibil.

Și cum rămâne cu această clasă, care înregistrează mesaje de eroare:

class Logger
{
	public function log(string $message)
	{
		$file = LOG_DIR . '/log.txt';
		file_put_contents($file, $message . "\n", FILE_APPEND);
	}
}

Ce părere aveți, am respectat regula #1: Lăsați să vi se transmită?

Nu am respectat-o.

Informația cheie, și anume directorul cu fișierul jurnal, este obținută de clasa însăși din constantă.

Priviți exemplul de utilizare:

$logger = new Logger;
$logger->log('The temperature is 23 °C');
$logger->log('The temperature is 10 °C');

Fără a cunoaște implementarea, ați putea răspunde la întrebarea unde sunt scrise mesajele? Ați putea ghici că existența constantei LOG_DIR este necesară pentru funcționarea sa? Și ați putea crea o a doua instanță care să scrie într-o altă locație? Cu siguranță că nu.

Haideți să reparăm clasa:

class Logger
{
	public function __construct(
		private string $file,
	) {
	}

	public function log(string $message): void
	{
		file_put_contents($this->file, $message . "\n", FILE_APPEND);
	}
}

Clasa este acum mult mai ușor de înțeles, mai ușor de configurat și, prin urmare, mai utilă.

$logger = new Logger('/path/to/log.txt');
$logger->log('The temperature is 15 °C');

Dar nu-mi pasă!

“Când creez un obiect articol și apelez la save(), nu vreau să am de-a face cu baza de date; vreau doar să fie salvat în cea pe care am stabilit-o în configurație.”

“Când folosesc Logger, vreau doar ca mesajul să fie scris și nu vreau să mă ocup de locul în care este scris. Să se folosească setările globale. ”

Acestea sunt puncte valide.

Ca exemplu, să ne uităm la o clasă care trimite buletine informative și înregistrează cum a decurs:

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;
		}
	}
}

Varianta îmbunătățită Logger, care nu mai utilizează constanta LOG_DIR, necesită specificarea căii de acces la fișier în constructor. Cum se poate rezolva acest lucru? Clasei NewsletterDistributor nu-i pasă unde sunt scrise mesajele; ea vrea doar să le scrie.

Soluția este din nou regula nr. 1: Lasă să ți se transmită: transmite toate datele de care clasa are nevoie.

Asta înseamnă că trebuie să transmitem calea către jurnal prin constructor, pe care o folosim apoi la crearea obiectului Logger?

class NewsletterDistributor
{
	public function __construct(
		private string $file, // ⛔ NU ÎN ACEST FEL!
	) {
	}

	public function distribute(): void
	{
		$logger = new Logger($this->file);

Nu, nu așa! Calea nu face parte dintre datele de care are nevoie clasa NewsletterDistributor; de fapt, Logger are nevoie de ea. Vedeți care este diferența? Clasa NewsletterDistributor are nevoie de loggerul însuși. Așa că asta este ceea ce vom trece:

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;
		}
	}
}

Din semnăturile clasei NewsletterDistributor reiese clar că și jurnalizarea face parte din funcționalitatea sa. Iar sarcina de a schimba loggerul cu un altul, poate pentru testare, este complet trivială. În plus, dacă se schimbă constructorul clasei Logger, acest lucru nu va afecta clasa noastră.

Regula nr. 2: Luați ceea ce este al dumneavoastră

Nu vă lăsați păcăliți și nu vă lăsați să treceți peste dependențele dependențelor voastre. Treceți-vă doar propriile dependențe.

Datorită acestui lucru, codul care utilizează alte obiecte va fi complet independent de modificările din constructorii acestora. API-ul său va fi mai veridic. Și, mai presus de toate, va fi trivial să înlocuiți aceste dependențe cu altele.

Un nou membru al familiei

Echipa de dezvoltare a decis să creeze un al doilea logger care să scrie în baza de date. Așa că am creat o clasă DatabaseLogger. Deci avem două clase, Logger și DatabaseLogger, una scrie într-un fișier, cealaltă într-o bază de date … nu vi se pare ciudată denumirea? Nu ar fi mai bine să redenumim Logger în FileLogger? Categoric da.

Dar haideți să o facem în mod inteligent. Creăm o interfață sub numele original:

interface Logger
{
	function log(string $message): void;
}

… pe care le vor pune în aplicare ambele loguri:

class FileLogger implements Logger
// ...

class DatabaseLogger implements Logger
// ...

Și, din acest motiv, nu va fi nevoie să se schimbe nimic în restul codului în care este utilizat loggerul. De exemplu, constructorul clasei NewsletterDistributor se va mulțumi în continuare să solicite Logger ca parametru. Și va fi la latitudinea noastră ce instanță vom trece.

De aceea nu adăugăm niciodată sufixul Interface sau prefixul I la numele interfețelor. Altfel, nu ar fi posibilă dezvoltarea atât de frumoasă a codului.

Houston, avem o problemă

În timp ce ne putem descurca cu o singură instanță a logger-ului, fie că este bazat pe fișier sau pe bază de date, în întreaga aplicație și pur și simplu să o trecem oriunde este înregistrat ceva, este cu totul altceva pentru clasa Article. Creăm instanțele sale după cum este necesar, chiar și de mai multe ori. Cum să tratăm dependența de baza de date în constructorul său?

Un exemplu poate fi un controler care ar trebui să salveze un articol în baza de date după trimiterea unui formular:

class EditController extends Controller
{
	public function formSubmitted($data)
	{
		$article = new Article(/* ... */);
		$article->title = $data->title;
		$article->content = $data->content;
		$article->save();
	}
}

O posibilă soluție este evidentă: treceți obiectul bazei de date la constructorul EditController și utilizați $article = new Article($this->db).

La fel ca în cazul precedent cu Logger și calea de acces la fișier, aceasta nu este abordarea corectă. Baza de date nu este o dependență a EditController, ci a Article. Transmiterea bazei de date contravine regulii nr. 2: ia ceea ce este al tău. Dacă se modifică constructorul clasei Article (se adaugă un nou parametru), va trebui să modificați codul acolo unde sunt create instanțe. Ufff.

Houston, ce sugerezi?

Regula nr. 3: Lăsați fabrica să se ocupe de asta

Prin eliminarea dependențelor ascunse și prin transmiterea tuturor dependențelor ca argumente, am obținut clase mai configurabile și mai flexibile. Și, prin urmare, avem nevoie de altceva pentru a crea și configura aceste clase mai flexibile pentru noi. Îl vom numi fabrici.

Regula de bază este: dacă o clasă are dependențe, lăsați crearea instanțelor acestora în seama fabricii.

Fabricile sunt un înlocuitor mai inteligent pentru operatorul new în lumea injecției de dependență.

Vă rugăm să nu faceți confuzie cu modelul de proiectare factory method, care descrie un mod specific de utilizare a fabricilor și nu are legătură cu acest subiect.

Fabrica

O fabrică este o metodă sau o clasă care creează și configurează obiecte. Vom numi clasa care produce Article ca ArticleFactory, iar aceasta ar putea arăta astfel:

class ArticleFactory
{
	public function __construct(
		private Nette\Database\Connection $db,
	) {
	}

	public function create(): Article
	{
		return new Article($this->db);
	}
}

Utilizarea sa în controler va fi următoarea:

class EditController extends Controller
{
	public function __construct(
		private ArticleFactory $articleFactory,
	) {
	}

	public function formSubmitted($data)
	{
		// permiteți fabricii să creeze un obiect
		$article = $this->articleFactory->create();
		$article->title = $data->title;
		$article->content = $data->content;
		$article->save();
	}
}

În acest moment, dacă semnătura constructorului clasei Article se schimbă, singura parte a codului care trebuie să reacționeze este ArticleFactory. Toate celelalte coduri care lucrează cu obiectele Article, cum ar fi EditController, nu vor fi afectate.

S-ar putea să vă întrebați dacă am îmbunătățit de fapt lucrurile. Cantitatea de cod a crescut și totul începe să pară suspect de complicat.

Nu vă faceți griji, în curând vom ajunge la containerul Nette DI. Iar acesta are câteva trucuri în mânecă, care vor simplifica foarte mult construirea de aplicații folosind injecția de dependență. De exemplu, în loc de clasa ArticleFactory, va trebui să scrieți doar o interfață simplă:

interface ArticleFactory
{
	function create(): Article;
}

Dar ne devansăm; vă rugăm să aveți răbdare :-)

Rezumat

La începutul acestui capitol, am promis să vă arătăm un proces de proiectare a unui cod curat. Tot ce este nevoie este ca clasele să:

La prima vedere, aceste trei reguli pot părea să nu aibă consecințe profunde, dar ele conduc la o perspectivă radical diferită asupra proiectării codului. Merită? Dezvoltatorii care au renunțat la vechile obiceiuri și au început să folosească în mod consecvent injecția de dependență consideră acest pas un moment crucial în viața lor profesională. Le-a deschis lumea aplicațiilor clare și ușor de întreținut.

Dar ce se întâmplă dacă codul nu folosește în mod consecvent injecția de dependență? Ce se întâmplă dacă se bazează pe metode statice sau singletoni? Cauzează asta probleme? Da, da, și unele foarte fundamentale.

versiune: 3.x