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