Mi az a Dependency Injection?

Ez a fejezet megismerteti Önt azokkal az alapvető programozási gyakorlatokkal, amelyeket bármilyen alkalmazás írása során követnie kell. Ezek az alapok szükségesek a tiszta, érthető és karbantartható kód írásához.

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 maximális kényelmet biztosít, így magára a logikára koncentrálhatsz.

Az elvek, amelyeket itt bemutatunk, meglehetősen egyszerűek. Nem kell aggódnia semmi miatt.

Emlékszel az első programodra?

Nem tudjuk, hogy milyen nyelven írta, de ha PHP volt, akkor 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 a tény, hogy egy függvény bemeneti adatokat fogad el, és egy eredményt ad vissza, 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, és általában nem kell tudnunk semmit a belső megvalósításról.

Most képzeljük el, hogy a függvény aláírás így néz ki:

function osszeg(float $x): float

Egyetlen paraméteres kiegészítés? Ez furcsa… Mi a helyzet ezzel?

function osszeg(): float

Ez tényleg furcsa, nem? Hogyan használják a funkciót?

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

Ha ilyen kódot néznénk, összezavarodnánk. Nemcsak egy kezdő nem értené, de még egy tapasztalt programozó sem értené az ilyen kódot.

Kíváncsi vagy, hogy egy ilyen függvény valójában hogyan nézne ki belülről? Honnan szerezné meg az összegzőket? Valószínűleg valahogyan magától szerezné meg őket, talán í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 sok negatív tulajdonság lényege:

  • A függvény aláírás úgy tett, mintha nem lenne szüksége az összegzőkre, ami összezavart minket.
  • fogalmunk sincs, hogyan lehetne a függvényt két másik számmal kiszámítani.
  • meg kellett néznünk a kódot, hogy rájöjjünk, honnan származnak az összegzők
  • rejtett függőségeket találtunk
  • a teljes megértéshez ezeket a függőségeket is meg kell vizsgálni

É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 módokat találnának ki, hogy maguk férjenek hozzá az adatokhoz, egyszerűen adja át a paramétereket. Ezzel időt takarít meg, amelyet rejtett utak kitalálásával töltene, amelyek biztosan nem javítanának a kódján.

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

Ezt a technikát szaknyelven függőségi injektálásnak nevezik. Ezeket az adatokat pedig függőségeknek nevezik. Ez csak közönséges 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ási konténerrel”, ami egy eszköz, valami szögesen más. A konténerekkel később fogunk foglalkozni.

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

És hogyan kapcsolódnak az osztályok? Egy osztály összetettebb egység, mint egy egyszerű függvény, de az 1. szabály itt is teljes mértékben é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ódusokon keresztül, vagy közvetlenül a konstruktoron keresztül:

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 számok összeadására szolgáló osztályokat írni. Térjünk át a gyakorlati példákra.

Legyen egy Article osztályunk, amely egy blogbejegyzést 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. Ennek megvalósítása a Nette Database segítségével gyerekjáték lesz, ha nem lenne egy bökkenő: honnan szerzi meg a Article az adatbázis-kapcsolatot, azaz egy Nette\Database\Connection osztályú objektumot ?

Úgy tűnik, rengeteg lehetőségünk van. Valahonnan egy statikus változóból is veheti. Vagy egy olyan osztályból örökli, amely adatbázis-kapcsolatot biztosít. Vagy kihasználja egy singleton előnyeit. Vagy használhatunk ú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: Legyen átadva: minden függőséget, amire az osztálynak szüksége van, át kell adni neki. Mert ha megszegjük ezt a szabályt, akkor elindultunk a rejtett függőségekkel teli, piszkos kódhoz vezető úton, amely tele van rejtett függőségekkel, érthetetlenséggel, és az eredmény egy olyan alkalmazás lesz, amelyet fájdalmas lesz karbantartani és fejleszteni.

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

A felhasználónak meg kell néznie, hogyan van implementálva a save() metódus, és meg kell találnia 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 függőségek pedig elég hosszú láncot alkothatnak.

A tiszta és jól megtervezett kódban soha nincsenek rejtett függőségek, Laravel-facadek vagy statikus változók. 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,
		]);
	}
}

Egy még praktikusabb megközelítés, mint később látni fogjuk, a konstruktoron keresztül történik:

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, azt gondolhatod, hogy a Article egyáltalán nem kellene, hogy rendelkezzen save() metódussal; tisztán adatkomponenst kellene képviselnie, és egy külön tárolónak kellene gondoskodnia a mentésről. Ennek van is értelme. De ez messze túlmutatna a téma keretein, ami a függőségi injektálás, és az egyszerű példák bemutatására tett erőfeszítésen.

Ha olyan osztályt írsz, aminek a működéséhez például adatbázisra van szüksége, ne találd ki, honnan szerzed be, hanem legyen átadva. Vagy a konstruktor paramétereként, vagy egy másik metódus paramétereként. Ismerd el a függőségeket. Ismerd el őket az osztályod API-jában. Érthető és kiszámítható kódot fogsz kapni.

És mi a helyzet ezzel az osztállyal, 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, azaz a naplófájlt tartalmazó könyvtárat maga az osztály kapja a konstansból.

Nézd meg a használati példát:

$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? Gondolnád, hogy a LOG_DIR állandó létezése szükséges a működéséhez? És tudnál-e létrehozni egy második példányt, amely más helyre írna? Biztosan 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 érthetőbb, konfigurálhatóbb és ezáltal 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 abba az adatbázisba kerüljön elmentésre, amelyet a konfigurációban beállítottam.”

“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. Legyen a globális beállítások használata.”

Ezek jogos észrevételek.

Példaként nézzünk meg egy olyan osztályt, amely hírleveleket küld és naplózza, hogyan ment:

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 konstanst, megköveteli a fájl elérési útvonalának megadását a konstruktorban. Hogyan lehet ezt megoldani? A NewsletterDistributor osztályt nem érdekli, hogy az üzenetek hova íródnak, csak írni akarja őket.

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

Ez tehát azt jelenti, hogy a konstruktoron keresztül átadjuk a napló elérési útvonalát, amit aztán a Logger objektum létrehozásakor használunk?

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

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

Nem, nem így! Az útvonal nem tartozik a NewsletterDistributor osztály számára szükséges adatok közé, sőt, a Logger osztálynak van rá szüksége. Látod a különbséget? A NewsletterDistributor osztálynak magára a naplózóra van szüksége. Tehát ezt fogjuk átadni:

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

A NewsletterDistributor osztály aláírásaiból egyértelmű, hogy a naplózás is része a funkcióinak. És a logger cseréje egy másikra, esetleg tesztelés céljából, teljesen triviális feladat. Ráadásul, ha a Logger osztály konstruktora megváltozik, az nem befolyásolja a mi osztályunkat.

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

Ne hagyd magad félrevezetni, és ne engedd át magad a függőségeid függőségein. Csak a saját függőségeidet add át.

Ennek köszönhetően a más objektumokat használó kód teljesen független lesz a konstruktoraikban bekövetkező változásoktól. Az API-ja sokkal igazabb lesz. És mindenekelőtt triviális lesz ezeket a függőségeket másokkal helyettesíteni.

Új családtag

A fejlesztőcsapat úgy döntött, hogy létrehoz egy második loggert, amely az adatbázisba ír. Ezért létrehozunk egy DatabaseLogger osztályt. Tehát két osztályunk van, Logger és DatabaseLogger, az egyik egy fájlba ír, a másik az adatbázisba … nem tűnik furcsának az elnevezés? Nem lenne jobb átnevezni a Logger -t FileLogger-re ? Határozottan igen.

De tegyük ezt okosan. Létrehozunk egy interfészt az eredeti név alatt:

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

… amelyet mindkét logger végrehajt:

class FileLogger implements Logger
// ...

class DatabaseLogger implements Logger
// ...

És emiatt nem kell semmit sem változtatni a kód többi részén, ahol a naplózót használjuk. Például a NewsletterDistributor osztály konstruktora továbbra is megelégszik azzal, hogy paraméterként a Logger címet kéri. Az pedig csak rajtunk múlik majd, hogy melyik példányt adjuk át.

Ez az oka annak, hogy soha nem adjuk hozzá a Interface utótagot vagy a I előtagot az interfésznevekhez. Máskülönben nem lehetne ilyen szépen fejleszteni a kódot.

Houston, van egy problémánk

Míg a logger egyetlen példányával – legyen az fájl- vagy adatbázis-alapú – az egész alkalmazásban boldogulhatunk, és egyszerűen átadhatjuk azt mindenhol, ahol valamit naplózni kell, addig a Article osztály esetében ez teljesen másképp van. Annak példányait szükség szerint hozzuk létre, akár többször is. Hogyan kezeljük az adatbázis-függőséget a konstruktorában?

Egy példa lehet egy olyan vezérlő, 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ézenfekvő: adjuk át az adatbázis-objektumot a EditController konstruktornak, és használjuk a $article = new Article($this->db) címet.

Csakúgy, mint az előző esetben a Logger és a fájl elérési útvonalával, 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 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 módosítani kell, ahol a példányok létrehozására sor kerül. Ufff.

Houston, mit javasolsz?

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

A rejtett függőségek kiküszöbölésével és az összes függőség argumentumként való átadásával sokkal konfigurálhatóbb és rugalmasabb osztályokat kaptunk. És ezért szükségünk van valami másra, ami ezeket a rugalmasabb osztályokat létrehozza és konfigurálja helyettünk. 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.

Kérjük, ne keverjük össze a factory method tervezési mintával, amely a gyárak használatának egy speciális módját írja le, és nem kapcsolódik ehhez a témához.

Gyár

A gyár egy olyan metódus vagy osztály, amely objektumokat hoz létre és konfigurál. A Article -t előállító osztályt ArticleFactory-nek fogjuk nevezni, és í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ő:

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

Ezen a ponton, ha a Article osztály konstruktorának aláírása megváltozik, a kód egyetlen része, amelyre reagálnia kell, maga a ArticleFactory. A Article objektumokkal dolgozó minden más kódot, például a EditController, ez nem érinti.

Talán elgondolkodik azon, hogy valóban jobbá tettük-e a dolgokat. A kód mennyisége megnőtt, és az egész kezd gyanúsan bonyolultnak tűnni.

Ne aggódjon, hamarosan eljutunk a Nette DI konténerhez. És ez számos trükköt tartogat a tarsolyában, ami nagyban leegyszerűsíti a függőségi injektálást használó alkalmazások építését. Például a ArticleFactory osztály helyett csak egy egyszerű interfészt kell írni:

interface ArticleFactory
{
	function create(): Article;
}

De most már elébe megyünk a dolgoknak, kérlek, legyetek türelemmel :-)

Összefoglaló

A fejezet elején azt ígértük, hogy bemutatjuk a tiszta kód tervezésének folyamatát. Mindössze annyit kell tennünk, hogy az osztályok:

Első pillantásra úgy tűnhet, hogy ennek a három szabálynak nincsenek messzemenő következményei, de a kódtervezés gyökeresen más szemléletéhez vezetnek. Megéri ez? Azok a fejlesztők, akik felhagytak a régi szokásokkal, és következetesen használni kezdték a függőségi injektálást, ezt a lépést szakmai életük döntő pillanatának tekintik. Megnyitotta előttük a letisztult és karbantartható alkalmazások világát.

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

verzió: 3.x