Mi az a Dependency Injection?
Ez a fejezet bemutatja azokat az alapvető programozási gyakorlatokat, amelyeket minden alkalmazás írásakor követnie kell. Ezek az alapok szükségesek a tiszta, érthető és karbantartható kód írásához.
Ha elsajátítja és követi ezeket a szabályokat, a Nette minden lépésben segíteni fog Önnek. Kezelni fogja a rutinfeladatokat, és maximális kényelmet biztosít Önnek, hogy a tényleges logikára koncentrálhasson.
Az itt bemutatott elvek meglehetősen egyszerűek. Nincs mitől félnie.
Emlékszel az első programodra?
Nem tudjuk, milyen nyelven írta, de ha PHP lett volna, valószínűleg így nézett volna ki:
function soucet(float $a, float $b): float
{
return $a + $b;
}
echo soucet(23, 1); // kiírja a 24-et
Néhány triviális kódsor, de annyi kulcsfontosságú koncepciót rejtenek magukban. Hogy vannak változók. Hogy a kód kisebb egységekre van osztva, mint például a függvények. Hogy bemeneti argumentumokat adunk át nekik, és eredményeket adnak vissza. Már csak a feltételek és a ciklusok hiányoznak.
Az, hogy bemeneti adatokat adunk át egy függvénynek, és az eredményt ad vissza, egy tökéletesen érthető koncepció, amelyet más területeken is használnak, például a matematikában.
Egy függvénynek van szignatúrája, amely a nevéből, a paraméterek és típusaik listájából, valamint végül 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 nem kell tudnunk semmit.
Most képzelje el, hogy a függvény szignatúrája így néz ki:
function soucet(float $x): float
Összeadás egy paraméterrel? Ez furcsa… És mi van ezzel?
function soucet(): float
Ez már tényleg nagyon furcsa, nem? Hogyan használják a függvényt?
echo soucet(); // vajon mit ír ki?
Egy ilyen kódot látva összezavarodnánk. Nemcsak egy kezdő nem értené, de egy tapasztalt programozó sem.
Gondolkodik azon, hogyan nézne ki egy ilyen függvény belülről? Honnan veszi az összeadandókat? Valószínűleg valahogy maga szerezné be őket, például így:
function soucet(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
A függvény törzsében rejtett függőségeket fedeztünk fel más globális függvényekre vagy statikus metódusokra. Ahhoz, hogy megtudjuk, honnan származnak valójában az összeadandók, tovább kell kutatnunk.
Nem erre!
Az imént bemutatott tervezés számos negatív tulajdonság esszenciája:
- A függvény szignatúrája úgy tett, mintha nem lenne szüksége összeadandókra, ami félrevezetett minket.
- Fogalmunk sincs, hogyan vegyük rá a függvényt, hogy két másik számot adjon össze.
- Bele kellett néznünk a kódba, hogy megtudjuk, honnan veszi az összeadandókat.
- Rejtett függőségeket fedeztünk fel.
- A teljes megértéshez ezeket a függőségeket is meg kell vizsgálni.
És egyáltalán az összeadó függvény feladata a bemenetek beszerzése? Természetesen nem. Az ő felelőssége csak maga az összeadás.
Ilyen kóddal nem akarunk találkozni, és határozottan nem akarunk ilyet írni. A javítás egyszerű: térjünk vissza az alapokhoz, és egyszerűen használjunk paramétereket:
function soucet(float $a, float $b): float
{
return $a + $b;
}
1. szabály: Kérd el
A legfontosabb szabály: minden adatot, amire egy függvénynek vagy osztálynak szüksége van, át kell adni neki.
Ahelyett, hogy rejtett módokat találnál ki, amelyekkel maguk is hozzáférhetnének, egyszerűen add át a paramétereket. Időt takarítasz meg a rejtett utak kitalálásával, amelyek biztosan nem javítják a kódodat.
Ha ezt a szabályt mindig és mindenhol betartod, úton vagy a rejtett függőségek nélküli kód felé. Egy olyan kód felé, amely nemcsak a szerző számára érthető, hanem bárki számára is, aki utána olvassa. Ahol minden érthető a függvények és osztályok szignatúráiból, és nem kell rejtett titkok után kutatni a megvalósításban.
Ezt a technikát szakmailag dependency injection-nek (függőséginjektálás) nevezik. És ezeket az adatokat függőségeknek (dependencies). Valójában ez csak egyszerű paraméterátadás, semmi több.
Kérjük, ne keverje össze a dependency injection-t, ami egy tervezési minta, a „dependency injection container”-rel, ami egy eszköz, tehát valami gyökeresen más. A konténerekkel később foglalkozunk.
Függvényektől az osztályokig
És hogyan kapcsolódik ez az osztályokhoz? Az osztály egy összetettebb egység, mint egy egyszerű függvény, de az 1. szabály itt is maradéktalanul érvényes. Csak több lehetőség van az argumentumok átadására. Például egészen hasonlóan, mint egy függvénynél:
class Matematika
{
public function soucet(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Matematika;
echo $math->soucet(23, 1); // 24
Vagy más metódusokkal, vagy közvetlenül a konstruktorral:
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
Mindkét példa teljes mértékben összhangban van a dependency injection elvével.
Valós példák
A való világban nem fogsz osztályokat írni számok összeadására. 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
{
// elmentjük a cikket az adatbázisba
}
}
és a használat a következő lesz:
$article = new Article;
$article->title = '10 dolog, amit tudnod kell a fogyásról';
$article->content = 'Minden évben emberek milliói ...';
$article->save();
A save()
metódus elmenti a cikket egy adatbázis táblába. A Nette
Database segítségével megvalósítani gyerekjáték lenne, ha nem lenne egy bökkenő: honnan veszi az 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ökölhet egy olyan osztálytól, amely biztosítja az adatbázis-kapcsolatot. Vagy használhatja az úgynevezett singleton mintát. Vagy az úgynevezett facades-okat, amelyeket 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égsem?
Idézzük fel az #1. szabály: Kérd el: minden függőséget, amire az osztálynak szüksége van, át kell adni neki. Mert ha megszegjük a szabályt, a piszkos kód útjára léptünk, tele rejtett függőségekkel, érthetetlenséggel, és az eredmény egy olyan alkalmazás lesz, amelyet fájdalmas lesz karbantartani és fejleszteni.
Az Article
osztály felhasználója nem tudja, hova menti a save()
metódus a cikket. Adatbázis
táblába? Melyikbe, az élesbe vagy a tesztbe? És hogyan lehet ezt megváltoztatni?
A felhasználónak meg kell néznie, hogyan van implementálva a save()
metódus, és megtalálja a
DB::insert()
metódus használatát. Tehát tovább kell kutatnia, hogyan szerzi be ez a metódus az
adatbázis-kapcsolatot. És a rejtett függőségek elég hosszú láncot alkothatnak.
A tiszta és jól megtervezett kódban soha nincsenek rejtett függőségek, Laravel facade-ok vagy statikus változók. A tiszta és jól megtervezett kódban argumentumokat adnak át:
class Article
{
public function save(Nette\Database\Connection $db): void
{
$db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Még praktikusabb lesz, ahogy később látni fogjuk, a konstruktorral:
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 azt gondolod, hogy az Article
-nek egyáltalán nem kellene
save()
metódussal rendelkeznie, tisztán adatkomponensnek kellene lennie, és a mentésről egy különálló
repositorynak kellene gondoskodnia. Ennek van értelme. De ezzel messze túllépnénk a témán, ami a dependency injection, és
az egyszerű példák bemutatására tett erőfeszítésen.
Ha olyan osztályt írsz, amelynek a működéséhez például adatbázisra van szüksége, ne azon gondolkodj, honnan szerezd be, hanem kérd el. Például a konstruktor 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 kapsz.
És mi van 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. szabály: Kérd el?
Nem tartottuk be.
A kulcsinformációt, azaz a naplófájlt tartalmazó könyvtárat, az osztály maga szerzi be egy konstansból.
Nézd meg a használati példát:
$logger = new Logger;
$logger->log('A hőmérséklet 23 °C');
$logger->log('A hőmérséklet 10 °C');
Az implementáció ismerete nélkül tudnál válaszolni arra a kérdésre, hogy hova íródnak az üzenetek? Eszedbe jutna,
hogy a működéshez szükség van a LOG_DIR
konstans létezésére? És tudnál létrehozni egy második példányt,
amely máshova ír? Biztosan nem.
Javítsuk ki 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 sokkal érthetőbb, konfigurálhatóbb és ezáltal hasznosabb.
$logger = new Logger('/útvonal/a/naplóhoz.txt');
$logger->log('A hőmérséklet 15 °C');
De ez engem nem érdekel!
“Amikor létrehozok egy Article objektumot és meghívom a save()-t, nem akarok az adatbázissal foglalkozni, egyszerűen azt akarom, hogy abba mentse el, amit a konfigurációban beállítottam.”
“Amikor a Logger-t használom, egyszerűen azt akarom, hogy az üzenet íródjon ki, és nem akarom megoldani, hogy hova. Használja a globális beállítást.”
Ezek helyes észrevételek.
Példaként egy hírleveleket küldő osztályt mutatunk be, amely naplózza, hogyan sikerült:
class NewsletterDistributor
{
public function distribute(): void
{
$logger = new Logger(/* ... */);
try {
$this->sendEmails();
$logger->log('Az e-mailek elküldve');
} catch (Exception $e) {
$logger->log('Hiba történt a küldés során');
throw $e;
}
}
}
A továbbfejlesztett Logger
, amely már nem használja a LOG_DIR
konstansot, a konstruktorban
megköveteli a fájl elérési útjának megadását. Hogyan oldjuk ezt meg? A NewsletterDistributor
osztályt
egyáltalán nem érdekli, hova íródnak az üzenetek, csak ki akarja írni őket.
A megoldás ismét az #1. szabály: Kérd el: minden adatot, amire az osztálynak szüksége van, átadunk neki.
Tehát ez azt jelenti, hogy a konstruktoron keresztül átadjuk a napló elérési útját, amelyet 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 így! Az elérési út ugyanis nem tartozik azok közé az adatok közé, amelyekre a
NewsletterDistributor
osztálynak szüksége van; azokra ugyanis a Logger
-nek van szüksége. Érzed a
különbséget? A NewsletterDistributor
osztálynak magára a loggerre van szüksége. Tehát azt adjuk át:
class NewsletterDistributor
{
public function __construct(
private Logger $logger, // ✅
) {
}
public function distribute(): void
{
try {
$this->sendEmails();
$this->logger->log('Az e-mailek elküldve');
} catch (Exception $e) {
$this->logger->log('Hiba történt a küldés során');
throw $e;
}
}
}
Most már a NewsletterDistributor
osztály szignatúráiból világos, hogy a funkcionalitásának része a
naplózás is. És a logger cseréjének feladata egy másikra, például tesztelés céljából, teljesen triviális. Ráadásul,
ha a Logger
osztály konstruktora megváltozna, az nem lenne hatással az osztályunkra.
2. szabály: Vedd el, ami a tiéd
Ne hagyd magad megtéveszteni, és ne kérd a függőségeid függőségeinek átadását. Csak a saját függőségeidet kérd el.
Ennek köszönhetően a más objektumokat használó kód teljesen független lesz a konstruktoraik változásaitól. Az API-ja igazabb lesz. És főleg triviális lesz ezeket a függőségeket másokra cserélni.
Új családtag
A fejlesztői csapat úgy döntött, hogy létrehoz egy második loggert, amely adatbázisba ír. Tehát létrehozunk egy
DatabaseLogger
osztályt. Így van két osztályunk, a Logger
és a DatabaseLogger
, az
egyik fájlba ír, a másik adatbázisba… nem tűnik valami furcsának az elnevezés? Nem lenne jobb átnevezni a
Logger
-t FileLogger
-re? Biztosan igen.
De okosan csináljuk. Az eredeti név alatt létrehozunk egy interfészt:
interface Logger
{
function log(string $message): void;
}
… amelyet mindkét logger implementálni fog:
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
Ennek köszönhetően nem kell semmit sem változtatni a kód többi részében, ahol a loggert használják. Például a
NewsletterDistributor
osztály konstruktora továbbra is elégedett lesz azzal, hogy paraméterként
Logger
-t igényel. És csak rajtunk múlik, melyik példányt adjuk át neki.
Ezért soha nem adunk az interfészek nevéhez Interface
utótagot vagy I
előtagot.
Különben nem lehetne a kódot ilyen szépen fejleszteni.
Houston, van egy problémánk
Míg az egész alkalmazásban megelégedhetünk egyetlen logger példánnyal, legyen az fájl- vagy adatbázis-alapú, és
egyszerűen átadjuk mindenhol, ahol valami naplózásra kerül, egészen más a helyzet az Article
osztály
esetében. Ennek példányait ugyanis 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?
Példaként szolgálhat egy kontroller, amelynek egy űrlap elküldése után el kell mentenie a cikket 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 adódik: átadjuk az adatbázis objektumot a konstruktoron keresztül az
EditController
-nek, és használjuk a $article = new Article($this->db)
kódot.
Ahogy az előző esetben a Logger
-rel és a fájl elérési útjával, ez sem a helyes megközelítés. Az
adatbázis nem az EditController
függősége, hanem az Article
-é. Az adatbázis átadása tehát
ellentétes a 2. szabály: Vedd el, ami a tiéd szabállyal. Ha az
Article
osztály konstruktora megváltozik (új paraméter kerül hozzáadásra), akkor a kódot is módosítani kell
mindenhol, ahol példányt hoznak létre. Pfff.
Houston, mit javasolsz?
3. szabály: Hagyd a factory-ra
Azzal, hogy megszüntettük a rejtett függőségeket, és minden függőséget argumentumként adunk át, konfigurálhatóbb és rugalmasabb osztályokat kaptunk. És ezért szükségünk van még valamire, ami létrehozza és konfigurálja nekünk ezeket a rugalmasabb osztályokat. Ezt factory-nak (gyárnak) fogjuk nevezni.
A szabály így szól: ha egy osztálynak függőségei vannak, hagyd a példányok létrehozását a factory-ra.
A factory-k az new
operátor okosabb helyettesítői a dependency injection világában.
Kérjük, ne keverje össze a factory method tervezési mintával, amely a factory-k specifikus felhasználási módját írja le, és nem kapcsolódik ehhez a témához.
Factory
A factory egy metódus vagy osztály, amely objektumokat gyárt és konfigurál. Az Article
-t gyártó osztályt
ArticleFactory
-nak nevezzük, és például így nézhet ki:
class ArticleFactory
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function create(): Article
{
return new Article($this->db);
}
}
Használata a kontrollerben a következő lesz:
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// hagyjuk, hogy a factory hozza létre az objektumot
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Ha ebben a pillanatban megváltozik az Article
osztály konstruktorának szignatúrája, az egyetlen kódrészlet,
amelynek reagálnia kell rá, maga a ArticleFactory
. Minden más kód, amely Article
objektumokkal
dolgozik, mint például az EditController
, ettől érintetlen marad.
Talán most a homlokodra csapsz, hogy egyáltalán segítettünk-e magunkon. A kód mennyisége megnőtt, és az egész kezd gyanúsan bonyolultnak tűnni.
Ne aggódj, hamarosan eljutunk a Nette DI konténerhez. És annak számos aduásza van a tarsolyában, amelyek rendkívül
leegyszerűsítik a dependency injectiont használó alkalmazások építését. Például az ArticleFactory
osztály
helyett elég lesz csak egy interfészt írni:
interface ArticleFactory
{
function create(): Article;
}
De ezzel előreszaladunk, még tarts ki :-)
Összegzés
Ennek a fejezetnek az elején azt ígértük, hogy bemutatunk egy módszert a tiszta kód tervezésére. Elég az osztályoknak
- átadni a szükséges függőségeket
- és fordítva, nem átadni azt, amire közvetlenül nincs szükségük
- és hogy a függőségekkel rendelkező objektumokat a legjobban factory-kban lehet létrehozni
Első pillantásra talán nem tűnik úgy, de ennek a három szabálynak messzemenő következményei vannak. Radikálisan más nézőponthoz vezetnek a kódtervezésben. Megéri? Azok a programozók, akik elhagyták régi szokásaikat és következetesen elkezdték használni a dependency injectiont, ezt a lépést szakmai életük kulcsfontosságú pillanatának tartják. Megnyílt előttük az áttekinthető és karbantartható alkalmazások világa.
De mi van, ha a kód nem használja következetesen a dependency injectiont? Mi van, ha statikus metódusokra vagy singletonokra épül? Ez okoz valamilyen problémát? Igen, és nagyon alapvetőeket.