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
- a szükséges függőségeket
- és ne azt, amire nincs közvetlen szükségük
- és hogy a függőségekkel rendelkező objektumok a legjobb, ha gyárakban készülnek.
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.