Что такое Dependency Injection?
Эта глава знакомит с базовыми приёмами программирования, которых стоит придерживаться при написании любого приложения. Это основы, необходимые для чистого, понятного и поддерживаемого кода.
Если вы примете и будете соблюдать эти правила, 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: пусть вам это передадут
Самое важное правило гласит: все данные, которые нужны функциям или классам, должны быть им переданы.
Вместо того чтобы придумывать скрытые способы, которыми они раздобудут данные, просто передайте параметры. Вы сэкономите время, потраченное на выдумывание скрытых путей, которые точно не улучшат ваш код.
Если вы будете всегда и везде соблюдать это правило, вы на пути к коду без скрытых зависимостей. К коду, понятному не только автору, но и всякому, кто прочитает его позже. Где всё понятно из сигнатур функций и классов и не нужно разыскивать скрытые подробности в реализации.
Профессионально этот приём называется Dependency Injection (внедрение зависимостей). А сами данные называются зависимостями. Это просто передача параметров, ничего больше.
Пожалуйста, не путайте Dependency Injection, который является шаблоном проектирования, с “контейнером внедрения зависимостей”, который является инструментом, то есть чем-то принципиально другим. О контейнерах мы поговорим позже.
От функций к классам
А как это относится к классам? Класс – сущность посложнее простой функции, но правило № 1 полностью применимо и здесь. Просто способов передать аргументы больше. Например, довольно похоже на случай с функцией:
class Math
{
public function sum(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->sum(23, 1); // 24
Или другими методами, или прямо через конструктор:
class Sum
{
public function __construct(
private float $a,
private float $b,
) {
}
public function calculate(): float
{
return $this->a + $this->b;
}
}
$sum = new Sum(23, 1);
echo $sum->calculate(); // 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?
Кажется, что вариантов много. Он мог бы взять его из статической переменной. Или наследуясь от класса, предоставляющего соединение с базой. Или использовать синглтон. Или так называемые фасады, как в 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): void
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Как вы думаете, соблюли ли мы Правило № 1: пусть вам это передадут?
Нет.
Ключевые сведения, каталог с файлом лога, класс добывает сам из константы.
Посмотрите на пример использования:
$logger = new Logger;
$logger->log('Temperature is 23 °C');
$logger->log('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('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 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 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 в мире 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;
}
Но мы забегаем вперёд, оставайтесь с нами :-)
Итоги
В начале этой главы мы пообещали показать порядок проектирования чистого кода. Достаточно позаботиться о том, чтобы классам:
- передавались зависимости, которые им нужны
- и наоборот, не передавалось то, что им напрямую не нужно
- а объекты с зависимостями лучше всего создавать в фабриках
На первый взгляд это может быть неочевидно, но эти три правила имеют далеко идущие последствия. Они приводят к принципиально другому взгляду на проектирование кода. Стоит ли оно того? Программисты, которые оставили старые привычки и стали последовательно использовать Dependency Injection, считают этот шаг поворотным в своей профессиональной карьере. Он открыл им мир ясных и поддерживаемых приложений.
А что, если код не использует Dependency Injection последовательно? Что, если он построен на статических методах или синглтонах? Приводит ли это к проблемам? Да, и к весьма серьёзным.