Какво е “прилагане на зависимостта”?
Тази глава ви запознава с основните практики за програмиране, които трябва да следвате при писането на всяко приложение. Това са основите, необходими за писане на чист, разбираем и поддържан код.
Ако научите и следвате тези правила, Nette ще бъде до вас на всяка крачка. Тя ще се справя с рутинните задачи вместо вас и ще ви осигури възможно най-голямо удобство, за да можете да се съсредоточите върху самата логика.
Принципите, които ще покажем тук, са съвсем прости. Няма за какво да се притеснявате.
Помните ли първата си програма?
Нямаме представа на какъв език сте я написали, но ако е била на PHP, вероятно щеше да изглежда по следния начин:
function addition(float $a, float $b): float
{
return $a + $b;
}
echo addition(23, 1); // извежда 24
Няколко тривиални реда код, но в тях са скрити толкова много ключови концепции. Че има променливи. Че кодът е разделен на по-малки единици, които са функции, например. Че им подаваме входни аргументи и те връщат резултати. Липсват само условия и цикли.
Фактът, че подаваме входни аргументи на функция и тя връща резултат, е напълно разбираема концепция, която се използва и в други области, например в математиката.
Една функция има сигнатура, която се състои от нейното име, списък на параметрите и техните типове, и накрая типа на връщаната стойност. Като потребители ние се интересуваме от сигнатурата; обикновено не е необходимо да знаем нищо за вътрешната реализация.
Сега си представете, че сигнатурата на една функция изглежда така:
function addition(float $x): float
Добавка с един параметър? Това е странно… Какво ще кажете за това?
function addition(): float
Това е наистина странно, нали? Как мислите, че се използва тази функция?
echo addition(); // какво се показва тук?
Като гледаме такъв код, сме объркани. Не само начинаещ не би го разбрал, дори опитен програмист не би разбрал такъв код.
Чудите ли се как всъщност би изглеждала една такава функция вътре? Откъде би взела суматорите? Вероятно би ги получила по някакъв начин сама, например по следния начин:
function addition(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Оказва се, че в тялото на функцията има скрити връзки към други функции (или статични методи) и за да разберем откъде всъщност идват добавките, трябва да се разровим допълнително.
Не по този начин!
Дизайнът, който току-що ни беше показан, е същността на много отрицателни характеристики:
- сигнатурата на функцията се преструва, че не се нуждае от добавки, което ни обърква
- нямаме представа как да накараме функцията да изчислява с две други числа
- трябваше да се вгледаме в кода, за да видим откъде взема адентите
- открихме скрити връзки
- за да разберем напълно, трябва да изследваме и тези връзки
А дали изобщо задачата на функцията за добавяне е да набавя входове? Разбира се, че не е. Нейната отговорност е само да добавя.
Не искаме да срещаме такъв код и със сигурност не искаме да го пишем. Лекарството е просто: върнете се към основите и просто използвайте параметри:
function addition(float $a, float $b): float
{
return $a + $b;
}
Правило № 1: Нека да ти бъде предадено
Най-важното правило е: всички данни, от които се нуждаят функциите или класовете, трябва да им бъдат предадени.
Вместо да измисляте скрити механизми, които да им помогнат по някакъв начин сами да стигнат до тях, просто предайте параметрите. Ще спестите времето, което е необходимо за измисляне на скрит начин, който определено няма да подобри кода ви.
Ако спазвате това правило винаги и навсякъде, сте на път към код без скрити обвързвания. Към код, който е разбираем не само за автора, но и за всеки, който го прочете след това. Където всичко се разбира от сигнатурите на функциите и класовете и няма нужда да търсите скрити тайни в имплементацията.
Тази техника експертно се нарича инжектиране на зависимости. А данните се наричат зависимости. Но това е просто предаване на параметри, нищо повече.
Моля, не бъркайте инжектирането на зависимости, което е шаблон за проектиране, с “контейнер за инжектиране на зависимости”, който е инструмент, нещо съвсем различно. Контейнерите ще обсъдим по-късно.
От функции към класове
А как се отнасят класовете към това? Класът е по-сложна единица от простата функция, но правило № 1 важи и тук. Просто има повече начини за предаване на аргументи. Например, доста подобно на случая с функцията:
class Math
{
public function addition(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->addition(23, 1); // 24
Или чрез използване на други методи, или директно чрез конструктора:
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
И двата примера са в пълно съответствие с принципа за инжектиране на зависимости.
Примери от реалния живот
В реалния свят няма да напишете класове за събиране на числа. Нека преминем към примери от реалния свят.
Нека имаме клас 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
?
Изглежда, че имаме много възможности. Той може да я вземе от някъде в статична променлива. Или да я наследи от клас, който ще предостави връзката с базата данни. Или да се възползвате от един-единствен клас. Или така наречените фасади, които се използват в 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()
, трябва да бъде чист компонент
за данни, а за съхранението трябва да се грижи отделно хранилище. Това е
логично. Но това би ни отвело далеч отвъд темата, която е инжектиране на
зависимости, и се опитваме да дадем прости примери.
Ако ще пишете клас, който изисква база данни, за да работи, например, не измисляйте откъде да я вземете, а да ви бъде предадена. Може би като параметър на конструктор или друг метод. Декларирайте зависимости. Разкрийте ги в 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('The temperature is 23 °C');
$logger->log('The temperature is 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('/path/to/log.txt');
$logger->log('The temperature is 15 °C');
Но на мен не ми пука!
“Когато създавам обект Article и извиквам save(), не искам да се занимавам с базата данни, а само да я запазя в тази, която съм задал в конфигурацията. ”
“Когато използвам Logger, искам просто съобщението да бъде записано и не искам да се занимавам с това къде. Нека се използват глобалните настройки. ”
Това са правилни коментари.
Като пример, нека вземем клас, който изпраща бюлетини и регистрира как е протекло това:
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;
}
}
}
Усъвършенстваният 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
. Класът се
нуждае от самия логер. И точно това ще предадем:
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;
}
}
}
Сега от сигнатурите на класа 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
се промени (добави се нов параметър),
кодът на всички места, където се създават екземпляри, също ще трябва да
се промени. Ufff.
Хюстън, какво предлагаш?
Правило № 3: Оставете фабриката да се справи с това
Като премахнем скритите връзки и предадем всички зависимости като аргументи, получаваме по-конфигурируеми и гъвкави класове. И по този начин се нуждаем от нещо друго, за да създаваме и конфигурираме тези по-гъвкави класове. Ще го наречем фабрики.
Правилото е: ако даден клас има зависимости, оставете създаването на техните инстанции на фабриката.
Фабриките са по-интелигентен заместител на оператора new
в
света на инжектирането на зависимости.
Моля, не бъркайте с шаблона за проектиране 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. А той има
редица асове в ръкава си, които ще направят изграждането на приложения,
използващи инжектиране на зависимости, изключително лесно. Например,
вместо класа ArticleFactory
ще е достатъчно да напишете прост интерфейс:
interface ArticleFactory
{
function create(): Article;
}
Но ние изпреварваме, почакайте :-)
Резюме
В началото на тази глава обещахме да ви покажем начин за проектиране на чист код. Просто дайте класовете
- зависимостите, от които се нуждаят
- а не това, от което нямат пряка нужда
- и че обектите със зависимости е най-добре да се правят във фабрики
На пръв поглед може да не изглежда така, но тези три правила имат далечни последици. Те водят до коренно различен поглед върху проектирането на кода. Струва ли си? Програмистите, които са изхвърлили старите навици и са започнали последователно да използват инжектиране на зависимости, смятат това за ключов момент в професионалния си живот. Той им е отворил свят на ясни и устойчиви приложения.
Но какво става, ако кодът не използва последователно инжектиране на зависимости? Какво става, ако е изграден върху статични методи или единични елементи? Това носи ли някакви проблеми? Да, създава, и то много съществени.