Що таке “впровадження залежностей”?

У цьому розділі ви познайомитеся з основними практиками програмування, яких слід дотримуватися при написанні будь-якої програми. Це основи, необхідні для написання чистого, зрозумілого та зручного для супроводу коду.

Якщо ви вивчите і будете дотримуватися цих правил, 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: Let It Be Passed to You: всі залежності, які потрібні класу, повинні бути передані йому. Тому що якщо ми порушуємо це правило, ми стаємо на шлях до брудного коду, повного прихованих залежностей, незрозумілості, і в результаті отримуємо додаток, який буде болісно підтримувати і розвивати.

Користувач класу 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: Let It Be Passed to You: передайте всі дані, які потрібні класу.

Чи означає це, що ми передаємо шлях до логу через конструктор, який потім використовуємо при створенні об'єкта 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 (додається новий параметр), вам потрібно буде модифікувати код скрізь, де створюються екземпляри. Уффф.

Х'юстон, що ти пропонуєш?

Правило №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;
}

Але ми забігаємо наперед, будь ласка, наберіться терпіння :-)

Резюме

На початку цієї глави ми обіцяли показати вам процес проектування чистого коду. Все, що для цього потрібно, це щоб класи:

На перший погляд може здатися, що ці три правила не мають далекосяжних наслідків, але вони призводять до радикально іншого погляду на дизайн коду. Чи варто воно того? Розробники, які відмовилися від старих звичок і почали послідовно використовувати ін'єкцію залежностей, вважають цей крок переломним моментом у своєму професійному житті. Він відкрив для них світ зрозумілих і зручних для супроводу додатків.

Але що, якщо код не використовує ін'єкцію залежностей послідовно? Що, якщо він покладається на статичні методи або синглетони? Чи виникають через це проблеми? Так, створює, і дуже серйозні.

версію: 3.x