Czym jest wstrzykiwanie zależności?
Ten rozdział przedstawia podstawowe praktyki programistyczne, których powinieneś trzymać się przy pisaniu każdej aplikacji. To fundamenty niezbędne do pisania czystego, zrozumiałego i utrzymywalnego kodu.
Jeśli przyswoisz sobie te reguły i będziesz się ich trzymać, Nette wesprze Cię na każdym kroku. Załatwi za Ciebie rutynowe zadania i zapewni maksymalną wygodę, dzięki czemu będziesz mógł skupić się na samej logice.
Zasady, które tu pokażemy, są całkiem proste. Nie ma się czego bać.
Pamiętasz swój pierwszy program?
Nie wiemy, w jakim języku go napisałeś, ale gdyby to było PHP, wyglądał zapewne mniej więcej tak:
function addition(float $a, float $b): float
{
return $a + $b;
}
echo addition(23, 1); // wypisze 24
Kilka trywialnych wierszy kodu, a jednak kryją tyle kluczowych pojęć. Że istnieją zmienne. Że kod dzieli się na mniejsze jednostki, takie jak funkcje. Że przekazujemy im argumenty wejściowe i zwracają wyniki. Brakuje tylko warunków i pętli.
To, że przekazujemy funkcji dane wejściowe, a ona zwraca wynik, jest koncepcją całkowicie zrozumiałą, używaną również w innych dziedzinach, jak matematyka.
Funkcja ma swoją sygnaturę, składającą się z nazwy, listy parametrów i ich typów oraz wreszcie typu wartości zwracanej. Jako użytkowników interesuje nas sygnatura; o wewnętrznej implementacji zwykle nie musimy wiedzieć nic.
Wyobraź sobie teraz, że sygnatura funkcji wyglądałaby tak:
function addition(float $x): float
Dodawanie z jednym parametrem? To dziwne… A co powiesz na to?
function addition(): float
To już naprawdę dziwne, prawda? Jak używa się takiej funkcji?
echo addition(); // co wypisze?
Patrząc na taki kod, bylibyśmy zdezorientowani. Nie tylko początkujący by go nie zrozumiał, ale nawet wprawny programista nie zrozumiałby takiego kodu.
Zastanawiasz się, jak taka funkcja wyglądałaby w środku? Skąd wzięłaby liczby do dodania? Zapewne pozyskałaby je jakoś sama, może tak:
function addition(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
W ciele funkcji odkryliśmy ukryte zależności od innych funkcji globalnych albo metod statycznych. Aby dowiedzieć się, skąd tak naprawdę pochodzą liczby, musimy szukać dalej.
Nie tędy droga!
Konstrukcja, którą właśnie pokazaliśmy, jest źródłem wielu negatywnych cech:
- sygnatura funkcji udawała, że nie potrzebuje liczb do dodania, co nas zdezorientowało
- nie mamy pojęcia, jak sprawić, aby funkcja dodała dwie inne liczby
- musieliśmy zajrzeć do kodu, aby dowiedzieć się, skąd bierze liczby
- odkryliśmy ukryte zależności
- pełne zrozumienie wymaga zbadania również tych zależności
I czy w ogóle zadaniem funkcji dodającej jest pozyskiwanie danych wejściowych? Oczywiście nie. Jej odpowiedzialnością jest wyłącznie samo dodawanie.
Nie chcemy natykać się na taki kod i już na pewno nie chcemy go pisać. Naprawa jest prosta: wracamy do podstaw i po prostu używamy parametrów:
function addition(float $a, float $b): float
{
return $a + $b;
}
Zasada nr 1: pozwól, aby Ci to przekazano
Najważniejsza zasada brzmi: wszystkie dane, których potrzebują funkcje albo klasy, muszą zostać im dostarczone.
Zamiast wymyślać ukryte sposoby, w jakie mogłyby te dane pozyskać, po prostu przekaż parametry. Zaoszczędzisz czas spędzony na wymyślaniu ukrytych ścieżek, które z pewnością nie poprawią Twojego kodu.
Jeśli zawsze i wszędzie będziesz trzymać się tej zasady, jesteś na drodze do kodu bez ukrytych zależności. Do kodu zrozumiałego nie tylko dla autora, ale i dla każdego, kto przeczyta go później. Gdzie wszystko wynika z sygnatur funkcji i klas i nie trzeba szukać ukrytych szczegółów w implementacji.
Technika ta fachowo nazywa się wstrzykiwaniem zależności (Dependency Injection). A dane nazywane są zależnościami. To zwykłe przekazywanie parametrów, nic więcej.
Nie myl wstrzykiwania zależności, które jest wzorcem projektowym, z “kontenerem wstrzykiwania zależności”, który jest narzędziem, czyli czymś zasadniczo innym. O kontenerach opowiemy później.
Od funkcji do klas
A jak ma się to do klas? Klasa jest bytem bardziej złożonym niż prosta funkcja, ale zasada nr 1 obowiązuje w pełni również tutaj. Jest tylko więcej sposobów przekazywania argumentów. Na przykład całkiem podobnie jak przy funkcji:
class Math
{
public function sum(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->sum(23, 1); // 24
Albo za pomocą innych metod, albo bezpośrednio konstruktora:
class Sum
{
public function __construct(
private float $a,
private float $b,
) {
}
public function calculate(): float
{
return $this->a + $this->b;
}
}
$sum = new Sum(23, 1);
echo $sum->calculate(); // 24
Oba przykłady są w pełni zgodne ze wstrzykiwaniem zależności.
Przykłady z życia
W prawdziwym świecie nie będziesz pisać klas dodających liczby. Przejdźmy do praktycznych przykładów.
Weźmy klasę Article reprezentującą artykuł na blogu:
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// zapisujemy artykuł do bazy danych
}
}
a użycie będzie następujące:
$article = new Article;
$article->title = '10 rzeczy, które musisz wiedzieć o odchudzaniu';
$article->content = 'Każdego roku miliony ludzi ...';
$article->save();
Metoda save() zapisze artykuł do tabeli bazy danych. Zaimplementowanie jej za pomocą Nette Database byłoby proste, gdyby nie jeden szkopuł: skąd Article
weźmie połączenie z bazą danych, czyli obiekt klasy Nette\Database\Connection?
Wygląda na to, że mamy sporo możliwości. Mógłby wziąć je ze zmiennej statycznej. Albo przez dziedziczenie po klasie dostarczającej połączenie z bazą danych. Albo użyć singletona. Albo tak zwanych fasad, jak 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 aby na pewno?
Przypomnijmy sobie zasadę nr 1: pozwól, aby Ci to przekazano: wszystkie zależności, których klasa potrzebuje, muszą zostać jej przekazane. Bo jeśli złamiemy tę zasadę, wkroczyliśmy na drogę do bałaganiarskiego kodu pełnego ukrytych zależności i braku przejrzystości, a wynikiem będzie aplikacja, której utrzymanie i rozwijanie stanie się wyzwaniem.
Użytkownik klasy Article nie ma pojęcia, gdzie metoda save() zapisuje artykuł. Do tabeli bazy
danych? Której, produkcyjnej czy testowej? I jak to zmienić?
Użytkownik musi zajrzeć do implementacji metody save() i znajduje w niej użycie metody
DB::insert(). Musi więc szukać dalej, jak ta metoda pozyskuje połączenie z bazą danych. A ukryte zależności
mogą tworzyć całkiem długi łańcuch.
W czystym i dobrze zaprojektowanym kodzie nigdy nie ma ukrytych zależności, fasad Laravela ani zmiennych statycznych. 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 praktyczniejsze, jak zobaczymy później, jest użycie konstruktora:
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ą, możesz pomyśleć, że Article w ogóle nie powinien
mieć metody save(); powinien reprezentować wyłącznie strukturę danych, a zapisem powinno zajmować się osobne
repozytorium. To ma sens. Ale wyprowadziłoby nas to daleko poza zakres tematu, którym jest wstrzykiwanie zależności, i poza
cel podawania prostych przykładów.
Jeśli piszesz klasę, która do działania potrzebuje na przykład bazy danych, nie wymyślaj, skąd ją wziąć, tylko pozwól, aby Ci ją przekazano. Choćby jako parametr konstruktora albo innej metody. Przyznaj się do zależności. Przyznaj się do nich w API swojej klasy. Otrzymasz zrozumiały i przewidywalny kod.
A co z tą klasą, która loguje komunikaty o błędach:
class Logger
{
public function log(string $message): void
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Jak myślisz, czy trzymaliśmy się zasady nr 1: pozwól, aby Ci to przekazano?
Nie trzymaliśmy.
Kluczową informację, czyli katalog zawierający plik 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');
Czy bez znajomości implementacji potrafiłbyś odpowiedzieć, gdzie zapisywane są komunikaty? Przyszłoby Ci do głowy, że
do jej działania wymagane jest istnienie stałej LOG_DIR? I czy potrafiłbyś utworzyć drugą instancję, która
zapisywałaby gdzie indziej? Z pewnością nie.
Naprawmy tę 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 o wiele bardziej zrozumiała, konfigurowalna, a przez to użyteczniejsza.
$logger = new Logger('/path/to/log.txt');
$logger->log('Temperatura wynosi 15 °C');
Ale mnie to nie obchodzi!
“Gdy tworzę obiekt Article i wywołuję save(), nie chcę zajmować się bazą danych; chcę tylko, aby zapisał się w tej, którą mam skonfigurowaną.”
“Gdy używam Loggera, chcę tylko, aby komunikat został zapisany, i nie chcę zajmować się gdzie. Niech użyje ustawień globalnych.”
To słuszne uwagi.
Jako przykład pokażmy klasę, która rozsyła newslettery i loguje wynik:
class NewsletterDistributor
{
public function distribute(): void
{
$logger = new Logger(/* ... */);
try {
$this->sendEmails();
$logger->log('E-maile zostały wysłane');
} catch (Exception $e) {
$logger->log('Podczas wysyłania wystąpił błąd');
throw $e;
}
}
}
Ulepszony Logger, który nie używa już stałej LOG_DIR, wymaga w konstruktorze ścieżki do pliku.
Jak to rozwiązać? Klasa NewsletterDistributor nie interesuje się tym, gdzie zapisywane są komunikaty; chce je po
prostu logować.
Rozwiązaniem jest znów zasada nr 1: pozwól, aby Ci to przekazano: przekazujemy wszystkie dane, których klasa potrzebuje.
Czy oznacza to więc, że przekażemy przez konstruktor ścieżkę do logu, której użyjemy potem przy tworzeniu obiektu
Logger?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ NIE TAK!
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Nie tak! Bo ścieżka nie jest daną, której potrzebuje klasa NewsletterDistributor; potrzebuje jej
Logger. Dostrzegasz różnicę? Klasa NewsletterDistributor potrzebuje samego loggera. Przekażemy więc
sam logger:
class NewsletterDistributor
{
public function __construct(
private Logger $logger, // ✅
) {
}
public function distribute(): void
{
try {
$this->sendEmails();
$this->logger->log('E-maile zostały wysłane');
} catch (Exception $e) {
$this->logger->log('Podczas wysyłania wystąpił błąd');
throw $e;
}
}
}
Teraz z sygnatury klasy NewsletterDistributor jasno wynika, że logowanie jest częścią jej funkcji. A zadanie
podmiany loggera na inny, choćby na potrzeby testów, jest całkowicie proste. Co więcej, gdyby konstruktor klasy
Logger się zmienił, nie wpłynie to na naszą klasę.
Zasada nr 2: bierz to, co Twoje
Nie daj się zwieść i nie przyjmuj zależności swoich zależności. Przyjmuj wyłącznie własne zależności.
Dzięki temu kod używający innych obiektów będzie całkowicie niezależny od zmian ich konstruktorów. Jego API będzie dokładniejsze. A co najważniejsze, podmiana tych zależności na inne będzie prosta.
Nowy członek rodziny
Zespół deweloperski zdecydował się stworzyć drugi logger, taki, który zapisuje do bazy danych. Tworzymy więc klasę
DatabaseLogger. Mamy teraz dwie klasy, Logger i DatabaseLogger; jedna zapisuje do pliku,
druga do bazy danych… czy nazewnictwo nie wydaje się nieco dziwne? Czy nie lepiej byłoby zmienić nazwę Logger
na FileLogger? Z pewnością.
Zróbmy to jednak sprytnie. Tworzymy interfejs pod pierwotną nazwą:
interface Logger
{
function log(string $message): void;
}
… który zaimplementują oba loggery:
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
I dzięki temu w reszcie kodu, w której wykorzystywany jest logger, nie trzeba będzie niczego modyfikować. Na przykład
konstruktor klasy NewsletterDistributor nadal będzie zadowolony, wymagając jako parametru Logger.
A od nas zależy, którą instancję mu dostarczymy.
Dlatego nigdy nie dodajemy do nazw interfejsów przyrostka Interface ani przedrostka I. W
przeciwnym razie nie dałoby się tak elegancko rozszerzać kodu.
Houston, mamy problem
Podczas gdy w całej aplikacji wystarczy nam jedna instancja loggera, plikowego albo bazodanowego, i wystarczy przekazać ją
wszędzie tam, gdzie odbywa się logowanie, w przypadku klasy Article sytuacja jest zupełnie inna. Jej instancje
tworzymy według potrzeb, nawet wielokrotnie. Jak poradzić sobie z zależnością od bazy danych w jej konstruktorze?
Przykładem może być 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 wydaje się oczywiste: każmy przekazać obiekt bazy danych przez konstruktor do
EditController i użyjmy $article = new Article($this->db).
Tak jak w poprzednim przypadku z Logger i ścieżką do pliku, nie jest to poprawne podejście. Baza danych nie
jest zależnością EditController, lecz Article. Przekazanie bazy danych łamie więc zasadę nr 2: bierz to, co Twoje. Jeśli konstruktor klasy Article się
zmieni (dojdzie nowy parametr), będziesz musiał zmodyfikować kod we wszystkich miejscach, w których tworzone są
instancje. Ojej.
Houston, jaka jest Twoja propozycja?
Zasada nr 3: niech zajmie się tym fabryka
Eliminując ukryte zależności i przekazując wszystkie zależności jako argumenty, uzyskaliśmy bardziej konfigurowalne i elastyczne klasy. Potrzebujemy więc czegoś dodatkowego, co utworzy i skonfiguruje za nas te elastyczniejsze klasy. Nazwiemy to fabrykami.
Zasada brzmi: jeśli klasa ma zależności, deleguj tworzenie jej instancji do fabryki.
Fabryki są sprytniejszą alternatywą dla operatora new w świecie wstrzykiwania zależności.
Nie myl tego ze wzorcem projektowym factory method, który opisuje konkretny sposób wykorzystania fabryk i nie ma z tym tematem związku.
Fabryka
Fabryka to metoda albo klasa tworząca i konfigurująca obiekty. Klasę produkującą Article nazwiemy
ArticleFactory, a może wyglądać 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)
{
// pozwalamy fabryce utworzyć obiekt
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
W tym momencie, jeśli sygnatura konstruktora klasy Article się zmieni, jedyną częścią kodu, która musi
zareagować, jest sama ArticleFactory. Cały pozostały kod pracujący z obiektami Article, na
przykład EditController, pozostanie nietknięty.
Możesz teraz drapać się po głowie, zastanawiając się, czy rzeczywiście poprawiliśmy sytuację. Ilość kodu wzrosła, a całość zaczyna wyglądać podejrzanie skomplikowanie.
Bez obaw, wkrótce dojdziemy do kontenera Nette DI. A ma on w rękawie kilka sztuczek, które ogromnie uproszczą budowanie
aplikacji ze wstrzykiwaniem zależności. Na przykład zamiast klasy ArticleFactory wystarczy napisać sam interfejs:
interface ArticleFactory
{
function create(): Article;
}
Ale wyprzedzamy fakty, bądź czujny :-)
Podsumowanie
Na początku tego rozdziału obiecaliśmy pokazać sposób projektowania czystego kodu. Wystarczy zadbać o to, aby klasom:
- były przekazywane zależności, których potrzebują
- i odwrotnie, aby nie przekazywano im tego, czego bezpośrednio nie potrzebują
- a obiekty z zależnościami najlepiej było tworzyć w fabrykach
Na pierwszy rzut oka może to nie być widoczne, ale te trzy zasady mają daleko idące 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, uznają ten krok za przełomowy moment w swojej karierze zawodowej. Otworzył im świat przejrzystych i utrzymywalnych aplikacji.
A co, jeśli kod nie używa wstrzykiwania zależności konsekwentnie? Co, jeśli zbudowany jest na metodach statycznych albo singletonach? Czy prowadzi to do problemów? Tak, i to bardzo poważnych.