Pull to refresh

Готовимся к собеседованию по PHP: Что такое «DI», «Container», «Auto-wiring» за семь простых шагов

Reading time10 min
Views44K

Для кого и для чего написана статья?

Статья написана для начинающих разработчиков на языке PHP, чтобы помочь им усвоить понятия, нужные для понимания того, как устроены и работают современные фреймворки на PHP: Dependency Injection, Container, Auto-wiring.

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

Статья продолжает цикл под условным названием "Готовимся к собеседованию по PHP". Ссылки на предыдущие статьи:

Что нужно знать, чтобы понять материал статьи?

Для понимания материала достаточно:

  • Владеть синтаксисом PHP 8

  • Знать, что такое PDO и подготовленные запросы. Примеры в статье работают с базой данных. Хоть это и необязательно для темы, но такова логика материала.

  • Для понимания седьмого шага необходимо знать, что такое рефлексия.

NB: Все примеры в статье следуют синтаксису PHP версии 8.1. Имейте это ввиду, если вы читаете статью в 2023 или более позднем году.

Шаг 0. Постановка задачи.

Для иллюстрации темы статьи возьмем простую задачу.

Допустим, что у нас есть база данных, в которой хранится информация о пользователях. Мы с вами начинаем реализовывать систему авторизации.

Требуется:

  1. Принять email пользователя, пришедший извне (из формы входа на сайт)

  2. Убедиться, что пользователь с таким email существует

  3. ЕСЛИ он существует, ТО вывести его имя, ИНАЧЕ выдать ошибку

На этом всё. Мы не будем делать проверку пароля, проводить полноценную авторизацию и даже делать форму логина - всё это только помешает.

Шаг 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. Осознание проблемы зависимостей.

Зависимость - это объект, который необходим другому объекту, нуждающемуся в зависимости для своей работы.

Мы с вами написали код, который содержит в себе ряд зависимостей:

  1. Контроллеру UserController для его работы нужен экземпляр репозитория UserRepository

  2. Репозиторию 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"

Вопрос: А нельзя ли указать в конструкторе своего сервиса нужные зависимости и автоматически получить их от контейнера, раз уж тип зависимости у нас и есть ключ объекта в контейнере?

Ответ: можно. Но придется использовать рефлексию.

Реализуем следующий алгоритм:

  1. Получаем имя класса, объект которого нужно выдать из контейнера

  2. Получаем список аргументов конструктора этого класса

  3. Если этот список не пуст - пробуем получить каждый аргумент конструктора аналогично, через контейнер, считая его тип за 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"

Заключение

Мы решили все поставленные задачи и пошагово на учебном примере разобрали, что такое:

  • Зависимости

  • Внедрение зависимостей

  • Способы внедрения зависимостей - сеттеры и конструктор

  • Контейнер

  • Сервис

  • Автоматическое разрешение и внедрение зависимостей

и написали код, реализующий всё, что было изучено.

Успехов на собеседовании и в работе!

Что почитать еще?

Tags:
Hubs:
Total votes 19: ↑16 and ↓3+15
Comments13

Articles