Ce este Dependency Injection?

Acest capitol vă va introduce în practicile de programare de bază pe care ar trebui să le urmați atunci când scrieți toate aplicațiile. Acestea sunt elementele de bază necesare pentru a scrie cod curat, ușor de înțeles și de întreținut.

Dacă adoptați aceste reguli și le urmați, Nette vă va sprijini la fiecare pas. Se va ocupa de sarcinile de rutină pentru dvs. și vă va oferi confort maxim, astfel încât să vă puteți concentra pe logica în sine.

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 l-ați scris, dar dacă ar fi fost PHP, probabil ar fi arătat cam așa:

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

echo soucet(23, 1); // afișează 24

Câteva rânduri triviale de cod, dar conțin atât de multe concepte cheie. Că există variabile. Că codul este împărțit în unități mai mici, cum ar fi funcțiile. Că le transmitem argumente de intrare și ele returnează rezultate. Lipsesc doar condițiile și buclele.

Faptul că transmitem date de intrare unei funcții și aceasta returnează un rezultat este un concept perfect de înțeles, care este utilizat și în alte domenii, cum ar fi matematica.

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

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

function soucet(float $x): float

O sumă cu un singur parametru? Ciudat… Și ce ziceți de asta?

function soucet(): float

Asta e deja foarte ciudat, nu-i așa? Cum se folosește funcția?

echo soucet(); // ce va afișa oare?

Privind un astfel de cod, am fi confuzi. Nu numai că un începător nu l-ar înțelege, dar nici un programator experimentat nu î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 termenii? Probabil că i-ar obține într-un fel singură, poate așa:

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

În corpul funcției am descoperit legături ascunse către alte funcții globale sau metode statice. Pentru a afla de unde provin de fapt termenii, trebuie să investigăm mai departe.

Nu pe aici!

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

  • semnătura funcției pretindea că nu are nevoie de termeni, ceea ce ne-a indus în eroare
  • nu știm deloc cum să facem funcția să adune alte două numere
  • a trebuit să ne uităm în cod pentru a afla de unde ia termenii
  • am descoperit dependențe ascunse
  • pentru o înțelegere completă, este necesar să examinăm și aceste dependențe

Și este oare sarcina funcției de adunare să obțină intrări? Desigur că nu. Responsabilitatea sa este doar adunarea în sine.

Nu vrem să întâlnim un astfel de cod și cu siguranță nu vrem să-l scriem. Remedierea este simplă: revenirea la elementele de bază și pur și simplu folosirea parametrilor:

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

Regula nr. 1: Primește ce ai nevoie

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

În loc să inventați modalități ascunse prin care acestea ar putea ajunge cumva singure la ele, pur și simplu transmiteți parametrii. Veți economisi timp necesar pentru a inventa căi ascunse, care cu siguranță nu vă vor îmbunătăți codul.

Dacă veți respecta această regulă întotdeauna și peste tot, sunteți pe drumul către un cod fără dependențe ascunse. Către un cod care este de înțeles nu numai pentru autor, ci și pentru oricine îl va citi după el. Unde 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 tehnic dependency injection (injectarea dependențelor). Iar acele date se numesc dependențe. De fapt, este vorba de transmiterea obișnuită a parametrilor, nimic mai mult.

Vă rugăm să nu confundați dependency injection, care este un model de design (design pattern), cu „container DI”, care este un instrument, adică ceva diametral opus. Vom discuta despre containere mai târziu.

De la funcții la clase

Și cum se leagă clasele de asta? O clasă este o unitate mai complexă decât o funcție simplă, dar regula nr. 1 se aplică în totalitate și aici. Doar că există mai multe opțiuni pentru a pasa argumente. De exemplu, destul de similar cu cazul unei funcții:

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

$math = new Matematika;
echo $math->soucet(23, 1); // 24

Sau folosind alte metode, sau direct constructorul:

class Soucet
{
	public function __construct(
		private float $a,
		private float $b,
	) {
	}

	public function spocti(): float
	{
		return $this->a + $this->b;
	}

}

$soucet = new Soucet(23, 1);
echo $soucet->spocti(); // 24

Ambele exemple sunt pe deplin în concordanță cu dependency injection.

Exemple reale

În lumea reală, nu veți scrie clase pentru adunarea numerelor. Să trecem la exemple din practică.

Să avem o clasă Article care reprezintă un articol de blog:

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

	public function save(): void
	{
		// salvăm articolul în baza de date
	}
}

și 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() salvează articolul într-un tabel din baza de date. Implementarea acesteia cu ajutorul Nette Database ar fi o joacă de copil, dacă n-ar fi o mică problemă: de unde obține Article conexiunea la baza de date, adică obiectul clasei Nette\Database\Connection?

Se pare că avem multe opțiuni. Poate să o ia de undeva dintr-o variabilă statică. Sau să moștenească de la o clasă care asigură conexiunea la baza de date. Sau să utilizeze așa-numitul singleton. Sau așa-numitele facades, care sunt utilizate î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],
		);
	}
}

Excelent, am rezolvat problema.

Sau nu?

Să ne amintim Regula nr. 1: Primește ce ai nevoie: toate dependențele de care clasa are nevoie trebuie să-i fie transmise. Pentru că dacă încălcăm regula, am pornit pe calea către un cod murdar, plin de dependențe ascunse, neinteligibil, iar rezultatul va fi o aplicație pe care va fi dureros să o întreținem și să o dezvoltăm.

Utilizatorul clasei Article nu știe unde metoda save() salvează articolul. Într-un tabel din baza de date? În care, cel de producție sau cel de test? Și cum se poate schimba asta?

Utilizatorul trebuie să se uite cum este implementată metoda save() și găsește utilizarea metodei DB::insert(). Așa că trebuie să investigheze mai departe cum își obține această metodă conexiunea la baza de date. Iar dependențele ascunse pot forma un lanț destul de lung.

Într-un cod curat și bine proiectat nu există niciodată dependențe ascunse, facades Laravel sau variabile statice. Într-un cod curat și bine proiectat se transmit argumente:

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

Și mai practic, așa cum vom vedea mai departe, va fi prin constructor:

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, poate vă gândiți că Article nu ar trebui să aibă deloc metoda save(), ar trebui să reprezinte o componentă pură de date, iar de salvare ar trebui să se ocupe un repository separat. Asta are sens. Dar astfel am depăși cu mult subiectul dependency injection și efortul de a oferi exemple simple.

Dacă scrieți o clasă care necesită, de exemplu, o bază de date pentru funcționarea sa, nu vă gândiți de unde să o obțineți, ci lăsați să vă fie transmisă. De exemplu, ca parametru al constructorului sau al altei metode. Recunoașteți dependențele. Recunoașteți-le în API-ul clasei dvs. Veți obține un cod inteligibil și previzibil.

Și ce ziceți de această clasă, care loghează mesajele de eroare:

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

Ce credeți, am respectat Regula nr. 1: Primește ce ai nevoie?

Nu am respectat-o.

Informația cheie, adică directorul cu fișierul de log, clasa o obține singură dintr-o constantă.

Uitați-vă la exemplul de utilizare:

$logger = new Logger;
$logger->log('Temperatura este 23 °C');
$logger->log('Temperatura este 10 °C');

Fără a cunoaște implementarea, ați putea răspunde la întrebarea unde se scriu mesajele? V-ați fi gândit că pentru funcționare este necesară existența constantei LOG_DIR? Și ați putea crea o a doua instanță care să scrie în altă parte? Cu siguranță nu.

Să corectă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 inteligibilă, configurabilă și, prin urmare, mai utilă.

$logger = new Logger('/cale/catre/log.txt');
$logger->log('Temperatura este 15 °C');

Dar nu mă interesează!

„Când creez un obiect Article și apelez save(), nu vreau să mă ocup de baza de date, vreau doar să fie salvat în cea pe care o am setată în configurație.”

„Când folosesc Logger, vreau doar ca mesajul să fie scris și nu vreau să mă ocup de unde. Să se folosească setarea globală.”

Acestea sunt observații corecte.

Ca exemplu, vom arăta o clasă care distribuie newslettere și care loghează cum a decurs:

class NewsletterDistributor
{
	public function distribute(): void
	{
		$logger = new Logger(/* ... */);
		try {
			$this->sendEmails();
			$logger->log('E-mailurile au fost trimise');

		} catch (Exception $e) {
			$logger->log('A apărut o eroare la trimitere');
			throw $e;
		}
	}
}

Logger-ul îmbunătățit, care nu mai folosește constanta LOG_DIR, necesită specificarea căii către fișier în constructor. Cum rezolvăm asta? Clasa NewsletterDistributor nu este deloc interesată unde se scriu mesajele, vrea doar să le scrie.

Soluția este din nou Regula nr. 1: Primește ce ai nevoie: toate datele de care clasa are nevoie, i le transmitem.

Deci asta înseamnă că transmitem calea către log prin constructor, pe care apoi o folosim la crearea obiectului Logger?

class NewsletterDistributor
{
	public function __construct(
		private string $file, // ⛔ NU AȘA!
	) {
	}

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

Nu așa! Calea nu face parte din datele de care are nevoie clasa NewsletterDistributor; de acestea are nevoie Logger. Percepeți diferența? Clasa NewsletterDistributor are nevoie de logger ca atare. Așa că îl vom transmite pe acesta:

class NewsletterDistributor
{
	public function __construct(
		private Logger $logger, // ✅
	) {
	}

	public function distribute(): void
	{
		try {
			$this->sendEmails();
			$this->logger->log('E-mailurile au fost trimise');

		} catch (Exception $e) {
			$this->logger->log('A apărut o eroare la trimitere');
			throw $e;
		}
	}
}

Acum, din semnăturile clasei NewsletterDistributor este clar că logarea face parte din funcționalitatea sa. Iar sarcina de a înlocui loggerul cu altul, de exemplu pentru testare, este complet trivială. Mai mult, dacă constructorul clasei Logger s-ar schimba, acest lucru nu ar avea niciun impact asupra clasei noastre.

Regula nr. 2: Ia doar ce este al tău

Nu vă lăsați induși în eroare și nu vă lăsați să vi se transmită dependențele dependențelor voastre. Lăsați să vi se transmită doar dependențele voastre.

Datorită acestui fapt, codul care utilizează alte obiecte va fi complet independent de modificările constructorilor 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

În echipa de dezvoltare s-a decis crearea unui al doilea logger, care scrie în baza de date. Vom crea deci clasa DatabaseLogger. Așadar, avem două clase, Logger și DatabaseLogger, una scrie într-un fișier, cealaltă în baza de date… nu vi se pare ceva ciudat la această denumire? Nu ar fi mai bine să redenumim Logger în FileLogger? Cu siguranță da.

Dar o vom face inteligent. Sub numele original vom crea o interfață:

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

… pe care ambii loggeri o vor implementa:

class FileLogger implements Logger
// ...

class DatabaseLogger implements Logger
// ...

Și datorită acestui fapt, nu va fi nevoie să schimbăm nimic în restul codului unde se utilizează loggerul. De exemplu, constructorul clasei NewsletterDistributor va fi în continuare mulțumit că necesită Logger ca parametru. Și va depinde doar de noi ce instanță îi vom transmite.

De aceea nu adăugăm niciodată sufixul Interface sau prefixul I la numele interfețelor. Altfel nu ar fi posibil să dezvoltăm codul atât de frumos.

Houston, avem o problemă

În timp ce în întreaga aplicație ne putem descurca cu o singură instanță de logger, fie el de fișier sau de bază de date, și pur și simplu o transmitem oriunde se loghează ceva, situația este destul de diferită în cazul clasei Article. Instanțele sale le creăm după nevoie, chiar de mai multe ori. Cum să gestionăm dependența de baza de date în constructorul său?

Ca exemplu poate servi un controller care, după trimiterea unui formular, trebuie să salveze articolul în baza de date:

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 se oferă direct: lăsăm obiectul bazei de date să fie transmis prin constructor către EditController și folosim $article = new Article($this->db).

La fel ca în cazul anterior cu Logger și calea către fișier, aceasta nu este abordarea corectă. Baza de date nu este o dependență a EditController, ci a Article. Transmiterea bazei de date contravine deci Regulii nr. 2: Ia doar ce este al tău. Când se schimbă constructorul clasei Article (se adaugă un nou parametru), va fi necesar să se modifice și codul în toate locurile unde se creează instanțe. Ufff.

Houston, ce propui?

Regula nr. 3: Lasă pe seama fabricii

Prin eliminarea dependențelor ascunse și transmiterea tuturor dependențelor ca argumente, am obținut clase mai configurabile și mai flexibile. Și, prin urmare, avem nevoie de ceva în plus, care să ne creeze și să ne configureze acele clase mai flexibile. Le vom numi fabrici.

Regula este: dacă o clasă are dependențe, lăsați crearea instanțelor sale pe seama unei fabrici.

Fabricile sunt înlocuitori mai inteligenți ai operatorului new în lumea dependency injection.

Vă rugăm să nu confundați cu modelul de design (design pattern) 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 produce și configurează obiecte. Clasa care produce Article o vom numi ArticleFactory și ar putea arăta, de exemplu, astfel:

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

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

Utilizarea sa în controller va fi următoarea:

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

	public function formSubmitted($data)
	{
		// lăsăm fabrica să creeze obiectul
		$article = $this->articleFactory->create();
		$article->title = $data->title;
		$article->content = $data->content;
		$article->save();
	}
}

Dacă în acest moment se schimbă semnătura constructorului clasei Article, singura parte a codului care trebuie să reacționeze este însăși fabrica ArticleFactory. Tot restul codului care lucrează cu obiecte Article, cum ar fi EditController, nu va fi afectat în niciun fel.

Poate vă bateți acum capul dacă ne-am ajutat cu ceva. 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. Și acesta are o serie de ași în mânecă, care simplifică enorm construirea aplicațiilor care utilizează dependency injection. De exemplu, în loc de clasa ArticleFactory, va fi suficient să scrie doar o interfață:

interface ArticleFactory
{
	function create(): Article;
}

Dar anticipăm, mai aveți puțină răbdare :-)

Rezumat

La începutul acestui capitol am promis că vom arăta o metodă de a proiecta cod curat. Este suficient ca claselor

  1. să le transmitem dependențele de care au nevoie
  2. și, dimpotrivă, să nu le transmitem ceea ce nu au nevoie direct
  3. și că obiectele cu dependențe sunt cel mai bine create în fabrici

Poate nu pare așa la prima vedere, dar aceste trei reguli au consecințe de anvergură. Conduc la o perspectivă radical diferită asupra designului codului. Merită? Programatorii care au renunțat la vechile obiceiuri și au început să utilizeze consecvent dependency injection consideră acest pas un moment crucial în viața lor profesională. Li s-a deschis lumea aplicațiilor clare și ușor de întreținut.

Dar ce se întâmplă dacă codul nu utilizează consecvent dependency injection? Ce se întâmplă dacă este construit pe metode statice sau singleton-uri? Aduce asta probleme? Aduce și foarte fundamentale.

versiune: 3.x