Как создать собственный контейнер внедрения зависимостей на PHP

На запрос «контейнер внедрения зависимостей» packagist.org выдает более 95 страниц результатов. Так что можно с уверенностью утверждать, что это «колесо» уже изобретено.

Но ни один шеф-повар не учится готовить только по готовым блюдам. Точно так же, программист не учится только по «готовому коду».

Планируем наш контейнер внедрения зависимостей

Давайте подумаем, какие задачи мы хотим поручить нашему будущему контейнеру. Для начала выделим в «Контейнере внедрения зависимостей» две роли – «внедрение зависимостей» и «контейнер».

Двумя наиболее распространенными методами внедрения зависимостей являются внедрение через конструктор и внедрение через метод класса. То есть пропускание зависимостей классов через аргументы конструктора или вызовы метода. Если мы хотим, чтобы наш контейнер мог инстанцировать и содержать службы, он должен уметь делать все это.

Контейнер должен хранить и извлекать экземпляры служб. По сравнению с созданием служб это довольно простая задача, но и она требует внимания. Пакет container-interop предоставляет набор интерфейсов, которые контейнер может реализовать. Основным интерфейсом является ContainerInterface, который определяет два метода – один для извлечения службы и другой для проверки того, была ли служба определена.

interface ContainerInterface
{
    public function get($id);
    public function has($id);
}

Учимся на примере других контейнеров внедрения зависимостей

Контейнер внедрения зависимостей Symfony позволяет нам определять службы самыми разными способами. На YAML конфигурация контейнера может иметь следующий вид:

parameters:
    # ...
    mailer.transport: sendmail
services:
    mailer:
        class:     Mailer
        arguments: ["%mailer.transport%"]
    newsletter_manager:
        class:     NewsletterManager
        calls:
            - [setMailer, ["@mailer"]]

Благодаря тому, что Symfony разделяет конфигурацию контейнера на конфигурацию параметров и служб, секретные данные приложения (ключи API, шифровальные ключи и лексемы аутентификации) хранятся в файлах параметров, которых нет в хранилищах исходных кодов.

На PHP такая же конфигурация для компонента Внедрение зависимости в Symfony выглядела бы так:

use Symfony\Component\DependencyInjection\Reference;
// ...
$container->setParameter('mailer.transport', 'sendmail');
$container
    ->register('mailer', 'Mailer')
    ->addArgument('%mailer.transport%');
$container
    ->register('newsletter_manager', 'NewsletterManager')
    ->addMethodCall('setMailer', array(new Reference('mailer')));

Используя объект Reference в вызове метода к setMailer, логика внедрения зависимости может определить, что это значение не следует передавать напрямую, а необходимо заменить службой, на которую оно ссылается в контейнере. Благодаря этому PHP-значения и другие службы могут легко внедряться в службу.

Начинаем

Для начала создадим каталог нового проекта и файл composer.json, который Composer может использовать для автозагрузки наших классов. На данный момент этот файл лишь отображает пространство имен App\Container в каталог src.

{
    "autoload": {
        "psr-4": {
            "Devacademy\\Container\\": "src/"
        }
    },
}

Поскольку мы хотим, чтобы наш контейнер реализовывал интерфейсы container-interop, компоновщик должен загрузить и добавить их в наш файл composer.json:

composer require container-interop/container-interop

Помимо основного интерфейса ContainerInterface, пакет container-interop также определяет два интерфейса исключений. Первый – для общих исключений, которые встречаются при создании службы, а второй – для ситуаций, когда служба была запрошена, но не найдена. Мы добавим еще одно исключение для ситуаций, когда параметр был запрошен, но не найден.

Поскольку нам не нужно добавлять функциональность помимо той, что предлагается базовым PHP-классом Exception, эти классы получаются довольно простыми. Хотя они могут показаться бесполезными, такое их разделение позволяет нам легко перехватывать и обрабатывать каждый из них по отдельности.

Создайте каталог src и создайте эти три файла по адресам src/Exception/ContainerException.php, src/Exception/ServiceNotFoundException.php и src/Exception/ParameterNotFoundException.php, соответственно:

<?php
namespace Devacademy\Container\Exception;
use Interop\Container\Exception\ContainerException as InteropContainerException;
class ContainerException extends \Exception implements InteropContainerException {}
<?php
namespace Devacademy\Container\Exception;
use Interop\Container\Exception\NotFoundException as InteropNotFoundException;
class ServiceNotFoundException extends \Exception implements InteropNotFoundException {}
<?php
namespace Devacademy\Container\Exception;
class ParameterNotFoundException extends \Exception {}

Ссылки на контейнер

Класс Reference в Symfony, речь о котором шла ранее, позволил библиотеке провести различие между PHP-значениями, используемыми напрямую, и аргументами, которые нужно было заменить другими службами в контейнере.

Давайте позаимствуем эту идею и создадим два класса для ссылок на параметры и службы. Поскольку оба эти класса будут являться объектами-значениями, которые хранят лишь имя ресурса, на который они ссылаются, мы будем использовать абстрактный класс в качестве базы. Так нам не придется писать один и тот же код дважды.

Создайте следующие файлы по адресам src/Reference/AbstractReference.php, src/Reference/ServiceReference.php и src/Reference/ParameterReference.php, соответственно:

<?php
namespace Devacademy\Container\Reference;
abstract class AbstractReference
{
    private $name;
    public function __construct($name)
    {
        $this->name = $name;
    }
    public function getName()
    {
        return $this->name;
    }
}
<?php
namespace Devacademy\Container\Reference;
class ServiceReference extends AbstractReference {}
<?php
namespace Devacademy\Container\Reference;
class ParameterReference extends AbstractReference {}

Класс контейнера

Пришло время создавать контейнер. Мы начнем с базового эскиза класса контейнера и по ходу дела будем добавлять методы.

Основная идея будет заключаться в том, чтобы принять два массива в конструкторе нашего контейнера. Первый массив будет содержать определения служб, а второй – определения параметров. Добавьте следующий код по адресу src/Container.php:

<?php
namespace Devacademy\Container;
use Interop\Container\ContainerInterface as InteropContainerInterface;
class Container implements InteropContainerInterface
{
    private $services;
    private $parameters;
    private $serviceStore;
    public function __construct(array $services = [], array $parameters = [])
    {
        $this->services =  $services;
        $this->parameters = $parameters;
        $this->serviceStore = [];
    }
}

Мы реализируем ContainerInterface из пакета container-interop и загружаем определения в свойства, к которым мы обратимся позже. Также мы создали свойство serviceStore и инициализировали его, превратив в пустой массив. Мы сохраним службы в этом массиве, чтобы потом (когда контейнер попросят создать службы) их можно было извлечь без необходимости создавать их вновь.

Теперь начнем писать методы, определенные container-interop. Начиная с get($name), добавьте следующий метод в класс:

use Devacademy\Container\Exception\ServiceNotFoundException;
// ...
    public function get($name)
    {
        if (!$this->has($name)) {
            throw new ServiceNotFoundException('Service not found: '.$name);
        }
        if (!isset($this->serviceStore[$name])) {
            $this->serviceStore[$name] = $this->createService($name);
        }
        return $this->serviceStore[$name];
    }
// ...

Добавьте оператор use в верхнюю часть файла. Наш метод get($name) просто проверяет, содержит ли контейнер определение для службы. Если нет, то выдается созданное нами ранее исключение ServiceNotFoundException. Если да, то он возвращает службу, создавая и сохраняя ее в хранилище (если это не было сделано ранее).

Пока мы находимся на этом этапе, мы должны создать метод для извлечения параметра из контейнера. Предполагая, что параметры, переданные конструктору, образуют N-мерный ассоциативный массив, нам нужен способ, который позволит нам легко получить доступ к любому элементу в этом массиве, используя одинарную строку. Для этого нам нужно использовать . в качестве разделителя, чтобы строка foo.bar ссылалась на ключ bar в ключе foo массива корневых параметров.

use Devacademy\Container\Exception\ParameterNotFoundException;
// ...
    public function getParameter($name)
    {
        $tokens  = explode('.', $name);
        $context = $this->parameters;
        while (null !== ($token = array_shift($tokens))) {
            if (!isset($context[$token])) {
                throw new ParameterNotFoundException('Parameter not found: ' . $name);
            }
            $context = $context[$token];
        }
        return $context;
    }
// ...

Мы уже использовали несколько методов, которые еще не написали. Первый – это метод has($name), который также определяется container-interop. Это довольно простой метод, и он лишь проверяет, содержит ли массив определений, переданный конструктору, запись для службы $name.

// ...
    public function has($name)
    {
        return isset($this->services[$name]);
    }
// ...

Второй метод, который мы вызвали, но еще не написали, это метод createService($name). Этот метод будет использовать определения, предоставленные для создания службы. Поскольку мы не хотим, чтобы этот метод вызывался за пределами контейнера, мы сделаем его закрытым.

Первое, что мы сделаем в этом методе, — проверим работоспособность. Для каждого определения службы нам нужен массив, содержащий ключ class и опциональные ключи arguments и calls. Они будут использоваться для внедрения констуктора и внедрения установщика, соответственно. Также мы можем добавить защиту от круговых ссылок – для этого нужно проверить, пытались ли мы уже создать службу.

Если у нас есть ключ arguments, нам нужно конвертировать этот массив определений аргументов в массив PHP-значений, которые можно передать конструктору. Для этого нам нужно будет конвертировать ранее определенные нами ссылочные объекты в значения, на которые они ссылаются из контейнера. Пока применим эту логику в методе resolveArguments($name, array $argumentDefinitons). Мы используем метод ReflectionClass::newInstanceArgs() для создания службы при помощи массива arguments. Это внедрение конструктора.

Если у нас имеется ключ calls, нам нужно использовать массив call definitions и применить эти определения к только что созданной нами службе. И снова мы применим эту логику в отдельном методе, определенном как initializeService($service, $name, array $callDefinitions). Это внедрение установщика.

use Devacademy\Container\Exception\ContainerException;
// ...
    private function createService($name)
    {
        $entry = &$this->services[$name];
        if (!is_array($entry) || !isset($entry['class'])) {
            throw new ContainerException($name.' service entry must be an array containing a \'class\' key');
        } elseif (!class_exists($entry['class'])) {
            throw new ContainerException($name.' service class does not exist: '.$entry['class']);
        } elseif (isset($entry['lock'])) {
            throw new ContainerException($name.' service contains a circular reference');
        }
        $entry['lock'] = true;
        $arguments = isset($entry['arguments']) ? $this->resolveArguments($name, $entry['arguments']) : [];
        $reflector = new \ReflectionClass($entry['class']);
        $service = $reflector->newInstanceArgs($arguments);
        if (isset($entry['calls'])) {
            $this->initializeService($service, $name, $entry['calls']);
        }
        return $service;
    }
// ...

Теперь нам осталось создать два последних метода. Первый метод должен конвертировать массив определений аргументов в массив PHP-значений. Для этого нам нужно заменить объекты ParameterReference и ServiceReference на соответствующие параметры и службы из контейнера.

use Devacademy\Container\Reference\ParameterReference;
use Devacademy\Container\Reference\ServiceReference;
// ...
    private function resolveArguments($name, array $argumentDefinitions)
    {
        $arguments = [];
        foreach ($argumentDefinitions as $argumentDefinition) {
            if ($argumentDefinition instanceof ServiceReference) {
                $argumentServiceName = $argumentDefinition->getName();
                $arguments[] = $this->get($argumentServiceName);
            } elseif ($argumentDefinition instanceof ParameterReference) {
                $argumentParameterName = $argumentDefinition->getName();
                $arguments[] = $this->getParameter($argumentParameterName);
            } else {
                $arguments[] = $argumentDefinition;
            }
        }
        return $arguments;
    }

Последний метод осуществляет внедрение установщика на инстанцированном объекте службы. Для этого нужно пройти в цикле по массиву определений вызовов метода. Ключ method используется для точного указания метода, а опциональный ключ arguments может использоваться для предоставления аргументов для вызова этого метода. Для того, чтобы перевести эти аргументы в PHP-значения, мы можем повторно использовать только что написанный нами метод.

    private function initializeService($service, $name, array $callDefinitions)
    {
        foreach ($callDefinitions as $callDefinition) {
            if (!is_array($callDefinition) || !isset($callDefinition['method'])) {
                throw new ContainerException($name.' service calls must be arrays containing a \'method\' key');
            } elseif (!is_callable([$service, $callDefinition['method']])) {
                throw new ContainerException($name.' service asks for call to uncallable method: '.$callDefinition['method']);
            }
            $arguments = isset($callDefinition['arguments']) ? $this->resolveArguments($name, $callDefinition['arguments']) : [];
            call_user_func_array([$service, $callDefinition['method']], $arguments);
        }
    }
}

Теперь у нас есть функционирующий контейнер внедрения зависимостей!

Заключение

Мы научились создавать простой контейнер внедрения зависимостей, но существует огромное множество контейнеров с интересными функциями, которых у нашего контейнера пока нет.
Некоторые контейнеры внедрения зависимостей (такие, как PHP-DI и Aura.Di) имеют функцию «автосоединение» (auto-wiring). Благодаря этой функции контейнер видит, какие службы из контейнера необходимо внедрить в другие. Для этого используется API отражения (API Reflection) , который позволяет получать информацию о параметрах конструктора.

Вы можете создать ответвление от хранилища и добавить другие функции (автосоединение или что угодно)!