Что такое 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
?
Кажется, у нас много вариантов. Он может взять его откуда-то из статической переменной. Или унаследовать от класса, который обеспечивает соединение с базой данных. Или использовать так называемый синглтон. Или так называемые фасады, которые используются в 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;
}
Но мы забегаем вперед, потерпите еще немного :-)
Резюме
В начале этой главы мы обещали показать вам, как проектировать чистый код. Достаточно классам
- передавать зависимости, которые им нужны
- и наоборот, не передавать то, что им напрямую не нужно
- и что объекты с зависимостями лучше всего создавать в фабриках
На первый взгляд это может показаться не так, но эти три правила имеют далеко идущие последствия. Они ведут к радикально иному взгляду на проектирование кода. Стоит ли оно того? Программисты, отбросившие старые привычки и начавшие последовательно использовать dependency injection, считают этот шаг ключевым моментом в своей профессиональной жизни. Им открылся мир понятных и поддерживаемых приложений.
Но что, если код не использует последовательно dependency injection? Что, если он построен на статических методах или синглтонах? Приносит ли это какие-либо проблемы? Приносит, и очень серьезные.