Kaj je Vbrizgavanje odvisnosti?
To poglavje vas bo seznanilo z osnovnimi programerskimi postopki, ki jih morate upoštevati pri pisanju vseh aplikacij. Gre za osnove, potrebne za pisanje čiste, razumljive in vzdržljive kode.
Če boste ta pravila sprejeli in jih upoštevali, vam bo Nette v vsakem koraku pomagal. Za vas bo reševal rutinske naloge in vam zagotovil maksimalno udobje, da se boste lahko osredotočili na samo logiko.
Principi, ki jih bomo tukaj predstavili, so precej preprosti. Ničesar se vam ni treba bati.
Se spomnite svojega prvega programa?
Ne vemo sicer, v katerem jeziku ste ga napisali, a če bi bil to PHP, bi verjetno izgledal nekako takole:
function soucet(float $a, float $b): float
{
return $a + $b;
}
echo soucet(23, 1); // izpiše 24
Nekaj trivialnih vrstic kode, a v njih se skriva toliko ključnih konceptov. Da obstajajo spremenljivke. Da se koda deli na manjše enote, kot so na primer funkcije. Da jim predajamo vhodne argumente in one vračajo rezultate. Manjkajo le še pogoji in zanke.
To, da funkciji predamo vhodne podatke in ona vrne rezultat, je popolnoma razumljiv koncept, ki se uporablja tudi na drugih področjih, kot na primer v matematiki.
Funkcija ima svojo signaturo, ki jo sestavljajo njeno ime, seznam parametrov in njihovih tipov ter na koncu tip vrnjene vrednosti. Kot uporabnike nas zanima signatura, o notranji implementaciji običajno ne potrebujemo vedeti ničesar.
Zdaj si predstavljajte, da bi signatura funkcije izgledala takole:
function soucet(float $x): float
Seštevanje z enim parametrom? To je čudno… Kaj pa takole?
function soucet(): float
To pa je že res zelo čudno, kajne? Kako se funkcija sploh uporablja?
echo soucet(); // kaj naj bi izpisalo?
Ob pogledu na takšno kodo bi bili zmedeni. Ne samo, da je ne bi razumel začetnik, takšne kode ne razume niti izkušen programer.
Razmišljate, kako bi takšna funkcija sploh izgledala znotraj? Kje bi vzela seštevance? Očitno bi si jih na nek način priskrbela sama, na primer takole:
function soucet(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
V telesu funkcije smo odkrili skrite povezave na druge globalne funkcije ali statične metode. Da bi ugotovili, od kod se seštevanci dejansko vzamejo, moramo raziskovati naprej.
Tako ne!
Načrt, ki smo ga pravkar predstavili, je bistvo mnogih negativnih lastnosti:
- signatura funkcije se je pretvarjala, da ne potrebuje seštevancev, kar nas je zmedlo
- sploh ne vemo, kako funkcijo pripraviti do tega, da sešteje drugi dve števili
- morali smo pogledati v kodo, da bi ugotovili, kje vzame seštevance
- odkrili smo skrite povezave
- za popolno razumevanje je treba preučiti tudi te povezave
In ali je sploh naloga seštevalne funkcije, da si priskrbi vhode? Seveda ni. Njena odgovornost je le samo seštevanje.
S takšno kodo se nočemo srečati in je zagotovo nočemo pisati. Popravek je pri tem preprost: vrniti se k osnovam in preprosto uporabiti parametre:
function soucet(float $a, float $b): float
{
return $a + $b;
}
Pravilo št. 1: naj ti bo predano
Najpomembnejše pravilo se glasi: vsi podatki, ki jih funkcije ali razredi potrebujejo, jim morajo biti predani.
Namesto da bi si izmišljali skrite načine, s katerimi bi lahko sami prišli do njih, preprosto predajte parametre. Prihranili boste čas, potreben za izmišljanje skritih poti, ki zagotovo ne bodo izboljšale vaše kode.
Če boste to pravilo vedno in povsod upoštevali, ste na poti h kodi brez skritih povezav. H kodi, ki je razumljiva ne samo avtorju, ampak tudi vsakomur, ki jo bo bral za njim. Kjer je vse razumljivo iz signatur funkcij in razredov in ni treba iskati skritih skrivnosti v implementaciji.
Tej tehniki se strokovno reče dependency injection (vbrizgavanje odvisnosti). In tem podatkom se reče odvisnosti. Pri tem gre za povsem običajno predajanje parametrov, nič več.
Prosimo, ne zamenjujte dependency injection, ki je načrtovalski vzorec, z „dependency injection container“, ki je orodje, torej nekaj diametralno drugačnega. Z vsebniki se bomo ukvarjali kasneje.
Od funkcij k razredom
In kako so s tem povezani razredi? Razred je kompleksnejša celota kot preprosta funkcija, vendar pravilo št. 1 velja brez izjeme tudi tukaj. Obstaja le več možnosti, kako predati argumente. Na primer precej podobno kot pri funkciji:
class Matematika
{
public function soucet(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Matematika;
echo $math->soucet(23, 1); // 24
Ali z drugimi metodami ali neposredno s konstruktorjem:
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
Oba primera sta popolnoma v skladu z dependency injection.
Realni primeri
V resničnem svetu ne boste pisali razredov za seštevanje števil. Premaknimo se k primerom iz prakse.
Imejmo razred Article
, ki predstavlja članek na blogu:
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// shranimo članek v podatkovno bazo
}
}
in uporaba bo naslednja:
$article = new Article;
$article->title = '10 Things You Need to Know About Losing Weight';
$article->content = 'Every year millions of people in ...';
$article->save();
Metoda save()
shrani članek v podatkovno tabelo. Implementirati jo s pomočjo Nette Database bi bilo enostavno, če ne bi bilo ene ovire: kje naj
Article
vzame povezavo s podatkovno bazo, tj. objekt razreda Nette\Database\Connection
?
Zdi se, da imamo veliko možnosti. Lahko jo vzame od nekod iz statične spremenljivke. Ali podeduje od razreda, ki zagotovi povezavo s podatkovno bazo. Ali uporabi t.i. singleton. Ali t.i. facades, ki se uporabljajo v Laravelu:
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],
);
}
}
Odlično, problem smo rešili.
Ali ne?
Spomnimo se #pravilo št. 1: naj ti bo predano: vse odvisnosti, ki jih razred potrebuje, mu morajo biti predane. Ker če pravilo kršimo, smo stopili na pot k umazani kodi, polni skritih povezav, nerazumljivosti, in rezultat bo aplikacija, ki jo bo boleče vzdrževati in razvijati.
Uporabnik razreda Article
ne ve, kam metoda save()
članek shranjuje. V podatkovno tabelo?
V katero, produkcijsko ali testno? In kako je to mogoče spremeniti?
Uporabnik mora pogledati, kako je implementirana metoda save()
, in najde uporabo metode DB::insert()
.
Torej mora raziskovati naprej, kako si ta metoda priskrbi podatkovno povezavo. In skrite povezave lahko tvorijo precej dolgo
verigo.
V čisti in dobro zasnovani kodi se nikoli ne pojavljajo skrite povezave, Laravelove facades ali statične spremenljivke. V čisti in dobro zasnovani kodi se predajajo argumenti:
class Article
{
public function save(Nette\Database\Connection $db): void
{
$db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Še bolj praktično, kot bomo videli kasneje, bo to s konstruktorjem:
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,
]);
}
}
Če ste izkušen programer, morda mislite, da Article
sploh ne bi smel imeti metode
save()
, moral bi predstavljati zgolj podatkovno komponento in za shranjevanje bi moral skrbeti ločen repozitorij. To
ima smisel. Toda s tem bi se oddaljili daleč preko okvira teme, ki je dependency injection, in prizadevanja za navajanje
preprostih primerov.
Če boste pisali razred, ki za svoje delovanje potrebuje npr. podatkovno bazo, ne izmišljajte si, od kod jo dobiti, ampak naj vam jo predajo. Na primer kot parameter konstruktorja ali druge metode. Priznajte odvisnosti. Priznajte jih v API-ju vašega razreda. Dobili boste razumljivo in predvidljivo kodo.
Kaj pa ta razred, ki beleži sporočila o napakah:
class Logger
{
public function log(string $message)
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Kaj mislite, smo upoštevali #pravilo št. 1: naj ti bo predano?
Nismo.
Ključno informacijo, torej imenik z datoteko z logom, si razred priskrbi sam iz konstante.
Poglejte primer uporabe:
$logger = new Logger;
$logger->log('Temperatura je 23 °C');
$logger->log('Temperatura je 10 °C');
Brez poznavanja implementacije, bi lahko odgovorili na vprašanje, kam se sporočila zapisujejo? Bi pomislili, da je za
delovanje potrebna obstoj konstante LOG_DIR
? In bi lahko ustvarili drugo instanco, ki bo zapisovala drugam?
Zagotovo ne.
Popravimo razred:
class Logger
{
public function __construct(
private string $file,
) {
}
public function log(string $message): void
{
file_put_contents($this->file, $message . "\n", FILE_APPEND);
}
}
Razred je zdaj veliko bolj razumljiv, nastavljiv in torej uporabnejši.
$logger = new Logger('/pot/do/loga.txt');
$logger->log('Temperatura je 15 °C');
Ampak to me ne zanima!
„Ko ustvarim objekt Article in pokličem save(), potem nočem reševati podatkovne baze, preprosto želim, da se shrani v tisto, ki jo imam nastavljeno v konfiguraciji.“
„Ko uporabim Logger, preprosto želim, da se sporočilo zapiše, in nočem reševati kam. Naj se uporabi globalna nastavitev.“
To so pravilne pripombe.
Kot primer si bomo pokazali razred, ki pošilja novice (newsletterje) in zabeleži, kako se je izšlo:
class NewsletterDistributor
{
public function distribute(): void
{
$logger = new Logger(/* ... */);
try {
$this->sendEmails();
$logger->log('E-pošta je bila poslana');
} catch (Exception $e) {
$logger->log('Prišlo je do napake pri pošiljanju');
throw $e;
}
}
}
Izboljšan Logger
, ki ne uporablja več konstante LOG_DIR
, zahteva v konstruktorju navedbo poti do
datoteke. Kako to rešiti? Razreda NewsletterDistributor
sploh ne zanima, kam se sporočila zapisujejo, želi jih le
zapisati.
Rešitev je spet #pravilo št. 1: naj ti bo predano: vse podatke, ki jih razred potrebuje, mu predamo.
Torej to pomeni, da si preko konstruktorja predamo pot do loga, ki jo nato uporabimo pri ustvarjanju objekta
Logger
?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ TAKO NE!
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Tako ne! Pot namreč ne spada med podatke, ki jih razred NewsletterDistributor
potrebuje; te namreč
potrebuje Logger
. Zaznavate razliko? Razred NewsletterDistributor
potrebuje logger kot takega. Torej si
tega predamo:
class NewsletterDistributor
{
public function __construct(
private Logger $logger, // ✅
) {
}
public function distribute(): void
{
try {
$this->sendEmails();
$this->logger->log('E-pošta je bila poslana');
} catch (Exception $e) {
$this->logger->log('Prišlo je do napake pri pošiljanju');
throw $e;
}
}
}
Zdaj je iz signatur razreda NewsletterDistributor
jasno, da je del njegove funkcionalnosti tudi logiranje. In
naloga zamenjati logger za drugega, na primer zaradi testiranja, je popolnoma trivialna. Poleg tega, če bi se konstruktor razreda
Logger
spremenil, to ne bo imelo nobenega vpliva na naš razred.
Pravilo št. 2: vzemi, kar je tvoje
Ne pustite se zmesti in ne pustite si predajati odvisnosti svojih odvisnosti. Pustite si predajati le svoje odvisnosti.
Zahvaljujoč temu bo koda, ki uporablja druge objekte, popolnoma neodvisna od sprememb njihovih konstruktorjev. Njen API bo bolj resničen. In predvsem bo trivialno te odvisnosti zamenjati za druge.
Nov član družine
V razvojni ekipi je padla odločitev ustvariti drugi logger, ki zapisuje v podatkovno bazo. Ustvarili bomo torej razred
DatabaseLogger
. Imamo torej dva razreda, Logger
in DatabaseLogger
, eden zapisuje
v datoteko, drugi v podatkovno bazo … se vam pri tem poimenovanju ne zdi nekaj čudnega? Ali ne bi bilo bolje preimenovati
Logger
v FileLogger
? Zagotovo da.
Ampak naredili bomo pametno. Pod prvotnim imenom bomo ustvarili vmesnik:
interface Logger
{
function log(string $message): void;
}
… ki ga bosta oba loggerja implementirala:
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
In zahvaljujoč temu ne bo treba ničesar spreminjati v preostalem delu kode, kjer se logger uporablja. Na primer konstruktor
razreda NewsletterDistributor
bo še vedno zadovoljen s tem, da kot parameter zahteva Logger
. In samo
od nas bo odvisno, katero instanco mu bomo predali.
Zato nikoli ne dajemo imenom vmesnikov pripone Interface
ali predpone I
. Sicer ne bi bilo
mogoče kode tako lepo razvijati.
Houston, imamo problem
Medtem ko si lahko v celotni aplikaciji zadostujemo z eno samo instanco loggerja, bodisi datotečnega ali podatkovnega, in ga
preprosto predajamo povsod tam, kjer se nekaj logira, je povsem drugače v primeru razreda Article
. Njegove instance
namreč ustvarjamo po potrebi, lahko tudi večkrat. Kako se spopasti s povezavo na podatkovno bazo v njegovem konstruktorju?
Kot primer lahko služi kontroler, ki mora po oddaji obrazca shraniti članek v podatkovno bazo:
class EditController extends Controller
{
public function formSubmitted($data)
{
$article = new Article(/* ... */);
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Možna rešitev se ponuja kar sama: pustimo si objekt podatkovne baze predati s konstruktorjem v EditController
in uporabimo $article = new Article($this->db)
.
Enako kot v prejšnjem primeru z Logger
in potjo do datoteke, to ni pravilen postopek. Podatkovna baza ni
odvisnost EditController
, ampak Article
. Predajanje podatkovne baze torej gre proti pravilu št. 2: vzemi, kar je tvoje. Ko se spremeni konstruktor razreda
Article
(doda se nov parameter), bo treba prilagoditi tudi kodo na vseh mestih, kjer se ustvarjajo
instance. Ufff.
Houston, kaj predlagaš?
Pravilo št. 3: prepusti tovarni
S tem, ko smo odpravili skrite povezave in vse odvisnosti predajamo kot argumente, smo dobili bolj nastavljive in prožne razrede. In zato potrebujemo še nekaj drugega, kar nam bo te prožnejše razrede ustvarilo in konfiguriralo. Temu bomo rekli tovarne.
Pravilo se glasi: če ima razred odvisnosti, prepusti ustvarjanje njihovih instanc tovarni.
Tovarne so pametnejša zamenjava za operator new
v svetu dependency injection.
Prosimo, ne zamenjujte z načrtovalskim vzorcem factory method, ki opisuje specifičen način uporabe tovarn in s to temo ni povezan.
Tovarna
Tovarna je metoda ali razred, ki izdeluje in konfigurira objekte. Razred, ki izdeluje Article
, bomo poimenovali
ArticleFactory
in bi lahko izgledal na primer takole:
class ArticleFactory
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function create(): Article
{
return new Article($this->db);
}
}
Njegova uporaba v kontrolerju bo naslednja:
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// pustimo tovarni ustvariti objekt
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Če se v tem trenutku spremeni signatura konstruktorja razreda Article
, je edini del kode, ki se mora na to
odzvati, sama tovarna ArticleFactory
. Vse ostale kode, ki delajo z objekti Article
, kot na primer
EditController
, se to nikakor ne dotakne.
Morda si zdaj trkate po čelu, ali smo si sploh pomagali. Količina kode se je povečala in vse skupaj začenja izgledati sumljivo zapleteno.
Ne skrbite, kmalu bomo prišli do Nette DI vsebnika. In ta ima vrsto asov v rokavu, s katerimi gradnjo aplikacij, ki
uporabljajo dependency injection, neizmerno poenostavi. Tako na primer namesto razreda ArticleFactory
bo zadostovalo
napisati zgolj vmesnik:
interface ArticleFactory
{
function create(): Article;
}
Ampak to prehitevamo, še počakajte :-)
Povzetek
Na začetku tega poglavja smo obljubili, da si bomo pokazali postopek, kako načrtovati čisto kodo. Zadostuje razredom
- predajati odvisnosti, ki jih potrebujejo
- in nasprotno ne predajati, česar neposredno ne potrebujejo
- in da se objekti z odvisnostmi najbolje izdelujejo v tovarnah
Morda se na prvi pogled ne zdi tako, a ta tri pravila imajo daljnosežne posledice. Vodijo k radikalno drugačnemu pogledu na načrtovanje kode. Se splača? Programerji, ki so opustili stare navade in začeli dosledno uporabljati dependency injection, menijo, da je ta korak ključni trenutek v njihovem poklicnem življenju. Odprl se jim je svet preglednih in vzdržljivih aplikacij.
Kaj pa, če koda dosledno ne uporablja dependency injection? Kaj če je zgrajena na statičnih metodah ali singletonih? Ali to prinaša kakšne težave? Prinaša in zelo bistvene.