Статьи

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

Поиск по «контейнеру внедрения зависимостей» в Packagist в настоящее время предоставляет более 95 страниц результатов. Можно с уверенностью сказать, что именно это «колесо» было изобретено.

Квадратное колесо?

Однако ни один повар не научился готовить, используя только готовые блюда. Аналогично, ни один разработчик никогда не изучал программирование, используя только «готовый код» .

В этой статье мы узнаем, как создать простой контейнер для внедрения зависимостей. Весь код, написанный в этой статье, а также аннотации PHPDoc и модульные тесты со 100% покрытием доступны в этом репозитории GitHub . Это также перечислено на Packagist .

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

Давайте начнем с планирования того, что мы хотим, чтобы наш контейнер делал. Хорошее начало — разделить «Контейнер внедрения зависимостей» на две роли: «Инъекция зависимости» и «Контейнер».

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

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

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

Изучение других контейнеров для инъекций зависимостей

Контейнер Symfony Dependency Injection позволяет нам определять сервисы различными способами. В 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 может использовать для автозагрузки наших классов. В данный момент все, что делает этот файл — это сопоставление пространства имен 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 соответственно:

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

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

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

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

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

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

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

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

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

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

 <?php namespace SitePoint \ 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 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 ) ; } } } 

И теперь у нас есть полезный контейнер для инъекций зависимостей! Чтобы увидеть примеры использования, посмотрите репозиторий на GitHub .

Последние мысли

Мы узнали, как сделать простой контейнер для инъекций зависимостей, но есть множество контейнеров с классными функциями, которых у нас еще нет!

Некоторые контейнеры для инъекций зависимостей, такие как PHP-DI и Aura.Di, предоставляют функцию, называемую автоматическим подключением. Именно здесь контейнер угадывает, какие сервисы из контейнера следует внедрить в другие. Для этого они используют API отражения, чтобы узнать информацию о параметрах конструктора.

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

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

Следите за новыми статьями на SitePoint PHP. Вскоре мы расскажем, как изобрести колесо с помощью ряда общих компонентов PHP!