Kaj je vrivanje odvisnosti?

V tem poglavju boste spoznali osnovne programerske prakse, ki jih morate 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 opravljala rutinska opravila in zagotavljala največje udobje, tako da se boste lahko osredotočili na samo logiko.

Načela, ki jih bomo prikazali tukaj, so precej preprosta. Ni vam treba skrbeti za ničesar.

Se spomnite svojega prvega programa?

Ne vemo, v katerem jeziku ste ga napisali, vendar če je bil PHP, bi bil lahko 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.

Dejstvo, da funkcija sprejme vhodne podatke in vrne rezultat, je povsem razumljiv koncept, ki se uporablja tudi na drugih področjih, na primer v matematiki.

Funkcija ima svoj podpis, ki je sestavljen iz njenega imena, seznama parametrov in njihovih tipov ter tipa vrnjene vrednosti. Kot uporabnike nas zanima signatura in nam običajno ni treba vedeti ničesar o notranji implementaciji.

Predstavljajte si, da bi bil podpis funkcije videti takole:

function addition(float $x): float

Dodatek z enim parametrom? To je čudno… Kaj pa to?

function addition(): float

To je res čudno, kajne? Kako se funkcija uporablja?

echo addition(); // kaj natisne?

Ob pogledu na takšno kodo bi bili zmedeni. Ne le, da je ne bi razumel začetnik, tudi izkušen programer ne bi razumel takšne kode.

Se sprašujete, kako bi bila takšna funkcija dejansko videti v notranjosti? Kje bi dobila vsote? Verjetno bi jih nekako dobila sama, morda takole:

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 prikazali, je bistvo številnih negativnih lastnosti:

  • podpis funkcije se je pretvarjal, da ne potrebuje seštevkov, kar nas je zmotilo
  • nimamo pojma, kako bi funkcijo pripravili do tega, da bi računala z dvema drugima številoma
  • morali smo pogledati kodo, da smo ugotovili, od kod prihajajo seštevki
  • našli smo skrite odvisnosti
  • za popolno razumevanje je treba preučiti tudi te odvisnosti

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 bi izumljali skrite načine, kako sami dostopajo do podatkov, jim preprosto posredujte parametre. Prihranili boste čas, ki bi ga porabili za izumljanje skritih poti, ki zagotovo ne bodo izboljšale vaše kode.

Če boste vedno in povsod upoštevali to pravilo, ste na poti do kode brez skritih odvisnosti. Do kode, ki je razumljiva ne le avtorju, temveč tudi vsakomur, ki jo prebere pozneje. Kjer je vse razumljivo iz podpisov funkcij in razredov in ni treba iskati skritih skrivnosti v implementaciji.

Ta tehnika se strokovno imenuje vbrizgavanje odvisnosti. Ti podatki pa se imenujejo odvisnosti. To je le običajno posredovanje parametrov, nič več.

Ne zamenjujte vbrizgavanja odvisnosti, ki je načrtovalski vzorec, z “vsebnikom za vbrizgavanje odvisnosti”, ki je orodje, nekaj diametralno različnega. S kontejnerji se bomo ukvarjali pozneje.

Od funkcij do razredov

In kako so razredi povezani? Razred je bolj zapletena enota kot preprosta funkcija, vendar tudi tu v celoti velja pravilo št. 1. Obstaja le več načinov za posredovanje argumentov. Na primer, zelo 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 prek drugih metod ali neposredno prek konstruktorja:

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 številk. Preidimo na praktične primere.

Imejmo razred Article, ki predstavlja objavo na blogu:

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() bo članek shranila v tabelo podatkovne zbirke. Implementacija z uporabo podatkovne baze Nette bo prava mala malica, če ne bi bilo ene zadrege: kje Article dobi povezavo s podatkovno bazo, tj. objekt razreda Nette\Database\Connection?

Zdi se, da imamo veliko možnosti. Lahko jo vzame nekje iz statične spremenljivke. Ali pa podeduje od razreda, ki zagotavlja povezavo s podatkovno bazo. Ali pa izkoristi prednosti enojnega razreda (singleton). Ali pa uporabimo 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 bo posredovano: vse odvisnosti, ki jih razred potrebuje, mu morajo biti posredovane. Če namreč prekršimo to pravilo, smo se podali na pot umazane kode, polne skritih odvisnosti, nerazumljivosti, rezultat pa bo aplikacija, ki jo bo boleče vzdrževati in razvijati.

Uporabnik razreda Article nima pojma, kam metoda save() shrani članek. V tabeli podatkovne zbirke? V kateri, produkcijski ali testni? In kako jo lahko spremeni?

Uporabnik mora pogledati, kako je implementirana metoda save(), in najde uporabo metode DB::insert(). Torej mora iskati naprej, da bi ugotovil, kako ta metoda pridobi povezavo s podatkovno bazo. In skrite odvisnosti lahko tvorijo precej dolgo verigo.

V čisti in dobro zasnovani kodi nikoli ni skritih odvisnosti, Laravelovih fasad ali statičnih spremenljivk. 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čen pristop, kot bomo videli pozneje, je uporaba konstruktorja:

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(); predstavljal bi izključno podatkovno komponento, za shranjevanje pa bi moral skrbeti ločen repozitorij. To je smiselno. Toda to bi daleč preseglo obseg teme, ki je vbrizgavanje odvisnosti, in prizadevanje, da bi navedli preproste primere.

Če napišete razred, ki za svoje delovanje potrebuje na primer podatkovno zbirko, si ne izmišljujte, od kod jo dobiti, temveč naj bo posredovana. Bodisi kot parameter konstruktorja ali druge metode. Priznajte odvisnosti. Priznajte 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čne informacije, tj. imenik z datoteko dnevnika, 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 zapisana sporočila? Ali bi uganili, da je obstoj konstante LOG_DIR potreben za njeno delovanje? 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 bolj razumljiv, nastavljiv in s tem 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; ž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 veljavne pripombe.

Kot primer si oglejmo razred, ki pošilja glasila in beleži, kako je potekalo pošiljanje:

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 navedbo poti do datoteke v konstruktorju. 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: Predajajte vse podatke, ki jih razred potrebuje.

Ali to torej pomeni, da skozi konstruktor posredujemo pot do dnevnika, ki jo nato uporabimo pri ustvarjanju predmeta Logger?

class NewsletterDistributor
{
	public function __construct(
		private string $file, // ⛔ NOT THIS WAY!
	) {
	}

	public function distribute(): void
	{
		$logger = new Logger($this->file);

Ne, ne tako! Pot ne sodi med podatke, ki jih potrebuje razred NewsletterDistributor; pravzaprav jih potrebuje razred Logger. Ali vidite razliko? Razred NewsletterDistributor potrebuje sam dnevnik. Zato bomo posredovali le tega:

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 razvidno, da je del njegove funkcionalnosti tudi beleženje. In naloga zamenjave loggerja z drugim, morda za testiranje, je povsem trivialna. Poleg tega, če se konstruktor razreda Logger spremeni, to ne bo vplivalo na naš razred.

Pravilo 2: Vzemi, kar je tvoje

Ne pustite se zavajati in ne dovolite, da bi prešli v odvisnost od vaših odvisnikov. Prepustite le svoje lastne odvisnosti.

Zaradi tega bo koda, ki uporablja druge predmete, popolnoma neodvisna od sprememb v njihovih konstruktorjih. Njen API bo bolj resničen. Predvsem pa bo te odvisnosti trivialno zamenjati z drugimi.

Novi član družine

Razvojna skupina se je odločila, da bo ustvarila drugi logger, ki bo pisal v podatkovno zbirko. Zato ustvarimo razred DatabaseLogger. Torej imamo dva razreda, Logger in DatabaseLogger, eden piše v datoteko, drugi v podatkovno zbirko … se vam poimenovanje ne zdi čudno? Ali ne bi bilo bolje preimenovati Logger v FileLogger? Vsekakor da.

Toda naredimo to pametno. Ustvarimo vmesnik pod prvotnim imenom:

interface Logger
{
	function log(string $message): void;
}

… ki ga bosta izvajala oba zapisovalnika:

class FileLogger implements Logger
// ...

class DatabaseLogger implements Logger
// ...

Zaradi tega v preostalem delu kode, kjer se dnevnik uporablja, ne bo treba ničesar spreminjati. Na primer, konstruktor razreda NewsletterDistributor se bo še vedno zadovoljil s tem, da bo kot parameter zahteval Logger. Od nas pa bo odvisno, kateri primerek bomo posredovali.

Zato imenom vmesnikov nikoli ne dodajamo končnice Interface ali predpone I. V nasprotnem primeru kode ne bi bilo mogoče tako lepo razviti.

Houston, imamo težavo

Medtem ko lahko v celotni aplikaciji uporabimo en sam primerek loggerja, ki temelji na datoteki ali podatkovni zbirki, in ga preprosto posredujemo povsod, kjer se kaj beleži, je pri razredu Article precej drugače. Njegove instance ustvarjamo po potrebi, tudi večkrat. Kako ravnati z odvisnostjo od podatkovne zbirke v njegovem konstruktorju?

Primer je lahko krmilnik, ki mora po oddaji obrazca shraniti č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 je očitna: konstruktorju EditController posredujemo objekt podatkovne zbirke in uporabimo $article = new Article($this->db).

Tako kot v prejšnjem primeru s Logger in potjo do datoteke to ni pravi pristop. Podatkovna baza ni odvisna od EditController, temveč od Article. Posredovanje podatkovne baze je v nasprotju s pravilom 2: vzemi, kar je tvoje. Če se konstruktor razreda Article spremeni (doda se nov parameter), boste morali spremeniti kodo povsod, kjer se ustvarjajo primerki. Ufff.

Houston, kaj predlagate?

Pravilo št. 3: Pustite, da se s tem ukvarja tovarna

Z odpravo skritih odvisnosti in posredovanjem vseh odvisnosti kot argumentov smo pridobili bolj nastavljive in prilagodljive razrede. Zato potrebujemo nekaj drugega, kar bo ustvarilo in konfiguriralo te bolj prilagodljive razrede za nas. Imenovali ga bomo tovarne.

Velja pravilo: če ima razred odvisnosti, ustvarjanje njihovih primerkov prepustite tovarni.

Tovarne so pametnejša zamenjava za operater new v svetu vbrizgavanja odvisnosti.

Ne zamenjujte z oblikovnim vzorcem factory method, ki opisuje poseben način uporabe tovarn in ni povezan s to temo.

Tovarna

Tovarna je metoda ali razred, ki ustvarja in konfigurira predmete. Razred, ki proizvaja Article, bomo poimenovali ArticleFactory, izgledal pa bi lahko takole:

class ArticleFactory
{
	public function __construct(
		private Nette\Database\Connection $db,
	) {
	}

	public function create(): Article
	{
		return new Article($this->db);
	}
}

Njegova uporaba v krmilniku je 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();
	}
}

Če se spremeni podpis konstruktorja razreda Article, je na tej točki edini del kode, ki se mora odzvati, sam konstruktor ArticleFactory. Na vso drugo kodo, ki dela z objekti Article, kot je EditController, to ne bo vplivalo.

Morda se sprašujete, ali smo stvari dejansko izboljšali. Količina kode se je povečala in vse skupaj je videti sumljivo zapleteno.

Ne skrbite, kmalu bomo prišli do vsebnika Nette DI. Ta pa ima v rokavu več trikov, ki bodo močno poenostavili gradnjo aplikacij z uporabo vbrizgavanja odvisnosti. Na primer, namesto razreda ArticleFactory boste morali napisati le preprost vmesnik:

interface ArticleFactory
{
	function create(): Article;
}

Vendar prehitevamo sami sebe; bodite potrpežljivi :-)

Povzetek

Na začetku tega poglavja smo vam obljubili, da vam bomo predstavili postopek za oblikovanje čiste kode. Vse, kar je potrebno, je, da razredi:

Na prvi pogled se zdi, da ta tri pravila nimajo daljnosežnih posledic, vendar vodijo do korenito drugačnega pogleda na oblikovanje kode. Ali je vredno? Razvijalci, ki so opustili stare navade in začeli dosledno uporabljati vbrizgavanje odvisnosti, menijo, da je ta korak ključen trenutek v njihovem poklicnem življenju. Z njim se jim je odprl svet preglednih in vzdržljivih aplikacij.

Kaj pa, če koda ne uporablja dosledno vbrizgavanja odvisnosti? Kaj pa, če se zanaša na statične metode ali enojne metode? Ali to povzroča težave? Da, povzroča, in to zelo temeljne.

različica: 3.x