Kaj je vrivanje odvisnosti?
V tem poglavju so predstavljene osnovne programske prakse, ki jih je treba upoštevati pri pisanju katere koli aplikacije. To so osnove, ki so potrebne za pisanje čiste, razumljive in vzdrževane kode.
Če se naučite teh pravil in jih upoštevate, vam bo Nette pomagal na vsakem koraku. Za vas bo opravila rutinska opravila in poskrbela za čim večje udobje, da se boste lahko osredotočili na samo logiko.
Načela, ki jih bomo prikazali tukaj, so precej preprosta. Ničesar vam ni treba skrbeti.
Se spomnite svojega prvega programa?
Nimamo pojma, v katerem jeziku ste ga napisali, a če je bil PHP, bi bil verjetno videti nekako takole:
function addition(float $a, float $b): float
{
return $a + $b;
}
echo addition(23, 1); // odtisi 24
Nekaj trivialnih vrstic kode, v katerih pa se skriva toliko ključnih konceptov. Da obstajajo spremenljivke. Da je koda razdeljena na manjše enote, ki so na primer funkcije. Da jim posredujemo vhodne argumente in da nam vrnejo rezultate. Manjkajo le še pogoji in zanke.
To, da funkciji posredujemo vhodne argumente in ta vrne rezultat, je povsem razumljiv koncept, ki se uporablja tudi na drugih področjih, na primer v matematiki.
Funkcija ima signaturo, ki je sestavljena iz njenega imena, seznama parametrov in njihovih vrst ter nazadnje vrste vrnjene vrednosti. Kot uporabnike nas zanima signatura; običajno nam ni treba vedeti ničesar o notranji implementaciji.
Predstavljajte si, da je podpis funkcije videti takole:
function addition(float $x): float
Dodatek z enim parametrom? To je čudno… Kaj pa tole?
function addition(): float
To je res čudno, kajne? Kako mislite, da se ta funkcija uporablja?
echo addition(); // kaj natisne?
Ob pogledu na takšno kodo smo zmedeni. Ne samo, da je ne bi razumel začetnik, takšne kode ne bi razumel niti izkušen programer.
Se sprašujete, kako bi bila takšna funkcija dejansko videti v notranjosti? Kje bi dobila seštevalnike? Verjetno bi jih dobila nekako sama od sebe, kot je to:
function addition(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Izkazalo se je, da so v telesu funkcije skrite povezave z drugimi funkcijami (ali statičnimi metodami), in da bi ugotovili, od kod dejansko prihajajo seštevalniki, moramo kopati naprej.
Ne na ta način!
Zasnova, ki smo jo pravkar videli, je bistvo številnih negativnih lastnosti:
- podpis funkcije se je pretvarjal, da ne potrebuje dodatkov, kar nas je zmedlo
- nimamo pojma, kako bi funkcijo pripravili do tega, da bi računala z dvema drugima številoma
- morali smo pogledati v kodo, da smo videli, kje vzame dodatke
- odkrili smo skrite vezi
- za popolno razumevanje moramo raziskati tudi te vezi
In ali je sploh naloga funkcije seštevanja, da pridobiva vhodne podatke? Seveda ni. Njena naloga je le dodajanje.
S takšno kodo se ne želimo srečati in je zagotovo ne želimo pisati. Rešitev je preprosta: vrnite se k osnovam in uporabite samo parametre:
function addition(float $a, float $b): float
{
return $a + $b;
}
Pravilo št. 1: Naj vam ga prenesejo
Najpomembnejše pravilo je: Vse podatke, ki jih potrebujejo funkcije ali razredi, jim je treba posredovati.
Namesto da izumljate skrite mehanizme, ki bi jim pomagali, da bi do njih nekako prišli sami, jim preprosto posredujte parametre. Prihranili boste čas, ki je potreben za izumljanje skritega načina, ki zagotovo ne bo izboljšal vaše kode.
Če boste vedno in povsod upoštevali to pravilo, ste na poti do kode brez skritih vezav. Na poti do kode, ki ni razumljiva le avtorju, temveč tudi vsem, ki jo kasneje preberejo. Kjer je vse razumljivo iz podpisov funkcij in razredov in kjer ni treba iskati skritih skrivnosti v implementaciji.
Ta tehnika se strokovno imenuje vbrizgavanje odvisnosti. Podatki pa se imenujejo odvisnosti, vendar gre za preprosto posredovanje parametrov, nič več.
Ne zamenjujte vbrizgavanja odvisnosti, ki je načrtovalski vzorec, z “vsebnikom za vbrizgavanje odvisnosti”, ki je orodje, nekaj povsem drugega. O vsebnikih bomo razpravljali pozneje.
Od funkcij do razredov
In kako so s tem povezani razredi? Razred je bolj zapletena entiteta kot preprosta funkcija, vendar tudi tu velja pravilo št. 1. Obstaja le več načinov za posredovanje argumentov. Na primer, precej podobno kot pri funkciji:
class Math
{
public function addition(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->addition(23, 1); // 24
ali z uporabo drugih metod ali neposredno s konstruktorjem:
class Addition
{
public function __construct(
private float $a,
private float $b,
) {
}
public function calculate(): float
{
return $this->a + $this->b;
}
}
$addition = new Addition(23, 1);
echo $addition->calculate(); // 24
Oba primera sta v celoti skladna z vbrizgavanjem odvisnosti.
Primeri iz resničnega življenja
V resničnem svetu ne boste pisali razredov za seštevanje števil. Preidimo na primere iz resničnega sveta.
Imejmo razred Article
, ki predstavlja blogovski članek:
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// shranite članek v zbirko podatkov.
}
}
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 tabelo podatkovne zbirke. Implementacija z uporabo podatkovne baze Nette bi bila prava mala malica, če ne bi bilo ene zadrege: kje naj
Article
dobi 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 pa jo podeduje od razreda, ki bo zagotovil povezavo s podatkovno bazo. Ali pa izkoristimo prednosti razreda singleton. Ali pa tako imenovane fasade, 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 pa smo?
Spomnimo se na pravilo št. 1: Naj vam ga prenesejo: vse odvisnosti, ki jih razred potrebuje, mu morajo biti posredovane. Ker če tega ne storimo in prekršimo pravilo, smo se podali na pot umazane kode, polne skritih povezav, nerazumljivosti, rezultat pa bo aplikacija, ki jo je neprijetno vzdrževati in razvijati.
Uporabnik razreda Article
nima pojma, kam metoda save()
shrani članek. V tabeli podatkovne zbirke?
V kateri, produkcijski ali razvojni? In kako je to mogoče spremeniti?
Uporabnik mora pogledati, kako je implementirana metoda save()
, da bi našel uporabo metode
DB::insert()
. Torej mora iskati naprej, da bi ugotovil, kako ta metoda pridobi povezavo s podatkovno bazo. Skrite
vezi pa lahko tvorijo precej dolgo verigo.
Skrite vezi, Laravelove fasade ali statične spremenljivke niso nikoli prisotne v čisti, dobro zasnovani kodi. V čisti in dobro zasnovani kodi se posredujejo 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 v nadaljevanju, je uporabiti konstruktor:
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, boste morda pomislili, da Article
sploh ne bi smel imeti metode
save()
, da bi moral biti čista podatkovna komponenta, za shranjevanje pa bi moralo skrbeti ločeno skladišče. To
je smiselno. Vendar bi to močno preseglo temo, ki je vbrizgavanje odvisnosti, in poskus podajanja preprostih primerov.
Če boste na primer napisali razred, ki za svoje delovanje potrebuje podatkovno zbirko, ne razmišljajte, od kod jo dobiti, temveč naj vam jo posreduje. Morda kot parameter konstruktorja ali druge metode. Razglasite odvisnosti. Izpostavite jih v API svojega 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 menite, ali smo upoštevali pravilo št. 1: Naj vam ga prenesejo?
Nismo.
Ključno informacijo, imenik dnevniške datoteke, razred pridobi iz konstante.
Oglejte si primer uporabe:
$logger = new Logger;
$logger->log('The temperature is 23 °C');
$logger->log('The temperature is 10 °C');
Ali lahko brez poznavanja izvajanja odgovorite na vprašanje, kje so sporočila zapisana? Ali bi lahko sklepali, da je za delovanje potreben obstoj konstante LOG_DIR? In ali bi lahko ustvarili drugi primerek, ki bi pisal na drugo lokacijo? 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 jasnejši, bolj nastavljiv in zato bolj uporaben.
$logger = new Logger('/path/to/log.txt');
$logger->log('The temperature is 15 °C');
Ampak meni je vseeno!
“Ko ustvarim objekt Article in kličem save(), se ne želim ukvarjati s podatkovno bazo, temveč želim le, da se shrani v tisto, ki sem jo določil v konfiguraciji. ”
“Ko uporabljam Logger, želim samo, da se sporočilo zapiše, in se ne želim ukvarjati s tem, kam. Naj se uporabijo globalne nastavitve. ”
To so pravilne pripombe.
Kot primer vzemimo razred, ki pošilja glasila in beleži, kako je to potekalo:
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;
}
}
}
Izboljšani Logger
, ki ne uporablja več konstante LOG_DIR
, zahteva v konstruktorju pot do datoteke.
Kako to rešiti? Razredu NewsletterDistributor
je vseeno, kje so sporočila zapisana, želi jih le zapisati.
Rešitev je spet pravilo št. 1: Naj vam ga prenesejo: vse podatke, ki jih razred potrebuje, mu posredujemo.
Tako konstruktorju posredujemo pot do dnevnika, ki ga nato uporabimo za ustvarjanje objekta Logger
?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ NOT THIS WAY!
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Ne tako! Kajti pot ne pripada podatkom, ki jih potrebuje razred NewsletterDistributor
; ta potrebuje
Logger
. Razred potrebuje sam logger. In to je tisto, kar bomo posredovali naprej:
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;
}
}
}
Zdaj je iz podpisov razreda NewsletterDistributor
jasno razvidno, da je beleženje del njegove funkcionalnosti. In
naloga zamenjave loggerja z drugim, morda za namene testiranja, je precej trivialna. Poleg tega, če spremenimo konstruktor
razreda Logger
, to ne bo imelo nobenega vpliva na naš razred.
Pravilo št. 2: Vzemite, kar je vaše
Ne pustite se zavesti in ne dovolite, da bi vam bili posredovani parametri vaših odvisnosti. Odvisnosti posredujejte neposredno.
Tako bo koda, ki uporablja druge predmete, popolnoma neodvisna od sprememb njihovih konstruktorjev. Njen programski vmesnik API bo resničnejši. In kar je najpomembneje, te odvisnosti bo trivialno zamenjati z drugimi.
Nov član družine
Razvojna skupina se je odločila, da bo ustvarila drugi logger, ki bo pisal v podatkovno zbirko. Zato smo ustvarili razred
DatabaseLogger
. Tako imamo dva razreda, Logger
in DatabaseLogger
, eden piše v datoteko,
drugi pa v podatkovno zbirko … se vam ne zdi, da je v tem imenu nekaj čudnega? Ali ne bi bilo bolje preimenovati
Logger
v FileLogger
? Zagotovo bi bilo.
Toda naredimo to pametno. Ustvarili bomo vmesnik pod prvotnim imenom:
interface Logger
{
function log(string $message): void;
}
…ki ga bosta implementirala oba loggerja:
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
Na ta način ne bo treba ničesar spreminjati v preostalem delu kode, kjer se uporablja dnevnik. Na primer, konstruktor
razreda NewsletterDistributor
bo še vedno zadovoljen s tem, da bo kot parameter zahteval Logger
. Od
nas pa bo odvisno, kateri primerek mu bomo posredovali.
Tudi zato imenom vmesnikov nikoli ne dajemo končnice Interface
ali predpone I
. V nasprotnem
primeru bi bilo nemogoče razviti tako lepo kodo.
Houston, imamo težavo
Medtem ko smo v celotni aplikaciji lahko zadovoljni z enim samim primerkom loggerja, ne glede na to, ali gre za datoteko ali
zbirko podatkov, in ga preprosto posredujemo povsod, kjer se kaj beleži, pa je v primeru razreda Article
povsem
drugače. Dejansko ustvarjamo njegove instance po potrebi, po možnosti večkrat. Kako ravnati z vezavo na podatkovno zbirko
v njegovem konstruktorju?
Kot primer lahko uporabimo krmilnik, ki naj bi po oddaji obrazca shranil članek v zbirko podatkov:
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 neposredno: objekt podatkovne zbirke naj konstruktor posreduje na naslov EditController
in uporabi $article = new Article($this->db)
.
Tako kot v prejšnjem primeru s Logger
in potjo do datoteke to ni pravilen pristop. Podatkovna baza ni odvisna od
EditController
, temveč od Article
. Zato je posredovanje podatkovne baze v nasprotju s pravilom 2: vzemi, kar je tvoje. Ko spremenimo konstruktor razreda Article
(dodamo nov parameter), bo treba spremeniti tudi kodo na vseh mestih, kjer se ustvarjajo primerki. Ufff.
Houston, kaj predlagate?
Pravilo št. 3: Pustite, da se s tem ukvarja tovarna
Z odstranitvijo skritih vezi in posredovanjem vseh odvisnosti kot argumentov dobimo bolj nastavljive in prilagodljive razrede. Zato potrebujemo nekaj drugega za ustvarjanje in konfiguriranje teh bolj prilagodljivih razredov. Imenovali ga bomo tovarne.
Velja pravilo: če ima razred odvisnosti, ustvarjanje njihovih primerkov prepustite tovarni.
Tovarne so pametnejša zamenjava za operator new
v svetu vbrizgavanja odvisnosti.
Tovarna
Tovarna je metoda ali razred, ki proizvaja in konfigurira predmete. Razred Article
, ki proizvaja, imenujemo
ArticleFactory
in je lahko videti takole:
class ArticleFactory
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function create(): Article
{
return new Article($this->db);
}
}
Njegova uporaba v krmilniku bi bila naslednja:
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// naj tovarna ustvari predmet
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Na tej točki je edini del kode, ki se mora odzvati na spremembo podpisa konstruktorja razreda Article
, sama
tovarna ArticleFactory
. Na nobeno drugo kodo, ki dela z objekti Article
, kot je
EditController
, to ne bo vplivalo.
Morda se zdaj trkate po čelu in se sprašujete, ali smo si sploh pomagali. Količina kode se je povečala in celotna stvar je videti sumljivo zapletena.
Ne skrbite, kmalu bomo prišli do vsebnika Nette DI. Ta ima v rokavu številne ase, s katerimi bo gradnja aplikacij
z uporabo vbrizgavanja odvisnosti izjemno preprosta. Namesto razreda ArticleFactory
bo na primer dovolj, da napišemo preprost vmesnik:
interface ArticleFactory
{
function create(): Article;
}
Toda prehitevamo, počakajte :-)
Povzetek
Na začetku tega poglavja smo vam obljubili, da vam bomo pokazali način oblikovanja čiste kode. Samo dajte razredom
- odvisnosti, ki jih potrebujejo
- in ne tistih, ki jih neposredno ne potrebujejo
- in da je predmete z odvisnostmi najbolje izdelati v tovarnah
Morda se na prvi pogled ne zdi tako, vendar imajo ta tri pravila daljnosežne posledice. Privedejo do korenito drugačnega pogleda na oblikovanje kode. Ali je vredno? Programerji, ki so zavrgli stare navade in začeli dosledno uporabljati vbrizgavanje odvisnosti, menijo, da je to ključni trenutek v njihovem poklicnem življenju. Odprl jim je svet jasnih in trajnostnih aplikacij.
Kaj pa, če koda ne uporablja dosledno vbrizgavanja odvisnosti? Kaj pa, če je zgrajena na statičnih metodah ali singletonih? Ali to prinaša kakšne težave? Težave so, in to zelo velike.