Что такое «внедрение зависимостей»?

Эта глава знакомит вас с основными методами программирования, которые лежат в основе всего фреймворка 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 вещей, которые нужно знать о потере веса';
$article->content = 'Каждый год миллионы людей в ...';
$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() хранит статью. В таблице базы данных? В какой именно, в crisp или test? И как это можно изменить?

Пользователь должен посмотреть, как реализован метод 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,
		]);
	}
}

Если вы собираетесь написать класс, которому, например, требуется база данных, не выясняйте, откуда её взять, а пусть она будет передана вам. Возможно, в качестве параметра конструктора или другого метода. Объявляйте зависимости. Объявляйте их в API вашего класса. Вы получите понятный и предсказуемый код.

Как насчет класса, который регистрирует сообщения об ошибках:

class Logger
{
	public function log(string $message)
	{
		$file = LogDir . '/log.txt';
		file_put_contents($file, $message . "\n", FILE_APPEND);
	}
}

Как вы думаете, следовали ли мы правилу №1: использовать параметры?

Нет.

Класс получает ключевую информацию, директорию, содержащую файл журнала, из константы.

Посмотрите пример использования:

$logger = new Logger;
$logger->log('Температура 23 °C');
$logger->log('Температура 10 °C');

Не зная реализации, можете ли вы ответить на вопрос, где записаны сообщения? Не кажется ли вам, что существование константы LogDir необходимо для его работы? И сможете ли вы создать второй экземпляр, который будет писать в другое место? Конечно, нет.

Давайте исправим класс:

class Logger
{
	public function __construct(
		private string $file,
	) {
	}

	public function log(string $message)
	{
		file_put_contents($this->file, $message . "\n", FILE_APPEND);
	}
}

Теперь класс стал намного понятнее, более настраиваемым и, следовательно, более полезным:

$logger = new Logger('/path/to/log.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, который больше не использует константу LogDir, требует указания пути к файлу в конструкторе. Как решить эту проблему? Классу NewsletterDistributor всё равно, куда записываются сообщения, он просто хочет их записывать.

Решением снова является правило №1: используйте параметры: передавайте классу все данные, которые ему нужны.

Значит, мы передаем путь к журналу в конструктор, который затем используем при создании объекта 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 ясно, что ведение журнала является частью его функциональности. И у вас есть возможность заменить логгер на другой.

Если во всем приложении мы можем довольствоваться единственным экземпляром логгера и передавать его везде, где что-то регистрируется, то в случае с классом Article всё обстоит иначе. Мы захотим создать несколько его экземпляров. Как справиться с зависимостью от базы данных в конструкторе? В качестве примера возьмем контроллер, который должен сохранять статью в базу данных после отправки формы:

class UserController extends Controller
{
	public function formSubmitted($data)
	{
		$article = new Article(/* ... */);
		$article->title = $data->title;
		$article->content = $data->content;
		$article->save();
	}
}

Предлагается возможное решение: передайте объект базы данных в конструктор класса UserController и используйте $article = new Article($this->db).

Как и в предыдущем случае, это неправильная практика. База данных не является зависимостью UserController, а является зависимостью Article. Более того, как только конструктор класса Article будет каким-то образом изменен (добавлен новый параметр), нам придется модифицировать код во всех местах, где создаются экземпляры.

Правильное решение — фабрики.

Правило №2: Используйте фабрики

Удаляя скрытые привязки и передавая все данные в качестве аргументов, мы получаем более настраиваемые и гибкие классы. Поэтому нам всё ещё нужно что-то для создания и настройки этих более гибких классов. Мы назовем это фабрикой.

Эмпирическое правило таково: если класс имеет зависимости, оставьте создание их экземпляров фабрике.

Фабрики являются более разумной заменой оператору new в мире внедрения зависимостей.

Фабрика

Фабрика — это класс, который создает и настраивает объекты. Фабрика, которая производит Article, будет называться ArticleFactory, и её использование в контроллере будет следующим:

class UserController extends Controller
{
	public function __construct(
		private ArticleFactory $articleFactory,
	) {
	}

	public function formSubmitted($data)
	{
		// let the factory create an object
		$article = $this->articleFactory->create();
		$article->title = $data->title;
		$article->content = $data->content;
		$article->save();
	}
}

Реализация фабрики может выглядеть следующим образом:

class ArticleFactory
{
	public function __construct(
		private Nette\Database\Connection $db,
	) {
	}

	public function create(): Article
	{
		return new Article($this->db);
	}
}

В этот момент, когда сигнатура конструктора класса Article изменяется, единственной частью кода, которая должна реагировать на это, является фабрика ArticleFactory. Любой другой код, работающий с объектами Article, например, UserController, остается незатронутым.

Вы, возможно, сейчас бьете себя по лбу, задаваясь вопросом, каким образом вам это поможет. Ведь объем кода вырос и переместился из контроллера в отдельный класс. Однако у Nette DI есть скрытый туз в рукаве. Он понимает концепцию фабрик и даже может написать такой сервис для нас. Поэтому вместо класса ArticleFactory мы можем просто создать интерфейс:

interface ArticleFactory
{
	function create(): Article;
}

Но мы немного забегаем вперед, перейдем к этому через минуту.

Подведём итог

В начале этой главы мы обещали продемонстрировать простой принцип разработки приложений. Хотя сам принцип прост (предоставить классам необходимые им данные), то, что из него следует, требует более глубокого осмысления. Не стесняйтесь перечитывать эту главу несколько раз.

Программисты, которые отбросили старые привычки и начали последовательно использовать внедрение зависимостей, считают это поворотным моментом в своей профессиональной жизни. Она открыла мир ясных и устойчивых приложений.

Теперь мы посмотрим, что такое Контейнер внедрения зависимостей.