Co to jest Wstrzykiwanie Zależności?

Ten rozdział wprowadzi Cię w podstawowe praktyki programistyczne, których powinieneś przestrzegać podczas pisania wszystkich aplikacji. Są to podstawy niezbędne do pisania czystego, zrozumiałego i łatwego w utrzymaniu kodu.

Jeśli przyswoisz sobie te zasady i będziesz ich przestrzegać, Nette będzie Cię wspierać na każdym kroku. Zajmie się za Ciebie rutynowymi zadaniami i zapewni maksymalną wygodę, abyś mógł skupić się na samej logice.

Zasady, które tutaj przedstawimy, są przy tym całkiem proste. Nie musisz się niczego obawiać.

Pamiętasz swój pierwszy program?

Nie wiemy, w jakim języku go napisałeś, ale gdyby to było PHP, prawdopodobnie wyglądałby jakoś tak:

function soucet(float $a, float $b): float
{
	return $a + $b;
}

echo soucet(23, 1); // wypisze 24

Kilka trywialnych linii kodu, a jednak kryje się w nich tyle kluczowych koncepcji. Że istnieją zmienne. Że kod dzieli się na mniejsze jednostki, jakimi są na przykład funkcje. Że przekazujemy im argumenty wejściowe, a one zwracają wyniki. Brakuje tam już tylko warunków i pętli.

To, że funkcji przekazujemy dane wejściowe, a ona zwraca wynik, jest doskonale zrozumiałym konceptem, stosowanym również w innych dziedzinach, jak na przykład w matematyce.

Funkcja ma swoją sygnaturę, którą tworzy jej nazwa, przegląd parametrów i ich typów, a na końcu typ zwracanej wartości. Jako użytkowników interesuje nas sygnatura, o wewnętrznej implementacji zazwyczaj nie potrzebujemy nic wiedzieć.

Teraz wyobraź sobie, że sygnatura funkcji wyglądałaby tak:

function soucet(float $x): float

Suma z jednym parametrem? To dziwne… A co na przykład tak?

function soucet(): float

To już jest naprawdę bardzo dziwne, prawda? Jak się takiej funkcji używa?

echo soucet(); // co to może wypisać?

Patrząc na taki kod, bylibyśmy zdezorientowani. Nie tylko początkujący by go nie zrozumiał, takiego kodu nie rozumie nawet doświadczony programista.

Zastanawiasz się, jak właściwie taka funkcja wyglądałaby w środku? Skąd wzięłaby składniki sumy? Prawdopodobnie w jakiś sposób załatwiłaby je sobie sama, na przykład tak:

function soucet(): float
{
	$a = Input::get('a');
	$b = Input::get('b');
	return $a + $b;
}

W ciele funkcji odkryliśmy ukryte powiązania z innymi globalnymi funkcjami lub metodami statycznymi. Aby dowiedzieć się, skąd naprawdę biorą się składniki sumy, musimy szukać dalej.

Tędy nie!

Projekt, który właśnie przedstawiliśmy, jest esencją wielu negatywnych cech:

  • sygnatura funkcji udawała, że nie potrzebuje składników sumy, co nas myliło
  • w ogóle nie wiemy, jak zmusić funkcję do zsumowania dwóch innych liczb
  • musieliśmy zajrzeć do kodu, aby dowiedzieć się, skąd bierze składniki sumy
  • odkryliśmy ukryte powiązania
  • do pełnego zrozumienia trzeba zbadać również te powiązania

A czy w ogóle zadaniem funkcji sumującej jest pozyskiwanie danych wejściowych? Oczywiście, że nie. Jej odpowiedzialnością jest tylko samo sumowanie.

Z takim kodem nie chcemy się spotykać i zdecydowanie nie chcemy go pisać. Naprawa jest przy tym prosta: wrócić do podstaw i po prostu użyć parametrów:

function soucet(float $a, float $b): float
{
	return $a + $b;
}

Zasada nr 1: niech ci to przekażą

Najważniejsza zasada brzmi: wszystkie dane, których funkcje lub klasy potrzebują, muszą być im przekazane.

Zamiast wymyślać ukryte sposoby, za pomocą których mogłyby jakoś same do nich dotrzeć, po prostu przekaż parametry. Zaoszczędzisz czas potrzebny na wymyślanie ukrytych ścieżek, które zdecydowanie nie ulepszą twojego kodu.

Jeśli będziesz przestrzegać tej zasady zawsze i wszędzie, jesteś na drodze do kodu bez ukrytych powiązań. Do kodu, który jest zrozumiały nie tylko dla autora, ale i dla każdego, kto będzie go po nim czytał. Gdzie wszystko jest zrozumiałe z sygnatur funkcji i klas i nie trzeba szukać ukrytych tajemnic w implementacji.

Tej technice fachowo mówi się wstrzykiwanie zależności (dependency injection). A tym danym mówi się zależności. Przy czym jest to zwykłe przekazywanie parametrów, nic więcej.

Proszę nie mylić wstrzykiwania zależności, które jest wzorcem projektowym, z „kontenerem wstrzykiwania zależności” (dependency injection container), który jest z kolei narzędziem, czyli czymś diametralnie innym. Kontenerami DI zajmiemy się później.

Od funkcji do klas

A jak to się ma do klas? Klasa jest bardziej złożoną całością niż prosta funkcja, niemniej jednak zasada nr 1 obowiązuje bez wyjątku również tutaj. Istnieje tylko więcej możliwości przekazania argumentów. Na przykład całkiem podobnie jak w przypadku funkcji:

class Matematika
{
	public function soucet(float $a, float $b): float
	{
		return $a + $b;
	}
}

$math = new Matematika;
echo $math->soucet(23, 1); // 24

Lub za pomocą innych metod, czy bezpośrednio konstruktora:

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 przykłady są całkowicie zgodne z wstrzykiwaniem zależności.

Prawdziwe przykłady

W prawdziwym świecie nie będziesz pisać klas do sumowania liczb. Przejdźmy do przykładów z praktyki.

Mamy klasę Article reprezentującą artykuł na blogu:

class Article
{
	public int $id;
	public string $title;
	public string $content;

	public function save(): void
	{
		// zapiszemy artykuł do bazy danych
	}
}

a użycie będzie następujące:

$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() zapisze artykuł do tabeli w bazie danych. Zaimplementowanie jej za pomocą Nette Database byłoby dziecinnie proste, gdyby nie jeden haczyk: skąd Article ma wziąć połączenie z bazą danych, tj. obiekt klasy Nette\Database\Connection?

Wydaje się, że mamy wiele możliwości. Może je wziąć skądś ze zmiennej statycznej. Lub dziedziczyć po klasie, która zapewni połączenie z bazą danych. Lub wykorzystać tzw. singleton. Lub tzw. fasady (facades), które są używane w 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],
		);
	}
}

Świetnie, problem rozwiązany.

Czy na pewno?

Przypomnijmy #zasadę nr 1: niech ci to przekażą: wszystkie zależności, których klasa potrzebuje, muszą być jej przekazane. Ponieważ jeśli naruszymy tę zasadę, wkroczyliśmy na drogę do brudnego kodu pełnego ukrytych powiązań, niezrozumiałości, a wynikiem będzie aplikacja, której utrzymanie i rozwój będą bolesne.

Użytkownik klasy Article nie ma pojęcia, gdzie metoda save() zapisuje artykuł. Do tabeli w bazie danych? Do której, produkcyjnej czy testowej? I jak to można zmienić?

Użytkownik musi zajrzeć, jak zaimplementowana jest metoda save(), i znajduje użycie metody DB::insert(). Musi więc szukać dalej, jak ta metoda pozyskuje połączenie z bazą danych. A ukryte powiązania mogą tworzyć całkiem długi łańcuch.

W czystym i dobrze zaprojektowanym kodzie nigdy nie występują ukryte powiązania, fasady Laravela czy zmienne statyczne. W czystym i dobrze zaprojektowanym kodzie przekazuje się argumenty:

class Article
{
	public function save(Nette\Database\Connection $db): void
	{
		$db->query('INSERT INTO articles', [
			'title' => $this->title,
			'content' => $this->content,
		]);
	}
}

Jeszcze bardziej praktyczne, jak zobaczymy dalej, będzie to przez 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,
		]);
	}
}

Jeśli jesteś doświadczonym programistą, być może myślisz, że Article w ogóle nie powinien mieć metody save(), powinien reprezentować czysto komponent danych, a o zapisywaniu powinien dbać oddzielny repozytorium. To ma sens. Ale tym wyszlibyśmy daleko poza zakres tematu, którym jest wstrzykiwanie zależności, i starania o podawanie prostych przykładów.

Jeśli będziesz pisać klasę wymagającą do swojego działania np. bazy danych, nie wymyślaj, skąd ją zdobyć, ale pozwól sobie ją przekazać. Na przykład jako parametr konstruktora lub innej metody. Przyznaj się do zależności. Przyznaj się do nich w API swojej klasy. Zyskasz zrozumiały i przewidywalny kod.

A co na przykład z tą klasą, która loguje komunikaty błędów:

class Logger
{
	public function log(string $message)
	{
		$file = LOG_DIR . '/log.txt';
		file_put_contents($file, $message . "\n", FILE_APPEND);
	}
}

Co myślisz, czy przestrzegaliśmy #zasady nr 1: niech ci to przekażą?

Nie przestrzegaliśmy.

Kluczową informację, czyli katalog z plikiem logu, klasa pozyskuje sama ze stałej.

Spójrz na przykład użycia:

$logger = new Logger;
$logger->log('Temperatura wynosi 23 °C');
$logger->log('Temperatura wynosi 10 °C');

Bez znajomości implementacji, czy potrafiłbyś odpowiedzieć na pytanie, gdzie zapisywane są komunikaty? Czy przyszłoby ci do głowy, że do działania potrzebna jest stała LOG_DIR? I czy potrafiłbyś utworzyć drugą instancję, która będzie zapisywać gdzie indziej? Na pewno nie.

Poprawmy klasę:

class Logger
{
	public function __construct(
		private string $file,
	) {
	}

	public function log(string $message): void
	{
		file_put_contents($this->file, $message . "\n", FILE_APPEND);
	}
}

Klasa jest teraz znacznie bardziej zrozumiała, konfigurowalna, a zatem bardziej użyteczna.

$logger = new Logger('/sciezka/do/logu.txt');
$logger->log('Temperatura wynosi 15 °C');

Ale to mnie nie obchodzi!

„Kiedy tworzę obiekt Article i wywołuję save(), nie chcę zajmować się bazą danych, po prostu chcę, żeby zapisał się do tej, którą mam ustawioną w konfiguracji.”

„Kiedy używam Loggera, po prostu chcę, żeby komunikat został zapisany, i nie chcę zajmować się tym, gdzie. Niech użyje globalnych ustawień.”

To są słuszne uwagi.

Jako przykład pokażemy klasę rozsyłającą newslettery, która zaloguje, jak poszło:

class NewsletterDistributor
{
	public function distribute(): void
	{
		$logger = new Logger(/* ... */);
		try {
			$this->sendEmails();
			$logger->log('E-maile zostały rozesłane');

		} catch (Exception $e) {
			$logger->log('Wystąpił błąd podczas rozsyłania');
			throw $e;
		}
	}
}

Ulepszony Logger, który już nie używa stałej LOG_DIR, wymaga w konstruktorze podania ścieżki do pliku. Jak to rozwiązać? Klasę NewsletterDistributor w ogóle nie interesuje, gdzie zapisywane są komunikaty, chce je tylko zapisać.

Rozwiązaniem jest ponownie #zasada nr 1: niech ci to przekażą: wszystkie dane, których klasa potrzebuje, przekazujemy jej.

Czy to oznacza, że przez konstruktor przekażemy ścieżkę do logu, którą następnie użyjemy przy tworzeniu obiektu Logger?

class NewsletterDistributor
{
	public function __construct(
		private string $file, // ⛔ TAK NIE!
	) {
	}

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

Tak nie! Ścieżka bowiem nie należy do danych, których potrzebuje klasa NewsletterDistributor; te bowiem potrzebuje Logger. Czy dostrzegasz różnicę? Klasa NewsletterDistributor potrzebuje loggera jako takiego. Więc to jego sobie przekażemy:

class NewsletterDistributor
{
	public function __construct(
		private Logger $logger, // ✅
	) {
	}

	public function distribute(): void
	{
		try {
			$this->sendEmails();
			$this->logger->log('E-maile zostały rozesłane');

		} catch (Exception $e) {
			$this->logger->log('Wystąpił błąd podczas rozsyłania');
			throw $e;
		}
	}
}

Teraz z sygnatur klasy NewsletterDistributor jest jasne, że częścią jej funkcjonalności jest również logowanie. A zadanie wymiany loggera na inny, na przykład w celu testowania, jest całkowicie trywialne. Co więcej, jeśli konstruktor klasy Logger się zmieni, nie będzie to miało żadnego wpływu na naszą klasę.

Zasada nr 2: bierz, co twoje

Nie daj się zwieść i nie pozwól sobie przekazywać zależności swoich zależności. Pozwól sobie przekazywać tylko swoje zależności.

Dzięki temu kod wykorzystujący inne obiekty będzie całkowicie niezależny od zmian ich konstruktorów. Jego API będzie bardziej prawdziwe. A przede wszystkim będzie trywialne wymienić te zależności na inne.

Nowy członek rodziny

W zespole deweloperskim podjęto decyzję o stworzeniu drugiego loggera, który zapisuje do bazy danych. Stworzymy więc klasę DatabaseLogger. Mamy więc dwie klasy, Logger i DatabaseLogger, jedna zapisuje do pliku, druga do bazy danych… czy nie wydaje ci się, że w tej nazwie jest coś dziwnego? Czy nie byłoby lepiej zmienić nazwę Logger na FileLogger? Na pewno tak.

Ale zrobimy to sprytnie. Pod pierwotną nazwą stworzymy interfejs:

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

… który oba loggery będą implementować:

class FileLogger implements Logger
// ...

class DatabaseLogger implements Logger
// ...

A dzięki temu nie będzie trzeba niczego zmieniać w reszcie kodu, gdzie używany jest logger. Na przykład konstruktor klasy NewsletterDistributor nadal będzie zadowolony z tego, że jako parametr wymaga Logger. A od nas będzie zależeć, którą instancję mu przekażemy.

Dlatego nigdy nie dodajemy do nazw interfejsów przyrostka Interface ani przedrostka I. W przeciwnym razie nie byłoby możliwe tak ładnie rozwijać kodu.

Houston, mamy problem

Podczas gdy w całej aplikacji możemy sobie poradzić z jedną instancją loggera, czy to plikowego, czy bazodanowego, i po prostu przekazujemy go wszędzie tam, gdzie coś jest logowane, zupełnie inaczej jest w przypadku klasy Article. Jej instancje bowiem tworzymy w miarę potrzeb, nawet wielokrotnie. Jak poradzić sobie z powiązaniem z bazą danych w jej konstruktorze?

Jako przykład może posłużyć kontroler, który po wysłaniu formularza ma zapisać artykuł do bazy danych:

class EditController extends Controller
{
	public function formSubmitted($data)
	{
		$article = new Article(/* ... */);
		$article->title = $data->title;
		$article->content = $data->content;
		$article->save();
	}
}

Możliwe rozwiązanie nasuwa się samo: przekażemy sobie obiekt bazy danych konstruktorem do EditController i użyjemy $article = new Article($this->db).

Tak jak w poprzednim przypadku z Logger i ścieżką do pliku, to nie jest właściwe postępowanie. Baza danych nie jest zależnością EditController, ale Article. Przekazywanie sobie bazy danych jest więc sprzeczne z zasadą nr 2: bierz, co twoje. Kiedy zmieni się konstruktor klasy Article (pojawi się nowy parametr), konieczne będzie również zmodyfikowanie kodu we wszystkich miejscach, gdzie tworzone są instancje. Ufff.

Houston, co proponujesz?

Zasada nr 3: zostaw to fabryce

Dzięki temu, że zlikwidowaliśmy ukryte powiązania i wszystkie zależności przekazujemy jako argumenty, zyskaliśmy bardziej konfigurowalne i elastyczne klasy. A zatem potrzebujemy jeszcze czegoś innego, co nam te bardziej elastyczne klasy utworzy i skonfiguruje. Będziemy to nazywać fabrykami.

Zasada brzmi: jeśli klasa ma zależności, zostaw tworzenie ich instancji fabryce.

Fabryki są sprytniejszym zamiennikiem operatora new w świecie wstrzykiwania zależności.

Proszę nie mylić z wzorcem projektowym factory method, który opisuje specyficzny sposób wykorzystania fabryk i nie ma związku z tym tematem.

Fabryka

Fabryka to metoda lub klasa, która produkuje i konfiguruje obiekty. Klasę produkującą Article nazwiemy ArticleFactory i mogłaby wyglądać na przykład tak:

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

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

Jej użycie w kontrolerze będzie następujące:

class EditController extends Controller
{
	public function __construct(
		private ArticleFactory $articleFactory,
	) {
	}

	public function formSubmitted($data)
	{
		// pozwolimy fabryce utworzyć obiekt
		$article = $this->articleFactory->create();
		$article->title = $data->title;
		$article->content = $data->content;
		$article->save();
	}
}

Gdy w tym momencie zmieni się sygnatura konstruktora klasy Article, jedyną częścią kodu, która musi na to zareagować, jest sama fabryka ArticleFactory. Całego pozostałego kodu, który pracuje z obiektami Article, jak na przykład EditController, to w żaden sposób nie dotknie.

Być może teraz pukasz się w czoło, czy w ogóle sobie pomogliśmy. Ilość kodu wzrosła i całość zaczyna wyglądać podejrzanie skomplikowanie.

Nie martw się, za chwilę dojdziemy do kontenera Nette DI. A ten ma wiele asów w rękawie, którymi budowanie aplikacji wykorzystujących wstrzykiwanie zależności niezmiernie uprości. Na przykład zamiast klasy ArticleFactory wystarczy napisać tylko interfejs:

interface ArticleFactory
{
	function create(): Article;
}

Ale wyprzedzamy fakty, jeszcze wytrzymaj :-)

Podsumowanie

Na początku tego rozdziału obiecywaliśmy, że pokażemy sposób projektowania czystego kodu. Wystarczy klasom

  1. przekazywać zależności, których potrzebują
  2. i odwrotnie, nie przekazywać tego, czego bezpośrednio nie potrzebują
  3. oraz że obiekty z zależnościami najlepiej tworzyć w fabrykach

Na pierwszy rzut oka może się tak nie wydawać, ale te trzy zasady mają dalekosiężne konsekwencje. Prowadzą do radykalnie innego spojrzenia na projektowanie kodu. Czy warto? Programiści, którzy porzucili stare nawyki i zaczęli konsekwentnie używać wstrzykiwania zależności, uważają ten krok za kluczowy moment w życiu zawodowym. Otworzył się przed nimi świat przejrzystych i łatwych w utrzymaniu aplikacji.

A co jeśli kod konsekwentnie nie używa wstrzykiwania zależności? Co jeśli jest zbudowany na metodach statycznych lub singletonach? Czy przynosi to jakieś problemy? Przynosi i to bardzo zasadnicze.

wersja: 3.x