Що таке 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. Або так звані facades, які використовуються в 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, це ніяк не зачепить.

Можливо, ви зараз стукаєте себе по лобі, чи ми взагалі собі допомогли. Кількість коду зросла, і все це починає виглядати підозріло складно.

Не хвилюйтеся, незабаром ми дійдемо до DI-контейнера Nette. А він має низку козирів у рукаві, які надзвичайно спрощують створення застосунків, що використовують dependency injection. Наприклад, замість класу ArticleFactory достатньо буде написати лише інтерфейс:

interface ArticleFactory
{
	function create(): Article;
}

Але ми забігаємо наперед, зачекайте ще :-)

Підсумок

На початку цього розділу ми обіцяли показати вам, як проектувати чистий код. Достатньо класам

  1. передавати залежності, які їм потрібні
  2. і навпаки, не передавати те, що їм безпосередньо не потрібно
  3. і що об'єкти із залежностями найкраще створювати у фабриках

На перший погляд це може здатися не так, але ці три правила мають далекосяжні наслідки. Вони ведуть до радикально іншого погляду на проектування коду. Чи варте воно того? Програмісти, які відкинули старі звички і почали послідовно використовувати dependency injection, вважають цей крок ключовим моментом у професійному житті. Їм відкрився світ зрозумілих та підтримуваних застосунків.

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

версія: 3.x