[Перевод] Как создать свой собственный Dependency Injection Container

Привет всем!
Это вольный перевод статьи How to Build Your Own Dependency Injection Container.
Т.к. это мой первый перевод для хабра, да и вообще. Прошу указывать на ошибки, неточности итд…

Как создать свой собственный Dependency Injection Container.


Поиск «dependency injection container» на packagist на данный момент выдает более 95 страниц результата. С уверенностью можно сказать, что это особое «колесо» уже изобретено.

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

В этой статье мы собираемся учиться делать простой dependency injection container пакет. Весь написанный в статье код, плюс PHPDoc аннатации и unit-тесты с 100% покрытием доступны на GitHub. Все это так же добавлено на Packagist.

Планируем наш Dependency Injection Container


Позвольте нам начать с планирования того что мы хотим чтобы наш контейнер делал.
Хорошее начало это разделить «Dependency Injection Container» на две роли, «Dependency Injection» и «Container».

Два наиболее распространенных метода для выполнения внедрения зависимости через constructor injection или setter injection. Это передача класса зависимости через конструктор в виде аргумента или вызов метода. Если наш контейнер будет иметь возможность создавать экземпляр и содержать сервисы, он будет способен осуществить оба этих способа.

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

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


Опыт других Dependency Injection Containers


Symfony Dependency Injection Container позволяет нам определять сервисы различными способами. В YAML, конфигурация для контейнера может выглядеть так:

parameters:
    # ...
    mailer.transport: sendmail

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


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

В PHP, та же самая конфигурация компонента Symfony Dependency Injection была бы похожа на это:

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 чтобы работал автозагрузчик наших классов. Весь этот файл в данный момент является картой SitePoint\Container пространства имен к директории src.

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


Дальше как мы собиралемся сделать наш контейнер будет реализовать интерфейс container-interop, нам нужно чтобы composer загрузил их и добавил в наш 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 соответственно:





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


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

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

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

name = $name;
    }

    public function getName()
    {
        return $this->name;
    }
}



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


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

Основная идея принимать два массива в конструктор нашего контейнера. Первый массив должен содержать определения сервиса, а второй определения параметров.

В src/Container.php поместите следующий код:

services     = $services;
        $this->parameters   = $parameters;
        $this->serviceStore = [];
    }
}


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

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

use SitePoint\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 SitePoint\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 SitePoint\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 SitePoint\Container\Reference\ParameterReference;
use SitePoint\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);
        }
    }
}


И сейчас мы имеем готовый dependency injection container! Чтобы увидеть примеры использования, проверьте репозиторий на GitHub.

Окончание мысли


Мы изучили как создать простой dependency injection container, но есть множество контейнеров с замечательными функциями которые наш еще не имеет.

Некоторые dependency injection containers, такие как PHP-DI и Aura.DI предоставляют особенность которая называется автоматическое подключение. Это где контейнер догадывается какие сервисы из контейнера следует внедрить в другие. Чтобы сделать это они используют reflection API для поиска информации о параметрах конструктора.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

© Habrahabr.ru