IoC a dependency injection v PHP
Veľkou časťou programátorov je PHP považované za patlací jazyk, kde sa všetko nejak polepí a hlbšia koncepcia tu vôbec neexistuje. Pravda je taká, že PHP patlačov je dosť, ale samotný jazyk za to nemôže. V tomto článku by som chcel priblížiť princíp IoC a dependency injection, ktorý môže pomôcť lepšie si rozvrhnúť kód.
Teória
Nechcem tu vypisovať rozsiahle poučky z internetu, ktoré si môžete nájsť aj sami. Len uvediem, že hlavným účelom použitia IoC (inversion of control) je:
- oddelenie väzieb medzi vykonávaním a implementáciou,
- zameranie sa na modul a jeho úlohu,
- oslobodenie modulov od toho, čo robia iné časti systému,
- zabránenie vedľajších účinkov pri výmene modulov.
Dependency injection je jeden zo spôsobov, ako IoC implementovať. A najlepšie je ukázať si to na príklade.
Príklad
Povedzme si, že chceme naprogramovať modul, ktorý bude ukladať dáta o zákazníkovi do databázy. Vytvoríme si teda triedu Customer a triedu Db. Pričom Customer bude používať Db na pripojenie do databázy. Riešenie, ktoré tu popíšem, bude trochu abstraktné, ale ide hlavne o princíp. Taktiež je všetok kód tu uvedený písaný do jedného PHP súboru. Na stiahnutie je aj zip, kde si môžete pozrieť celé riešenie, kde sú jednotlivé triedy rozdelené do separátnych súborov.
class Db
{
public function OpenConnection()
{
echo "Pripojenie do DB bolo otvorené.";
}
}
class Customer
{
public function Insert()
{
$db = new Db();
$db->OpenConnection();
echo "Otvorili sme spojenie a vložili zákazníka.";
}
}
Problémy
V jednoduchej aplikácií nám takéto riešenie nevadí, ale ak bude riešenie rozsiahlejšie, tak nám vznikajú nasledovné problémy.
Trieda Customer je úzko spätá s triedou Db. Čo ak budeme chcieť v budúcnosti použiť inú databázu? Ak máme len jeden objekt, tak ho prepíšeme, ale ak tých objektov bude 10 a viac, tak budeme musieť v každom z nich upraviť, aby používal povedzme triedu DbSqLite. A neskôr príde Oracle. A každý zákazník bude chcieť používať inú.
Čo potrebujeme?
Potrebujeme, aby tieto triedy neboli na sebe pevne závislé. Takže v podstate triede Customer hodíme do konštruktora parameter Db.
class Customer
{
private $Db;
public function __construct ($Db)
{
$this->Db = $Db;
}
public function Insert()
{
$this->Db->OpenConnection();
echo "Otvorili sme spojenie a vložili zákazníka.";
}
}
Týmto sme presunuli zodpovednosť za dodanie správnej databázy niekomu, kto bude triedu Customer používať. Trieda tak nie je pevne zviazaná s konkrétnou implementáciou. Ale je toto finálne riešenie? Nie je. Problém, ktorý nám teraz nastal je, že sme pridali robotu niekomu, kto chce používať našu triedu. A strom takýchto závislostí môže byť väčší. Trieda Db bude v konštruktore vyžadovať nejakú logovaciu triedu a tá zas bude vyžadovať cestu do log súboru, atď.
IoC framework
Na to, aby sme vyriešili tento problém, potrebujeme použiť niektorý z dostupných IoC frameworkov. Mne sa osvedčilo použitie Dice. Je jednoduchý a ľahko sa konfiguruje.
Načo nám je vlastne takýto framework? Framework nám bude slúžiť na inštancovanie tried. Vďaka konfigurácií, ktorú mu dodáme vie, že ak chceme inštanciu triedy Customer, tak má vytvoriť najprv inštanciu triedy Db a posunúť ju do konštruktora.
IoC container
Samotný framework obsahuje container, alebo kôš, do ktorého nahádžeme, ako sa má ktorý objekt prekladať. Výhodou je, že v samotnej implementácií sa spoliehame len na interface a konfiguráciou IoC containera uvedieme, aká konkrétna implementácia predstavuje daný interface. Druhou výhodou je, že konfigurácia je na jednom mieste a v prípade, že chceme globálne zmeniť databázu z MySql na Oracle, tak ju len zmeníme.
Interface, interface, interface
Na to, aby sme mohli správne využiť IoC, potrebujeme interface a type hinting. To znamená, že v konštruktoroch aj uvedieme, akú implementáciu očakávame.
Upravíme si teda kód nasledovne:
interface IDb
{
public function OpenConnection();
}
class DbMySql implements IDb
{
public function OpenConnection()
{
echo "Pripojenie do MySQL DB bolo otvorené.";
}
}
class DbOracle implements IDb
{
public function OpenConnection()
{
echo "Pripojenie do Oracle DB bolo otvorené.";
}
}
class Customer
{
private $connection;
public function __construct(IDb $connection)
{
$this->connection = $connection;
}
public function Insert()
{
$this->connection->OpenConnection();
echo "Zákazník bol vložený.";
}
}
Čo sme urobili?
Vytvorili sme si základný interface IDb, ktorý má jednu metódu OpenConnection. Implementovali sme ho dvomi triedami, jednu pre MySql a jednu pre Oracle.
Trieda Customer očakáva, že v konštruktore jej príde trieda, ktorá implementuje interface IDb. Nezaujíma ju už konkrétna implementácia. Tú si zabezpečíme my triedami DbMySql a DbOracle. IoC container a jeho konfigurácia nám zase zabezpečí, že pri vytváraní objektu Customer sa použije správna implementácia.
Použitie IoC containera
Teraz nám už stačí len nakonfigurovať Dice a použiť ho. Ja si na to použijem jednoduchú factory triedu (o factory patterne si môžete prečítať napríklad tu).
require_once ("dice/Dice.php");
class DiceFactory
{
public static function Instance ()
{
$dice = new \Dice\Dice();
$dice->addRule('Customer', ['substitutions' => ['IDb' => ['instance' => 'DbMySql']]]);
return $dice;
}
}
Vysvetlenie
DiceFactory je trieda obsahujúca jednu statickú metódu Instance, ktorá vytvorí inštanciu triedy Dice a vloží do nej konfiguráciu. V tomto prípade sme uviedli, že pre triedu Customer treba nahradiť interface IDb inštanciou triedy DbMySql.
Použitie
Nakoniec už len vytvárame objekty pomocou IoC containera. Zavoláme našu factory triedu a vypýtame si inštanciu triedy Customer. Potom zavoláme jeho metódu Insert.
$instance = DiceFactory::Instance()->create("Customer");
$instance->Insert();
Záver
Naučili sme sa základy použitia dependency injection pomocou IoC frameworku. Nie je samozrejme povedané, že takýto dizajn je všade vhodný. Treba zvážiť aj dopady na výkon. Nie všetky IoC frameworky sú výkonnostne odladené.
Veľkou výhodou použitia tohto princípu je modulárnosť riešenia. Ľahšie sa testuje, jednoduchšie sa vytvárajú mockupy za moduly, ktoré ešte nie sú implementované, a taktiež sa dobre konfiguruje. Náhrada jednej implementácie za inú sa robí na jednom spoločnom mieste.
V prílohách nájdete aj zip s úplným riešením zahrňujúcim využitie autoload a oddelenie tried do separátnych súborov.