Какво е Dependency Injection?
Тази глава ще ви запознае с основните програмни практики, които трябва да следвате при писането на всички приложения. Това са основите, необходими за писане на чист, разбираем и поддържаем код.
Ако усвоите тези правила и ги спазвате, Nette ще ви помага на всяка стъпка. Той ще се справя с рутинните задачи вместо вас и ще ви осигури максимален комфорт, за да можете да се съсредоточите върху самата логика.
Принципите, които ще покажем тук, са доста прости. Няма нужда да се притеснявате за нищо.
Спомняте ли си първата си програма?
Не знаем на какъв език сте я написали, но ако беше PHP, вероятно щеше да изглежда така:
function soucet(float $a, float $b): float
{
return $a + $b;
}
echo soucet(23, 1); // извежда 24
Няколко тривиални реда код, но в тях се крият толкова много ключови концепции. Че съществуват променливи. Че кодът се разделя на по-малки единици, като например функции. Че им предаваме входни аргументи и те връщат резултати. Липсват само условия и цикли.
Това, че предаваме входни данни на функция и тя връща резултат, е напълно разбираема концепция, която се използва и в други области, като например математиката.
Функцията има своя сигнатура, която се състои от нейното име, списък с параметри и техните типове, и накрая тип на връщаната стойност. Като потребители ни интересува сигнатурата, обикновено не е необходимо да знаем нищо за вътрешната имплементация.
Сега си представете, че сигнатурата на функцията изглеждаше така:
function soucet(float $x): float
Сума с един параметър? Това е странно… А какво ще кажете за това?
function soucet(): float
Това вече е наистина много странно, нали? Как се използва функцията?
echo soucet(); // какво ли ще изведе?
При вида на такъв код бихме били объркани. Не само начинаещ не би го разбрал, такъв код не разбира и опитен програмист.
Чудите ли се как би изглеждала такава функция отвътре? Откъде ще вземе събираемите? Вероятно би си ги набавила по някакъв начин сама, например така:
function soucet(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
В тялото на функцията открихме скрити връзки към други глобални функции или статични методи. За да разберем откъде всъщност идват събираемите, трябва да търсим по-нататък.
Не така!
Дизайнът, който току-що показахме, е есенцията на много негативни черти:
- сигнатурата на функцията се преструваше, че не се нуждае от събираеми, което ни объркваше
- изобщо не знаем как да накараме функцията да събере други две числа
- трябваше да погледнем в кода, за да разберем откъде взема събираемите
- открихме скрити зависимости
- за пълно разбиране е необходимо да се проучат и тези зависимости
И изобщо задача ли е на функцията за събиране да си набавя входове? Разбира се, че не е. Нейната отговорност е само самото събиране.
С такъв код не искаме да се сблъскваме и определено не искаме да го пишем. Поправката е проста: да се върнем към основите и просто да използваме параметри:
function soucet(float $a, float $b): float
{
return $a + $b;
}
Правило № 1: Нека ви го предадат
Най-важното правило е: всички данни, от които функциите или класовете се нуждаят, трябва да им бъдат предадени.
Вместо да измисляте скрити начини, чрез които те биха могли да стигнат до тях сами, просто предайте параметрите. Ще спестите време, необходимо за измисляне на скрити пътища, които определено няма да подобрят вашия код.
Ако спазвате това правило винаги и навсякъде, сте на път към код без скрити зависимости. Към код, който е разбираем не само за автора, но и за всеки, който ще го чете след него. Където всичко е разбираемо от сигнатурите на функциите и класовете и не е необходимо да се търсят скрити тайни в имплементацията.
Тази техника се нарича професионално dependency injection. А тези данни се наричат зависимости. Всъщност това е просто предаване на параметри, нищо повече.
Моля, не бъркайте dependency injection, което е дизайнерски патърн, с „dependency injection container“, което пък е инструмент, т.е. нещо диаметрално различно. Ще се занимаваме с контейнерите по-късно.
От функции към класове
А как това е свързано с класовете? Класът е по-сложна единица от проста функция, но правило № 1 важи изцяло и тук. Само че съществуват повече начини за предаване на аргументи. Например, доста подобно на случая с функция:
class Matematika
{
public function soucet(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Matematika;
echo $math->soucet(23, 1); // 24
Или чрез други методи, или директно чрез конструктора:
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
И двата примера са напълно в съответствие с dependency injection.
Реални примери
В реалния свят няма да пишете класове за събиране на числа. Нека преминем към примери от практиката.
Нека имаме клас Article
, представляващ статия в блог:
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// запазваме статията в базата данни
}
}
и употребата ще бъде следната:
$article = new Article;
$article->title = '10 Things You Need to Know About Losing Weight';
$article->content = 'Every year millions of people in ...';
$article->save();
Методът save()
запазва статията в таблица в базата данни.
Имплементирането му с помощта на Nette Database ще
бъде лесно, ако не беше една спънка: откъде Article
да вземе връзка
към базата данни, т.е. обект от клас Nette\Database\Connection
?
Изглежда, че имаме много възможности. Може да я вземе отнякъде от статична променлива. Или да наследи от клас, който осигурява връзка с базата данни. Или да използва т.нар. singleton. Или т.нар. фасади, които се използват в Laravel:
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],
);
}
}
Страхотно, решихме проблема.
Или не?
Да си припомним #Правило № 1: Нека ви го предадат: всички зависимости, от които класът се нуждае, трябва да му бъдат предадени. Защото ако нарушим правилото, сме поели по пътя към мръсен код, пълен със скрити зависимости, неразбираемост, и резултатът ще бъде приложение, което ще бъде болезнено за поддръжка и разработка.
Потребителят на класа Article
не знае къде методът save()
запазва статията. В таблица в базата данни? В коя, продукционната или
тестовата? И как може да се промени това?
Потребителят трябва да погледне как е имплементиран методът
save()
и намира използването на метода DB::insert()
. Така че
трябва да търси по-нататък как този метод си набавя връзка към базата
данни. А скритите зависимости могат да образуват доста дълга верига.
В чист и добре проектиран код никога не се срещат скрити зависимости, фасади в стил Laravel или статични променливи. В чист и добре проектиран код се предават аргументи:
class Article
{
public function save(Nette\Database\Connection $db): void
{
$db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Още по-практично, както ще видим по-нататък, ще бъде чрез конструктора:
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,
]);
}
}
Ако сте опитен програмист, може би си мислите, че Article
изобщо не трябва да има метод save()
, трябва да представлява чисто
компонент за данни и за запазването трябва да се грижи отделно
репозитори. Това има смисъл. Но така бихме се отклонили твърде много от
темата, която е dependency injection, и от стремежа да даваме прости примери.
Ако пишете клас, който изисква за дейността си например база данни, не измисляйте откъде да я вземете, а поискайте да ви бъде предадена. Например като параметър на конструктора или друг метод. Признайте зависимостите. Признайте ги в API на вашия клас. Ще получите разбираем и предвидим код.
А какво ще кажете за този клас, който логва съобщения за грешки:
class Logger
{
public function log(string $message)
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Какво мислите, спазихме ли #Правило № 1: Нека ви го предадат?
Не спазихме.
Ключовата информация, т.е. директорията с файла с лога, класът си набавя сам от константа.
Погледнете примера за употреба:
$logger = new Logger;
$logger->log('Температурата е 23 °C');
$logger->log('Температурата е 10 °C');
Без да познавате имплементацията, бихте ли могли да отговорите на
въпроса къде се записват съобщенията? Би ли ви хрумнало, че за
функционирането е необходимо съществуването на константата
LOG_DIR
? И бихте ли могли да създадете втора инстанция, която да
записва другаде? Със сигурност не.
Нека поправим класа:
class Logger
{
public function __construct(
private string $file,
) {
}
public function log(string $message): void
{
file_put_contents($this->file, $message . "\n", FILE_APPEND);
}
}
Класът сега е много по-разбираем, конфигурируем и следователно по-полезен.
$logger = new Logger('/път/към/лог.txt');
$logger->log('Температурата е 15 °C');
Но това не ме интересува!
„Когато създам обект Article и извикам save(), не искам да се занимавам с базата данни, просто искам да се запази в тази, която съм настроил в конфигурацията.“
„Когато използвам Logger, просто искам съобщението да се запише и не искам да се занимавам къде. Нека се използва глобалната настройка.“
Това са правилни забележки.
Като пример ще покажем клас, който разпраща бюлетини и логва как е минало:
class NewsletterDistributor
{
public function distribute(): void
{
$logger = new Logger(/* ... */);
try {
$this->sendEmails();
$logger->log('Имейлите бяха изпратени');
} catch (Exception $e) {
$logger->log('Възникна грешка при изпращането');
throw $e;
}
}
}
Подобреният Logger
, който вече не използва константата
LOG_DIR
, изисква в конструктора да се посочи пътят към файла. Как да
решим това? Класът NewsletterDistributor
изобщо не се интересува къде се
записват съобщенията, иска само да ги запише.
Решението е отново #Правило № 1: Нека ви го предадат: всички данни, от които класът се нуждае, му предаваме.
Значи това означава, че ще си предадем пътя към лога чрез
конструктора, който след това ще използваме при създаването на обекта
Logger
?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ ТАКА НЕ!
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Така не! Пътят всъщност не принадлежи към данните, от които класът
NewsletterDistributor
се нуждае; от тях се нуждае Logger
. Усещате ли
разликата? Класът NewsletterDistributor
се нуждае от логъра като такъв.
Така че ще си го предадем:
class NewsletterDistributor
{
public function __construct(
private Logger $logger, // ✅
) {
}
public function distribute(): void
{
try {
$this->sendEmails();
$this->logger->log('Имейлите бяха изпратени');
} catch (Exception $e) {
$this->logger->log('Възникна грешка при изпращането');
throw $e;
}
}
}
Сега от сигнатурите на класа NewsletterDistributor
е ясно, че част от
неговата функционалност е и логването. А задачата да се смени логърът с
друг, например за тестване, е напълно тривиална. Освен това, ако
конструкторът на класа Logger
се промени, това няма да има никакво
влияние върху нашия клас.
Правило № 2: Вземи това, което е твое
Не се заблуждавайте и не си предавайте зависимостите на вашите зависимости. Предавайте си само вашите собствени зависимости.
Благодарение на това кодът, използващ други обекти, ще бъде напълно независим от промените в техните конструктори. Неговото API ще бъде по-вярно. И най-важното, ще бъде тривиално тези зависимости да се заменят с други.
Нов член на семейството
В екипа за разработка беше взето решение да се създаде втори логър,
който записва в база данни. Затова създаваме клас DatabaseLogger
. Така
имаме два класа, Logger
и DatabaseLogger
, единият записва във файл,
другият в база данни … не ви ли се струва нещо странно в това именуване?
Не би ли било по-добре да преименуваме Logger
на FileLogger
? Със
сигурност да.
Но ще го направим умно. Под оригиналното име ще създадем интерфейс:
interface Logger
{
function log(string $message): void;
}
… който и двата логъра ще имплементират:
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
И благодарение на това няма да е необходимо да се променя нищо в
останалата част от кода, където се използва логърът. Например
конструкторът на класа NewsletterDistributor
ще продължи да бъде доволен,
че като параметър изисква Logger
. И ще зависи само от нас коя
инстанция ще му предадем.
Затова никога не даваме на имената на интерфейсите суфикс
Interface
или префикс I
. В противен случай не би било
възможно кодът да се развива толкова добре.
Хюстън, имаме проблем
Докато в цялото приложение можем да се справим с една единствена
инстанция на логъра, било то файлов или базиран на данни, и просто го
предаваме навсякъде, където нещо се логва, съвсем различно е
положението с класа Article
. Неговите инстанции създаваме според
нуждите, дори многократно. Как да се справим със зависимостта от базата
данни в неговия конструктор?
Като пример може да послужи контролер, който след изпращане на формуляр трябва да запази статия в базата данни:
class EditController extends Controller
{
public function formSubmitted($data)
{
$article = new Article(/* ... */);
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Възможното решение се натрапва само: ще си предадем обекта на базата
данни чрез конструктора в EditController
и ще използваме
$article = new Article($this->db)
.
Точно както в предишния случай с Logger
и пътя към файла, това не е
правилният подход. Базата данни не е зависимост на EditController
, а на
Article
. Предаването на базата данни следователно противоречи на Правило № 2: Вземи това, което е твое. Когато
конструкторът на класа Article
се промени (добави се нов параметър),
ще бъде необходимо да се коригира и кодът на всички места, където се
създават инстанции. Уф.
Хюстън, какво предлагаш?
Правило № 3: Остави го на фабриката
Като премахнахме скритите зависимости и предаваме всички зависимости като аргументи, получихме по-конфигурируеми и гъвкави класове. И следователно се нуждаем от още нещо, което да ни създаде и конфигурира тези по-гъвкави класове. Ще го наречем фабрики.
Правилото гласи: ако класът има зависимости, оставете създаването на техните инстанции на фабрика.
Фабриките са по-умната замяна на оператора new
в света на dependency
injection.
Моля, не бъркайте с дизайнерския патърн factory method, който описва специфичен начин за използване на фабрики и не е свързан с тази тема.
Фабрика
Фабриката е метод или клас, който произвежда и конфигурира обекти.
Класът, произвеждащ Article
, ще наречем ArticleFactory
и би могъл да
изглежда например така:
class ArticleFactory
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function create(): Article
{
return new Article($this->db);
}
}
Нейното използване в контролера ще бъде следното:
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// оставяме фабриката да създаде обекта
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Ако в този момент се промени сигнатурата на конструктора на класа
Article
, единствената част от кода, която трябва да реагира на това,
е самата фабрика ArticleFactory
. Целият останал код, който работи с
обекти Article
, като например EditController
, няма да бъде засегнат
по никакъв начин.
Може би сега си удряте челото, дали изобщо сме си помогнали. Количеството код нарасна и всичко започва да изглежда подозрително сложно.
Не се притеснявайте, скоро ще стигнем до Nette DI контейнера. А той има
редица асове в ръкава, с които изграждането на приложения, използващи
dependency injection, се опростява неимоверно. Така например, вместо клас
ArticleFactory
, ще е достатъчно напишете само интерфейс:
interface ArticleFactory
{
function create(): Article;
}
Но това е изпреварване, изчакайте още малко :-)
Резюме
В началото на тази глава обещахме, че ще покажем процедура за проектиране на чист код. Достатъчно е на класовете
- предавайте зависимостите, от които се нуждаят
- и обратно, не предавайте това, от което не се нуждаят пряко
- и че обектите със зависимости се създават най-добре във фабрики
Може да не изглежда така на пръв поглед, но тези три правила имат далечни последици. Водят до радикално различен поглед върху дизайна на кода. Струва ли си? Програмистите, които са изоставили старите навици и са започнали последователно да използват dependency injection, смятат тази стъпка за ключов момент в професионалния си живот. Открил се е пред тях свят на прегледни и поддържаеми приложения.
Ами ако кодът не използва последователно dependency injection? Ами ако е изграден върху статични методи или сингълтони? Носи ли това някакви проблеми? Носи и то много съществени.