Какво е “прилагане на зависимостта”?
Тази глава ще ви запознае с основните практики за програмиране, които трябва да спазвате при писането на всяко приложение. Това са основите, необходими за писането на чист, разбираем и поддържан код.
Ако научите и следвате тези правила, 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
. Виждате ли разликата? Класът NewsletterDistributor
се нуждае
от самия логер. Така че това е, което ще предадем:
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;
}
Но ние изпреварваме себе си; моля, бъдете търпеливи :-)
Резюме
В началото на тази глава обещахме да ви покажем процес за проектиране на чист код. Всичко, което е необходимо, е класовете да:
- да предават зависимостите, от които се нуждаят
- обратно, да не предават това, което не им е пряко необходимо
- и че обектите със зависимости е най-добре да се създават във фабрики
На пръв поглед тези три правила може да не изглеждат с далечни последици, но те водят до коренно различна перспектива за проектирането на кода. Струва ли си? Разработчиците, които са изоставили старите навици и са започнали последователно да използват инжектиране на зависимости, смятат тази стъпка за решаващ момент в професионалния си живот. Тя е отворила за тях света на ясните и поддържани приложения.
Но какво става, ако в кода не се използва последователно инжектиране на зависимости? Какво става, ако той разчита на статични методи или единични методи? Това води ли до някакви проблеми? Да, създава, и то много съществени.