Mi az a Dependency Injection?

Ez a fejezet bemutatja azokat az alapvető programozási gyakorlatokat, amelyeket bármilyen alkalmazás írása során követni kell. Ezek az alapok szükségesek ahhoz, hogy tiszta, érthető és karbantartható kódot írhassunk.

Ha megtanulod és betartod ezeket a szabályokat, a Nette minden lépésnél a segítségedre lesz. El fogja végezni helyetted a rutinfeladatokat, és a lehető legkényelmesebbé teszi, hogy magára a logikára koncentrálhass.

Az elvek, amelyeket itt bemutatunk, meglehetősen egyszerűek. Nincs miért aggódnia.

Emlékszel az első programodra?

Fogalmunk sincs, milyen nyelven írtad, de ha PHP volt, akkor valószínűleg valahogy így nézhetett ki:

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

echo osszeg(23, 1); // 24-et ír ki.

Néhány triviális kódsor, de nagyon sok kulcsfogalom rejtőzik benne. Hogy vannak változók. Hogy a kódot kisebb egységekre bontják, amelyek például függvények. Hogy bemeneti argumentumokat adunk át nekik, és eredményt adnak vissza. Csak a feltételek és a ciklusok hiányoznak.

Az, hogy bemeneti adatokat adunk át egy függvénynek, és az visszaad egy eredményt, egy tökéletesen érthető fogalom, amelyet más területeken, például a matematikában is használnak.

Egy függvénynek van egy szignatúrája, amely a nevéből, a paraméterek listájából és azok típusából, végül pedig a visszatérési érték típusából áll. Felhasználóként minket a szignatúra érdekel, a belső megvalósításról általában semmit sem kell tudnunk.

Most képzeljük el, hogy egy függvény szignója így néz ki:

function osszeg(float $x): float

Egy összeadás egy paraméterrel? Ez furcsa… Mit szólsz ehhez?

function osszeg(): float

Ez tényleg furcsa, nem? Mit gondolsz, hogyan használják a funkciót?

echo osszeg(); // mit ír ki?

Ha ilyen kódot nézünk, összezavarodunk. Nem csak egy kezdő nem értené, még egy gyakorlott programozó sem értené az ilyen kódot.

Vajon hogyan nézne ki egy ilyen függvény valójában belülről? Honnan szerezné meg az összeadókat? Valószínűleg valahogyan magától szerezné meg őket, például így:

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

Kiderült, hogy a függvény testében rejtett kötések vannak más függvényekhez (vagy statikus metódusokhoz), és ahhoz, hogy megtudjuk, honnan származnak az összeadók, tovább kell ásnunk.

Ne erre!

Az imént bemutatott dizájn számos negatív tulajdonság lényege:

  • a függvény aláírás úgy tett, mintha nem lenne szüksége addendumokra, ami összezavart minket.
  • fogalmunk sincs, hogyan lehetne a függvényt két másik számmal kiszámítani
  • bele kellett néznünk a kódba, hogy lássuk, hova veszi az addendeket
  • rejtett kötéseket fedeztünk fel
  • a teljes megértéshez ezeket a kötéseket is fel kell tárnunk.

És egyáltalán az összeadási függvény feladata a bemenetek beszerzése? Természetesen nem az. Az ő feladata csak az összeadás.

Ilyen kóddal nem akarunk találkozni, és biztosan nem akarjuk megírni. A megoldás egyszerű: térjünk vissza az alapokhoz, és használjunk csak paramétereket:

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

1. szabály: Hadd adassák át neked

A legfontosabb szabály: minden adatot, amelyre a függvényeknek vagy osztályoknak szükségük van, át kell adni nekik.

Ahelyett, hogy rejtett mechanizmusokat találnánk ki, hogy valahogyan maguk jussanak hozzá, egyszerűen adjuk át a paramétereket. Ezzel megspórolod a rejtett módok kitalálásához szükséges időt, ami biztosan nem javítja a kódodat.

Ha ezt a szabályt mindig és mindenhol betartod, akkor már úton vagy a rejtett kötések nélküli kód felé. Olyan kód felé, amely nemcsak a szerző számára érthető, hanem mindenki számára, aki utólag elolvassa. Ahol minden érthető a függvények és osztályok aláírásából, és nem kell rejtett titkokat keresni az implementációban.

Ezt a technikát szakszerűen függőségi injektálásnak nevezik. Az adatokat pedig függőségeknek hívják. De ez egy egyszerű paraméterátadás, semmi több.

Kérlek, ne keverd össze a függőségi injektálást, ami egy tervezési minta, a “függőségi injektáló konténerrel”, ami egy eszköz, valami teljesen más. A konténerekről később fogunk beszélni.

A függvényektől az osztályokig

És hogyan kapcsolódnak ehhez az osztályok? Egy osztály összetettebb entitás, mint egy egyszerű függvény, de az 1. szabály itt is érvényes. Csak több módja van az argumentumok átadásának. Például egészen hasonlóan, mint egy függvény esetében:

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

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

Vagy más metódusok használatával, vagy közvetlenül a konstruktor által:

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

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

}

$osszeg = new Osszeg(23, 1);
echo $osszeg->calculate(); // 24

Mindkét példa teljesen megfelel a függőségi injektálásnak.

Valós életbeli példák

A való világban nem fogsz osztályokat írni számok összeadására. Térjünk át a valós világbeli példákra.

Legyen egy Article osztályunk, amely egy blogcikket reprezentál:

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

	public function save(): void
	{
		// a cikk elmentése az adatbázisba
	}
}

és a használat a következő lesz:

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

A save() módszer elmenti a cikket egy adatbázis-táblába. A megvalósítás a Nette Database segítségével gyerekjáték lenne, ha nem lenne egy bökkenő: honnan szerezze meg a Article az adatbázis-kapcsolatot, azaz a Nette\Database\Connection osztály objektumát ?

Úgy tűnik, sok lehetőségünk van. Veheti valahonnan egy statikus változóból. Vagy örökli egy olyan osztályból, amelyik biztosítja az adatbázis-kapcsolatot. Vagy kihasználja egy singleton előnyeit. Vagy az úgynevezett facades-t, amit a Laravelben használnak:

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

Nagyszerű, megoldottuk a problémát.

Vagy mégis?

Emlékezzünk vissza az 1. szabályra: hadd adassák át neked: minden függőséget, amire az osztálynak szüksége van, át kell adni neki. Mert ha nem tesszük, és megszegjük a szabályt, akkor elindultunk a rejtett kötöttségekkel teli, piszkos kódhoz vezető úton, amely tele van rejtett kötöttségekkel, érthetetlenséggel, és az eredmény egy olyan alkalmazás lesz, amelynek a karbantartása és a fejlesztése csak kínszenvedés.

A Article osztály felhasználójának fogalma sincs, hogy a save() metódus hol tárolja a cikket. Egy adatbázis táblában? Melyikben, a termelési vagy a fejlesztési? És hogyan lehet ezt megváltoztatni?

A felhasználónak meg kell néznie, hogyan van implementálva a save() metódus, hogy megtalálja a DB::insert() metódus használatát. Tehát tovább kell keresnie, hogy megtudja, hogyan szerez ez a módszer adatbázis-kapcsolatot. A rejtett kötések pedig elég hosszú láncot alkothatnak.

A rejtett kötések, Laravel fakciók vagy statikus változók soha nincsenek jelen a tiszta, jól megtervezett kódban. A tiszta és jól megtervezett kódban az argumentumok átadásra kerülnek:

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

Még praktikusabb, ahogy azt a következőkben látni fogjuk, ha konstruktort használunk:

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

Ha tapasztalt programozó vagy, talán arra gondolsz, hogy a Article egyáltalán nem kellene, hogy legyen save() metódus, hanem egy tiszta adatkomponensnek kellene lennie, és egy külön tárolónak kellene gondoskodnia a tárolásról. Ennek van értelme. De ez jóval túlmutatna a témán, ami a függőségi injektálás, és megpróbálnánk egyszerű példákat adni.

Ha például olyan osztályt írsz, aminek a működéséhez adatbázisra van szükséged, akkor ne azt találd ki, hogy honnan szerezd meg, hanem azt add át magadnak. Esetleg egy konstruktor vagy más metódus paramétereként. Deklaráld a függőségeket. Mutassa ki őket az osztálya API-jában. Érthető és kiszámítható kódot kapsz.

Mit szólsz ehhez az osztályhoz, amely hibaüzeneteket naplóz:

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

Mit gondolsz, betartottuk az 1. számú szabályt: hadd adassák át neked?

Nem tettük meg.

A kulcsinformációt, a naplófájl könyvtárát az osztály a konstansból kapja.

Lásd a példahasználatot:

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

A megvalósítás ismerete nélkül tudna válaszolni arra a kérdésre, hogy hová íródnak az üzenetek? Ez azt sugallná, hogy a LOG_DIR konstans létezése szükséges a működéshez? És tudnál-e létrehozni egy második példányt, ami más helyre ír? Természetesen nem.

Javítsuk meg az osztályt:

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

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

Az osztály most már sokkal világosabb, jobban konfigurálható és ezért hasznosabb.

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

De nem érdekel!

“Amikor létrehozok egy cikkobjektumot és meghívom a save() parancsot, nem akarok foglalkozni az adatbázissal, csak azt akarom, hogy a konfigurációban beállított adatbázisba mentődjön. ”

“Amikor a Logger-t használom, csak azt akarom, hogy az üzenet kiírásra kerüljön, és nem akarok foglalkozni azzal, hogy hova. Legyenek a globális beállítások használva. ”

Ezek helyes megjegyzések.

Példaként vegyünk egy olyan osztályt, amely hírleveleket küld ki, és naplózzuk, hogyan ment ez:

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

A továbbfejlesztett Logger, amely már nem használja a LOG_DIR állandót, megköveteli a fájl elérési útvonalát a konstruktorban. Hogyan lehet ezt megoldani? A NewsletterDistributor osztályt nem érdekli, hogy hova íródnak az üzenetek, csak írni akarja őket.

A megoldás ismét az 1. szabály: hadd adassák át neked: adj át neki minden adatot, amire az osztálynak szüksége van.

Tehát átadjuk a napló elérési útvonalát a konstruktornak, amivel aztán létrehozzuk a Logger objektumot ?

class NewsletterDistributor
{
	public function __construct(
		private string $file, // ⛔ NEM ÍGY!
	) {
	}

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

Nem így! Mert az elérési út nem tartozik azokhoz az adatokhoz, amelyekre a NewsletterDistributor osztálynak szüksége van, hanem a Logger. Az osztálynak magára a loggerre van szüksége. És ezt fogjuk továbbadni:

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

Most már a NewsletterDistributor osztály aláírásából egyértelmű, hogy a naplózás része a funkcionalitásnak. És az a feladat, hogy a naplózót egy másikkal helyettesítsük, esetleg tesztelési céllal, elég triviális. Ráadásul, ha a Logger osztály konstruktorát megváltoztatjuk, az nem lesz hatással a mi osztályunkra.

2. szabály: Vedd el, ami a tiéd

Ne hagyd magad félrevezetni, és ne hagyd, hogy a függőségek paramétereit átadják neked. Adja át közvetlenül a függőségeket.

Ezáltal a más objektumokat használó kód teljesen független lesz a konstruktoraik módosításaitól. Az API-ja igazabb lesz. És ami a legfontosabb, triviális lesz ezeket a függőségeket másokra cserélni.

A család új tagja

A fejlesztőcsapat úgy döntött, hogy létrehoz egy második loggert, amely az adatbázisba ír. Létrehozunk tehát egy DatabaseLogger osztályt. Tehát két osztályunk van, a Logger és a DatabaseLogger, az egyik egy fájlba ír, a másik az adatbázisba … nem gondolod, hogy van valami furcsa ebben a névben? Nem lenne jobb, ha átneveznénk a Logger -t FileLogger-re ? Dehogynem.

De csináljuk okosan. Létrehozunk egy interfészt az eredeti név alatt:

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

…amit mindkét naplózó implementál:

class FileLogger implements Logger
// ...

class DatabaseLogger implements Logger
// ...

És így semmit sem kell változtatni a kód többi részén, ahol a logger használatban van. Például a NewsletterDistributor osztály konstruktora továbbra is elégedett lesz azzal, hogy paraméterként a Logger címet kéri. És rajtunk múlik majd, hogy melyik példányt adjuk át neki.

Ez az oka annak, hogy soha nem adunk interfész neveknek Interface utótagot vagy I előtagot. Máskülönben lehetetlen lenne ilyen szépen kódot fejleszteni.

Houston, van egy problémánk

Míg az egész alkalmazásban megelégedhetünk egyetlen loggerpéldánnyal, legyen az fájl vagy adatbázis, és egyszerűen átadhatjuk azt bárhol, ahol valamit naplózni kell, addig a Article osztály esetében ez egészen másképp van. Valójában szükség szerint hozunk létre példányokat belőle, esetleg többször is. Hogyan kezeljük az adatbázis-kötést a konstruktorában?

Példaként használhatunk egy olyan vezérlőt, amelynek egy űrlap elküldése után egy cikket kell elmentenie az adatbázisba:

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

Egy lehetséges megoldás közvetlenül kínálkozik: az adatbázis-objektumot a konstruktor adja át a EditController címre, és használja a $article = new Article($this->db) címet.

Az előző esethez hasonlóan a Logger és a fájl elérési útvonalával kapcsolatban ez nem a helyes megközelítés. Az adatbázis nem a EditController, hanem a Article függvénye. Az adatbázis átadása tehát ellentétes a 2. szabállyal: vedd el, ami a tiéd. Ha a Article osztály konstruktora megváltozik (új paramétert adunk hozzá), akkor a kódot is módosítani kell minden olyan helyen, ahol példányok jönnek létre. Ufff.

Houston, mit javasolsz?

3. szabály: Hagyd, hogy a gyár kezelje a dolgot

A rejtett kötések eltávolításával és az összes függőség argumentumként való átadásával sokkal konfigurálhatóbb és rugalmasabb osztályokat kapunk. És így valami másra van szükségünk, hogy létrehozzuk és konfiguráljuk ezeket a rugalmasabb osztályokat. Ezt nevezzük gyáraknak.

Az ökölszabály a következő: ha egy osztály függőségekkel rendelkezik, a példányaik létrehozását bízzuk a gyárra.

A gyárak a new operátor okosabb helyettesítői a függőségi injektálás világában.

Gyár

A gyár egy olyan metódus vagy osztály, amely objektumokat állít elő és konfigurál. A Article termelő osztályt ArticleFactory nevezzük, és ez így nézhet ki:

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

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

A vezérlőben való használata a következő lenne:

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

	public function formSubmitted($data)
	{
		// hagyja, hogy a gyár létrehozzon egy objektumot
		$article = $this->articleFactory->create();
		$article->title = $data->title;
		$article->content = $data->content;
		$article->save();
	}
}

Ekkor, amikor a Article osztály konstruktorának aláírása megváltozik, a kód egyetlen része, amelynek reagálnia kell, maga a ArticleFactory gyár. Minden más, a Article objektumokkal dolgozó kódot, például a EditController, ez nem érinti.

Lehet, hogy most a homlokodat kopogtatod, hogy vajon segítettünk-e egyáltalán magunkon. A kód mennyisége megnőtt, és az egész dolog kezd gyanúsan bonyolultnak tűnni.

Ne aggódj, hamarosan rátérünk a Nette DI konténerre. És számos olyan ász van a tarsolyában, amelyek rendkívül egyszerűvé teszik a függőségi injektálást használó alkalmazások építését. Például a ArticleFactory osztály helyett elég lesz egy egyszerű interfészt írni:

interface ArticleFactory
{
	function create(): Article;
}

De előreszaladtunk, várjunk csak :-)

Összefoglaló

A fejezet elején azt ígértük, hogy megmutatjuk, hogyan tervezhetünk tiszta kódot. Csak adjuk meg az osztályokat

Első pillantásra talán nem így tűnik, de ennek a három szabálynak messzemenő következményei vannak. A kódtervezés gyökeresen eltérő szemléletéhez vezetnek. Megéri ez? Azok a programozók, akik kidobták a régi szokásokat, és elkezdték következetesen használni a függőségi injektálást, ezt szakmai életük sorsfordító pillanatának tekintik. A tiszta és fenntartható alkalmazások világát nyitotta meg.

De mi van akkor, ha a kód nem következetesen használja a függőségi injektálást? Mi van, ha statikus metódusokra vagy singletonokra épül? Hozhat ez problémákat? Igen, és ez nagyon jelentős.

verzió: 3.x