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

Эта глава знакомит вас с основными практиками программирования, которым вы должны следовать при написании любого приложения. Это основы, необходимые для написания чистого, понятного и сопровождаемого кода.

Если вы усвоите эти правила и будете следовать им, 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: 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('Температура 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('/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, который больше не использует константу 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('Были разосланы электронные письма');

		} 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 в мире инъекций зависимостей.

Пожалуйста, не путайте с шаблоном проектирования 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