Для кого и для чего написана статья?
Статья написана для начинающих разработчиков на языке PHP, чтобы помочь им усвоить понятия, нужные для понимания того, как устроены и работают современные фреймворки на PHP: Dependency Injection, Container, Auto-wiring.
Автор надеется, что прочтение статьи поможет вам разобраться в этих вопросах и подготовиться к собеседованию или освоению нового фреймворка.
Статья продолжает цикл под условным названием «Готовимся к собеседованию по PHP». Ссылки на предыдущие статьи:
Что нужно знать, чтобы понять материал статьи?
Для понимания материала достаточно:
-
Владеть синтаксисом PHP 8
-
Знать, что такое PDO и подготовленные запросы. Примеры в статье работают с базой данных. Хоть это и необязательно для темы, но такова логика материала.
-
Для понимания седьмого шага необходимо знать, что такое рефлексия.
NB: Все примеры в статье следуют синтаксису PHP версии 8.1. Имейте это ввиду, если вы читаете статью в 2023 или более позднем году.
Шаг 0. Постановка задачи.
Для иллюстрации темы статьи возьмем простую задачу.
Допустим, что у нас есть база данных, в которой хранится информация о пользователях. Мы с вами начинаем реализовывать систему авторизации.
Требуется:
-
Принять email пользователя, пришедший извне (из формы входа на сайт)
-
Убедиться, что пользователь с таким email существует
-
ЕСЛИ он существует, ТО вывести его имя, ИНАЧЕ выдать ошибку
На этом всё. Мы не будем делать проверку пароля, проводить полноценную авторизацию и даже делать форму логина — всё это только помешает.
Шаг 1. Подготовка базовых классов.
Начнем подготовку классов, которые нам потребуются для решения задачи. Пойдем методом «снизу вверх», начиная с базовых классов, и заканчивая классами более высокого уровня.
NB: В целях упрощения статьи вопрос автозагрузки опускается. Реализуйте ее самостоятельно, либо используйте готовые решения, к примеру composer.
Сущность «Пользователь»
Упрощаем всё максимально и делаем сущность всего с двумя полями: email и имя. Никаких геттеров и сеттеров, просто публичные поля:
<?php namespace App; class User { public string $email; public string $name; }
Вопрос создания базы данных, таблицы пользователей, наполнения ее данными мы с вами опустим — он выходит за рамки этой статьи. Проделайте данные действия самостоятельно.
Класс для работы с базой данных
Нам нужен класс, который решит одну, но важную задачу: сможет делать произвольные запросы к базе данных и возвращать результаты в нужном нам виде.
Попробуем написать минимальный вариант такого класса:
<?php namespace App; class Db { private \PDO $dbh; public function __construct() { $this->dbh = new \PDO('pgsql:host=localhost;dbname=test', 'test', 'test'); } public function query(string $sql, array $params = [], $class = \stdClass::class): array { $sth = $this->dbh->prepare($sql); $sth->execute($params); return $sth->fetchAll(\PDO::FETCH_CLASS, $class); } }
NB: $dbh это «DataBase Handler», а $sth — «Statement Handler».
Репозиторий
Теперь создадим простейший репозиторий. Тоже с одним методом, который будет возвращать нам пользователя по его email:
<?php namespace App; class UserRepository { public function findByEmail(string $email): ?User { $db = new Db(); $res = $db->query( 'SELECT * FROM users WHERE email=:email', [':email' => $email], User::class ); return !empty($res) ? $res[0] : null; } }
Контроллер
Последний класс нашей «системы» — это контроллер. У него тоже будет один-единственный публичный метод (это вообще норма для контроллеров в современных фреймворках) и этот метод будет вызывать метод репозитория для поиска пользователя:
<?php namespace App; class UserController { public function handle() { $repo = new UserRepository(); // Тут, конечно, будет $_POST['email']: $user = $repo->findByEmail('test@test.com'); if (empty($user)) { throw new \Exception('Пользователь не найден!'); } return <<<RESPONSE Имя пользователя: $user->name RESPONSE; } }
Точка входа
Ну и напоследок сделаем простейшую точку входа в наше приложение, условный index.php:
<?php try { $controller = new \App\UserController(); echo $controller->handle(); } catch (Throwable $exception) { echo 'Ошибка: ' . $exception->getMessage(); }
Шаг 2. Осознание проблемы зависимостей.
Зависимость — это объект, который необходим другому объекту, нуждающемуся в зависимости для своей работы.
Мы с вами написали код, который содержит в себе ряд зависимостей:
-
Контроллеру
UserControllerдля его работы нужен экземпляр репозиторияUserRepository -
Репозиторию
UserRepository, в свою очередь, для его работы нужен объект доступа к базе данных классаDb
В нашем коде сейчас нет никакого механизма разрешения зависимостей. Мы их создаем «на месте» — там, где нужен какой-то объект, там он и создается. Потребовался репозиторий — написали new UserRepository и начали с ним работать.
Это не очень хорошо. Код содержит в себе смесь бизнес-логики и логики получения зависимостей, и это всё в одном месте.
Шаг 3. Разделим получение и использование зависимостей.
Давайте попробуем улучшить наш код. Разделим получение зависимых объектов и их использование. Перепишем наши UserRepository и UserController таким образом, чтобы нужные им для работы объекты-зависимости передавались в них явно извне, а не создавались в их коде:
<?php namespace App; class UserRepository { private Db $db; public function setDb(Db $db): self { $this->db = $db; return $this; } public function findByEmail(string $email): ?User { $res = $this->db->query( 'SELECT * FROM users WHERE email=:email', [':email' => $email], User::class ); return !empty($res) ? $res[0] : null; } }
<?php namespace App; class UserController { private UserRepository $userRepository; public function setUserRepository(UserRepository $userRepository): self { $this->userRepository = $userRepository; return $this; } public function handle() { $user = $this->userRepository->findByEmail('test@test.com'); if (empty($user)) { throw new \Exception('Пользователь не найден!'); } return <<<RESPONSE Имя пользователя: $user->name RESPONSE; } }
Ну, и, разумеется, нам придется изменить код, вызывающий наш контроллер:
<?php try { $controller = (new \App\UserController()) ->setUserRepository( (new \App\UserRepository()) ->setDb( new \App\Db() ) ) ; echo $controller->handle(); } catch (Throwable $exception) { echo 'Ошибка: ' . $exception->getMessage(); }
Что мы получили? Мы получили явное внедрение зависимостей. Теперь мы наглядно видим, какой объект у нас зависит от других объектов и от каких именно и управляем этими зависимостями с помощью сеттеров.
Внедрение зависимостей (или Dependency Injection, сокращенно «DI») — явная передача зависимости в объект, который в ней нуждается извне, вместо создания зависимого объекта в коде нуждающегося.
Шаг 4. Делаем внедрение зависимостей обязательным.
В чем существенный недостаток кода, который мы получили на предыдущем шаге? В том, что можно банально забыть внедрить зависимость!
Если мы не вызовем метод UserController::setUserRepository() — наш контроллер «сломается». Ровно то же произойдет с репозиторием, если не вызвать UserRepository::setDb() — он просто не станет работать должным образом.
Как быть? Есть ли способ «не забыть» внедрить в объект нужную ему зависимость?
Есть. Нужно перенести код внедрения зависимостей в конструктор.
Во-первых, конструктор вызывается неизбежно при создании объекта — его нельзя «забыть» вызвать. Во-вторых, так мы гарантируем, что если что-то пойдет не так, то наш код «сломается» раньше — не во время работы объекта, а сразу же в момент попытки его создать без нужной зависимости.
Переписываем наш код:
<?php namespace App; class UserRepository { public function __construct( private Db $db ) {} public function findByEmail(string $email): ?User { $res = $this->db->query( 'SELECT * FROM users WHERE email=:email', [':email' => $email], User::class ); return !empty($res) ? $res[0] : null; } }
<?php namespace App; class UserController { public function __construct( private UserRepository $userRepository ) {} public function handle() { $user = $this->userRepository->findByEmail('test@test.com'); if (empty($user)) { throw new \Exception('Пользователь не найден!'); } return <<<RESPONSE Имя пользователя: $user->name RESPONSE; } }
<?php try { $controller = (new \App\UserController( new \App\UserRepository( new \App\Db() ) )); echo $controller->handle(); } catch (Throwable $exception) { echo 'Ошибка: ' . $exception->getMessage(); }
Мы добились важных результатов: не только сократили наш код, но и сделали зависимости действительно обязательными — их теперь невозможно «забыть» передать в нуждающийся объект.
Внедрение зависимостей через конструктор считается основным способом использования DI в PHP.
Внедрение зависимостей через сеттеры тоже имеет право на жизнь. Например, в фреймворке Symfony используются и тот, и тот механизмы. Но, конечно, DI через конструктор — это способ по умолчанию.
Шаг 5. Добавляем в проект контейнер.
Контейнер — это очень простой паттерн. По сути дела это специальный объект, который умеет работать, как «key-value» хранилище для других объектов.
Мы просим у контейнера объект по некоему ключу — он нам его возвращает.
Для реализации контейнера мы последуем стандарту PSR-11: https://www.php-fig.org/psr/psr-11/ и реализуем методы has() и get().
Заранее прощу прощения за то, что в учебном примере не указываю интерфейс Psr\Container\ContainerInterface — я это делаю для упрощения.
Дополнительно в конструкторе нашего контейнера определим функции, которые будут, при обращении к ним, создавать нужные объекты. Тем самым мы добиваемся «ленивой» инициализации объектов — они создаются только в тот момент, когда действительно запрашиваются.
В реальном приложении, конечно же, код, ответственный за создание объектов, находящихся в контейнере, будет чуть сложнее.
<?php namespace App; class Container { private array $objects = []; public function __construct() { // Ключи в этом массиве - строковые ID объектов // Значения - функции, строящие нужный объект $this->objects = [ 'db' => fn() => new Db(), 'repository.user' => fn() => new UserRepository($this->get('db')), 'controller.user' => fn() => new UserController($this->get('repository.user')), ]; } public function has(string $id): bool { return isset($this->objects[$id]); } public function get(string $id): mixed { return $this->objects[$id](); } }
Код нашей точки входа, соответственно, будет тоже изменен и станет выглядеть так:
<?php try { $controller = (new \App\Container())->get('controller.user'); echo $controller->handle(); } catch (Throwable $exception) { echo 'Ошибка: ' . $exception->getMessage(); }
Таким образом мы с вами добились переноса логики получения объектов в контейнер. Кстати, пора ввести новый термин:
Объект, получаемый в нашем коде через контейнер и чьими зависимостями также управляет контейнер, достаточно часто называют сервисом.
Получается, что наши Db, UserRepository и UserController — это сервисы. И теперь контейнер занимается их «изготовлением», разрешением их зависимостей и «поставкой по требованию».
Шаг 6. Получаем сервисы по имени их класса.
На предыдущем шаге мы добились получения сервисов по их строковому идентификатору. Для объекта работы с базой данных это db, для репозитория repository.user, а для контроллера таким идентификатором стал controller.user
Однако не избыточно ли это? Ведь у каждого класса и так уже есть свой естественный идентификатор — полное имя этого класса!
Давайте перепишем наш код так, чтобы в качестве идентификаторов сервисов использовались имена их классов:
<?php namespace App; class Container { private array $objects = []; public function __construct() { $this->objects = [ Db::class => fn() => new Db(), UserRepository::class => fn() => new UserRepository($this->get(Db::class)), UserController::class => fn() => new UserController($this->get(UserRepository::class)), ]; } // Остальная часть класса не меняется }
<?php try { $controller = (new \App\Container())->get(\App\UserController::class); echo $controller->handle(); } catch (Throwable $exception) { echo 'Ошибка: ' . $exception->getMessage(); }
Стоит отметить, что достаточно часто в приложениях, использующих DI и контейнер, идентификаторами сервисов выступают имена их интерфейсов, а не классов.
В таком случае требуется дополнительная логика, чтобы контейнер мог определить — объект какого класса нужно подготовить и отдать в ответ на требование выдать, к примеру,
DbConnectionInterface
Шаг 7. Самый сложный. Добавляем немного рефлексии.
Вот теперь мы, наконец, подошли к самому интересному. А именно к концепции «Auto-wiring»
Вопрос: А нельзя ли указать в конструкторе своего сервиса нужные зависимости и автоматически получить их от контейнера, раз уж тип зависимости у нас и есть ключ объекта в контейнере?
Ответ: можно. Но придется использовать рефлексию.
Реализуем следующий алгоритм:
-
Получаем имя класса, объект которого нужно выдать из контейнера
-
Получаем список аргументов конструктора этого класса
-
Если этот список не пуст — пробуем получить каждый аргумент конструктора аналогично, через контейнер, считая его тип за ID сервиса.
Перепишем наш контейнер таким образом, чтобы сохранить и «старый» механизм ленивого вызова функций, возвращающих сервисы, и «новый» — с авторазрешением зависимостей:
<?php namespace App; class Container { private array $objects = []; public function has(string $id): bool { return isset($this->objects[$id]) || class_exists($id); } public function get(string $id): mixed { return isset($this->objects[$id]) ? $this->objects[$id]() // "Старый подход" : $this->prepareObject($id); // "Новый" подход } private function prepareObject(string $class): object { $classReflector = new \ReflectionClass($class); // Получаем рефлектор конструктора класса, проверяем - есть ли конструктор // Если конструктора нет - сразу возвращаем экземпляр класса $constructReflector = $classReflector->getConstructor(); if (empty($constructReflector)) { return new $class; } // Получаем рефлекторы аргументов конструктора // Если аргументов нет - сразу возвращаем экземпляр класса $constructArguments = $constructReflector->getParameters(); if (empty($constructArguments)) { return new $class; } // Перебираем все аргументы конструктора, собираем их значения $args = []; foreach ($constructArguments as $argument) { // Получаем тип аргумента $argumentType = $argument->getType()->getName(); // Получаем сам аргумент по его типу из контейнера $args[$argument->getName()] = $this->get($argumentType); } // И возвращаем экземпляр класса со всеми зависимостями return new $class(...$args); } }
Остальной код приложения на этом шаге не меняется.
Теперь, чтобы в нашем приложении один сервис получил другой в качестве зависимости, достаточно будет просто указать тип зависимого сервиса в конструкторе нуждающегося. Это и есть автоматическое разрешение зависимостей, или «auto-wiring»
Заключение
Мы решили все поставленные задачи и пошагово на учебном примере разобрали, что такое:
-
Зависимости
-
Внедрение зависимостей
-
Способы внедрения зависимостей — сеттеры и конструктор
-
Контейнер
-
Сервис
-
Автоматическое разрешение и внедрение зависимостей
и написали код, реализующий всё, что было изучено.
Успехов на собеседовании и в работе!
Что почитать еще?
-
https://symfony.com/doc/current/service_container/autowiring.html
-
Код, использованный в статье выложен на https://github.com/pr-of-it/habr-di, каждый шаг оформлен коммитом.
ссылка на оригинал статьи https://habr.com/ru/post/655399/
Добавить комментарий