Co to jest Dependency Injection?
Ten rozdział zapozna Cię z podstawowymi praktykami programistycznymi, których powinieneś przestrzegać podczas pisania dowolnej aplikacji. Są to podstawy potrzebne do pisania czystego, zrozumiałego i możliwego do utrzymania kodu.
Jeśli nauczysz się i będziesz przestrzegać tych zasad, Nette będzie Cię wspierać na każdym kroku. Zajmie się rutynowymi zadaniami za Ciebie i zapewni maksymalny komfort, dzięki czemu będziesz mógł skupić się na samej logice.
Zasady, które tu pokażemy, są dość proste. Nie musisz się o nic martwić.
Pamiętasz swój pierwszy program?
Nie wiemy, w jakim języku go napisałeś, ale jeśli był to PHP, mógł wyglądać coś takiego:
function suma(float $a, float $b): float
{
return $a + $b;
}
echo suma(23, 1); // wykazy 24
Kilka banalnych linii kodu, ale tak wiele kluczowych pojęć w nich ukrytych. To, że istnieją zmienne. Że kod jest rozbity na mniejsze jednostki, którymi są na przykład funkcje. Że przekazujemy im argumenty wejściowe, a one zwracają wyniki. Brakuje tylko warunków i pętli.
Fakt, że funkcja przyjmuje dane wejściowe i zwraca wynik, jest całkowicie zrozumiałym pojęciem, które jest również wykorzystywane w innych dziedzinach, takich jak matematyka.
Funkcja ma swoją sygnaturę, która składa się z jej nazwy, listy parametrów i ich typów, wreszcie typu wartości zwracanej. Jako użytkowników interesuje nas sygnatura, a o wewnętrznej implementacji zwykle nie musimy nic wiedzieć.
Teraz wyobraź sobie, że sygnatura funkcji wyglądała tak:
function suma(float $x): float
Dodatek z jednym parametrem? To dziwne… A co z tym?
function suma(): float
Teraz to jest naprawdę dziwne, prawda? Jak ta funkcja jest używana?
echo suma(); // co wypisuje?
Patrząc na taki kod, bylibyśmy zdezorientowani. Nie tylko początkujący nie zrozumiałby go, ale nawet doświadczony programista nie zrozumiałby takiego kodu.
Zastanawiasz się jak właściwie wyglądałaby taka funkcja w środku? Skąd brałaby sumy? Prawdopodobnie w jakiś sposób sama by je uzyskała, być może w taki sposób:
function suma(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Okazuje się, że w ciele funkcji znajdują się ukryte wiązania do innych funkcji (lub metod statycznych), a żeby dowiedzieć się, skąd tak naprawdę pochodzą addytywy, musimy kopać dalej.
Nie tędy droga!
Projekt, który właśnie pokazaliśmy, jest esencją wielu negatywnych cech:
- sygnatura funkcji udawała, że nie potrzebuje sumatorów, co nas dezorientowało
- nie mamy pojęcia, jak sprawić, żeby funkcja obliczała z dwoma innymi liczbami
- musieliśmy zajrzeć do kodu, żeby dowiedzieć się, skąd wzięły się sumy
- znaleźliśmy ukryte zależności
- pełne zrozumienie wymaga zbadania także tych zależności
A czy zadaniem funkcji dodawania jest w ogóle pozyskiwanie wejść? Oczywiście, że nie. Jej zadaniem jest tylko dodawanie.
Nie chcemy spotkać takiego kodu, a już na pewno nie chcemy go pisać. Lekarstwo jest proste: wróć do podstaw i po prostu używaj parametrów:
function suma(float $a, float $b): float
{
return $a + $b;
}
Zasada #1: Niech ci to zostanie przekazane
Najważniejszą zasadą jest: wszystkie dane, których potrzebują funkcje lub klasy, muszą być do nich przekazane.
Zamiast wymyślać ukryte sposoby, aby mogli sami uzyskać dostęp do danych, po prostu przekaż parametry. Zaoszczędzisz czas, który zostałby poświęcony na wymyślanie ukrytych ścieżek, które z pewnością nie poprawią twojego kodu.
Jeśli zawsze i wszędzie będziesz przestrzegał tej zasady, jesteś na dobrej drodze do kodu bez ukrytych zależności. Do kodu, który jest zrozumiały nie tylko dla autora, ale także dla każdego, kto go potem przeczyta. Gdzie wszystko jest zrozumiałe z sygnatur funkcji i klas, i nie ma potrzeby szukania ukrytych sekretów w implementacji.
Ta technika nazywa się fachowo dependency injection. A te dane nazywane są zależnościami. To tylko zwykłe przekazywanie parametrów, nic więcej.
Proszę nie mylić wtrysku zależności, który jest wzorcem projektowym, z “kontenerem wtrysku zależności”, który jest narzędziem, czymś diametralnie różnym. Kontenerami zajmiemy się później.
Od funkcji do klas
A jak klasy są powiązane? Klasa jest bardziej złożoną jednostką niż prosta funkcja, ale zasada #1 ma tutaj również całkowite zastosowanie. Jest po prostu więcej sposobów na przekazywanie argumentów. Na przykład, dość podobny do przypadku funkcji:
class Matematika
{
public function suma(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Matematika;
echo $math->suma(23, 1); // 24
Lub poprzez inne metody, lub bezpośrednio poprzez konstruktor:
class Suma
{
public function __construct(
private float $a,
private float $b,
) {
}
public function spocti(): float
{
return $this->a + $this->b;
}
}
$suma = new Suma(23, 1);
echo $suma->spocti(); // 24
Oba przykłady są całkowicie zgodne z zastrzykiem zależności.
Przykłady z życia wzięte
W prawdziwym świecie nie będziesz pisał klas do dodawania liczb. Przejdźmy więc do praktycznych przykładów.
Miejmy klasę Article
reprezentującą wpis na blogu:
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// zapisać artykuł do bazy danych
}
}
, a sposób użycia będzie następujący:
$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. Wdrożenie jej przy użyciu Nette Database to bułka z masłem, gdyby nie jeden problem: skąd
Article
ma wziąć połączenie z bazą danych, czyli obiekt klasy Nette\Database\Connection
?
Wydaje się, że mamy wiele możliwości. Może wziąć je gdzieś ze zmiennej statycznej. Albo dziedziczyć po klasie, która zapewnia połączenie z bazą danych. Albo skorzystać z singletonu. Albo wykorzystać tzw. fasady, które są stosowane 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, rozwiązaliśmy problem.
A może jednak?
Przypomnijmy sobie regułę #1: Let It Be Passed to You: wszystkie zależności, których potrzebuje klasa, muszą być do niej przekazane. Bo jeśli złamiemy tę regułę, to wkroczyliśmy na drogę do brudnego kodu pełnego ukrytych zależności, niezrozumiałości, a efektem będzie aplikacja, której utrzymanie i rozwój będą bolesne.
Użytkownik klasy Article
nie ma pojęcia, gdzie metoda save()
przechowuje artykuł. W tabeli bazy
danych? W której, produkcyjnej czy testowej? I jak można ją zmienić?
Użytkownik musi przyjrzeć się, jak zaimplementowana jest metoda save()
, i znajduje zastosowanie metody
DB::insert()
. Musi więc szukać dalej, aby dowiedzieć się, jak ta metoda uzyskuje połączenie z bazą danych.
A ukryte zależności mogą tworzyć dość długi łańcuch.
W czystym i dobrze zaprojektowanym kodzie nigdy nie ma ukrytych zależności, fasad Laravel czy zmiennych statycznych. W czystym i dobrze zaprojektowanym kodzie przekazywane są 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 podejście, jak zobaczymy później, będzie poprzez 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ą, możesz pomyśleć, że Article
nie powinien w ogóle
mieć metody save()
; powinien reprezentować składnik czysto danych, a oddzielne repozytorium powinno zająć się
zapisywaniem. To ma sens. Ale to zabrałoby nas daleko poza zakres tego tematu, który jest zastrzykiem zależności, a także
wysiłek, aby zapewnić proste przykłady.
Jeśli piszesz klasę, która do swojego działania wymaga np. bazy danych, to nie wymyślaj skąd ją wziąć, tylko zleć jej przekazanie. Albo 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)
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Jak myślicie, czy zastosowaliśmy się do zasady nr 1: Niech ci to zostanie przekazane?
Nie.
Kluczowa informacja, czyli katalog z plikiem dziennika, jest pozyskiwana przez samą klasę ze stałej.
Spójrz na przykład użycia:
$logger = new Logger;
$logger->log('The temperature is 23 °C');
$logger->log('The temperature is 10 °C');
Czy nie znając implementacji, mógłbyś odpowiedzieć na pytanie, gdzie zapisywane są wiadomości? Czy domyśliłbyś się,
że istnienie stałej LOG_DIR
jest niezbędne do jej funkcjonowania? A czy mógłbyś stworzyć drugą instancję,
która zapisywałaby w innym miejscu? Z pewnością nie.
Naprawmy więc 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('/path/to/log.txt');
$logger->log('The temperature is 15 °C');
Ale nie obchodzi mnie to!
“Kiedy tworzę obiekt Article i wywołuję save(), nie chcę mieć do czynienia z bazą danych; chcę tylko, aby został zapisany w tej, którą ustawiłem w konfiguracji.”
“Kiedy używam Loggera, chcę tylko, aby wiadomość została zapisana i nie chcę zajmować się tym, gdzie. Pozwól, aby użyto ustawień globalnych.”
To są ważne punkty.
Jako przykład spójrzmy na klasę, która wysyła biuletyny i rejestruje, jak to się stało:
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;
}
}
}
Ulepszony Logger
, który nie używa już stałej LOG_DIR
, wymaga określenia ścieżki pliku w
konstruktorze. Jak to rozwiązać? Klasa NewsletterDistributor
nie dba o to, gdzie zapisywane są wiadomości; chce
je po prostu zapisać.
Rozwiązaniem jest ponownie zasada #1: Let It Be Passed to You: przekaż wszystkie dane, których potrzebuje klasa.
Czy oznacza to więc, że przekazujemy ścieżkę do dziennika poprzez konstruktor, którego następnie używamy podczas
tworzenia obiektu Logger
?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ NIE W TEN SPOSÓB!
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Nie, nie w ten sposób! Ścieżka nie należy do danych, których potrzebuje klasa NewsletterDistributor
; w
rzeczywistości potrzebuje jej Logger
. Czy widzisz różnicę? Klasa NewsletterDistributor
potrzebuje
samego loggera. Więc to jest to, co przekażemy:
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;
}
}
}
Teraz z podpisów klasy NewsletterDistributor
jasno wynika, że logowanie jest również częścią jej
funkcjonalności. A zadanie zamiany loggera na inny, być może w celu przetestowania, jest zupełnie trywialne. Co więcej,
jeśli zmieni się konstruktor klasy Logger
, nie będzie to miało wpływu na naszą klasę.
Zasada #2: Bierz co twoje
Nie daj się zwieść i nie pozwól sobie na przechodzenie przez zależności swoich zależności. Przekazuj tylko swoje własne zależności.
Dzięki temu kod korzystający z innych obiektów będzie całkowicie niezależny od zmian w ich konstruktorach. Jego API będzie bardziej zgodne z prawdą. A przede wszystkim trywialne będzie zastąpienie tych zależności innymi.
Nowy członek rodziny
Zespół programistów postanowił stworzyć drugi logger, który zapisuje do bazy danych. Więc tworzymy klasę
DatabaseLogger
. Mamy więc dwie klasy, Logger
i DatabaseLogger
, jedna zapisuje do pliku,
druga do bazy danych … czy to nazewnictwo nie wydaje Ci się dziwne? Czy nie lepiej byłoby zmienić nazwę Logger
na FileLogger
? Zdecydowanie tak.
Ale zróbmy to sprytnie. Tworzymy interfejs pod oryginalną nazwą:
interface Logger
{
function log(string $message): void;
}
…które oba rejestratory zaimplementują:
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
I z tego powodu nie będzie trzeba nic zmieniać w pozostałej części kodu, w której używany jest logger. Na przykład
konstruktor klasy NewsletterDistributor
nadal będzie zadowalał się wymaganiem Logger
jako parametru.
I to od nas będzie zależało, którą instancję przekażemy.
To właśnie dlatego nigdy nie dodajemy do nazw interfejsów przyrostka Interface
ani przedrostka
I
Inaczej nie dałoby się tak ładnie rozwinąć kodu.
Houston, mamy problem
O ile możemy sobie poradzić z pojedynczą instancją loggera, czy to plikowego, czy bazodanowego, w całej aplikacji i po
prostu przekazać ją wszędzie tam, gdzie coś ma być rejestrowane, to w przypadku klasy Article
jest zupełnie
inaczej. Jej instancje tworzymy w miarę 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 przesłaniu formularza powinien 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 jest oczywiste: przekazać obiekt bazy danych do konstruktora EditController
i użyć
$article = new Article($this->db)
.
Podobnie jak w poprzednim przypadku z Logger
i ścieżką do pliku, nie jest to właściwe podejście. Baza
danych nie jest zależna od EditController
, ale od Article
. Przekazanie bazy danych jest sprzeczne z zasadą #2: bierz to, co twoje. Jeśli konstruktor klasy Article
ulegnie
zmianie (zostanie dodany nowy parametr), będziesz musiał zmodyfikować kod wszędzie tam, gdzie tworzone są
instancje. Ufff.
Houston, co proponujesz?
Zasada #3: Niech fabryka się tym zajmie
Eliminując ukryte zależności i przekazując wszystkie zależności jako argumenty, zyskaliśmy bardziej konfigurowalne i elastyczne klasy. I dlatego potrzebujemy czegoś innego, co stworzy i skonfiguruje dla nas te bardziej elastyczne klasy. Nazwiemy to fabrykami.
Zasadą jest: jeśli klasa ma zależności, pozostaw tworzenie ich instancji fabryce.
Fabryki są mądrzejszym zamiennikiem dla operatora new
w świecie zastrzyku zależności.
Proszę nie mylić z wzorcem projektowym factory method, który opisuje specyficzny sposób używania fabryk i nie jest związany z tym tematem.
Fabryka
Fabryka to metoda lub klasa, która tworzy i konfiguruje obiekty. Klasę produkującą Article
nazwiemy jako
ArticleFactory
, a mogłaby ona wyglądać tak:
class ArticleFactory
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function create(): Article
{
return new Article($this->db);
}
}
Jego wykorzystanie w sterowniku będzie wyglądało następująco:
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// pozwól fabryce stworzyć obiekt
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
W tym momencie, jeśli zmieni się podpis konstruktora klasy Article
, jedyną częścią kodu, która musi
zareagować, jest sam ArticleFactory
. Wszystkie inne kody pracujące z obiektami Article
, takie jak
EditController
, nie zostaną dotknięte.
Możesz się zastanawiać, czy faktycznie poprawiliśmy sytuację. Ilość kodu wzrosła, a wszystko zaczyna wyglądać podejrzanie skomplikowanie.
Nie martw się, wkrótce dotrzemy do kontenera Nette DI. A ma on w rękawie kilka sztuczek, które znacznie ułatwią
budowanie aplikacji wykorzystujących wstrzykiwanie zależności. Na przykład zamiast klasy ArticleFactory
wystarczy
napisać prosty interfejs:
interface ArticleFactory
{
function create(): Article;
}
Ale wyprzedzamy się; prosimy o cierpliwość :-)
Podsumowanie
Na początku tego rozdziału obiecaliśmy, że pokażemy Ci proces projektowania czystego kodu. Wystarczy, że klasy:
- przekazywały zależności, których potrzebują
- odwrotnie, nie przekazywały tego, czego bezpośrednio nie potrzebują
- oraz że obiekty z zależnościami najlepiej tworzyć w fabrykach
Na pierwszy rzut oka te trzy zasady mogą nie wydawać się mieć daleko idących konsekwencji, ale prowadzą do radykalnie innego spojrzenia na projektowanie kodu. Czy to się opłaca? Deweloperzy, którzy porzucili stare nawyki i zaczęli konsekwentnie używać dependency injection, uważają ten krok za kluczowy moment w swoim życiu zawodowym. Otworzył on przed nimi świat przejrzystych i możliwych do utrzymania aplikacji.
Ale co jeśli kod nie korzysta konsekwentnie z dependency injection? Co jeśli opiera się na metodach statycznych lub singletonach? Czy to powoduje jakieś problemy? Tak, powoduje, i to bardzo podstawowe.