Настоящая инъекция зависимостей с компонентами Symfony
Опубликовано: 2022-03-11Symfony2, высокопроизводительная PHP-инфраструктура, использует шаблон контейнера внедрения зависимостей, где компоненты предоставляют интерфейс внедрения зависимостей для DI-контейнера. Это позволяет каждому компоненту не заботиться о других зависимостях. Класс Kernel инициализирует DI-контейнер и внедряет его в различные компоненты. Но это означает, что DI-контейнер можно использовать в качестве Service Locator.
Для этого в Symfony2 даже есть класс ContainerAware. Многие придерживаются мнения, что Service Locator является антипаттерном в Symfony2. Лично я не согласен. Это более простой шаблон по сравнению с DI, и он хорош для простых проектов. Но шаблон Service Locator и шаблон DI-контейнера, объединенные в одном проекте, определенно являются анти-шаблоном.
В этой статье мы попытаемся создать приложение Symfony2 без реализации паттерна Service Locator. Будем следовать одному простому правилу: знать о DI-контейнере может только сборщик DI-контейнера.
DI-контейнер
В шаблоне внедрения зависимостей DI-контейнер определяет зависимости службы, а службы могут предоставлять только интерфейс для внедрения. Есть много статей о внедрении зависимостей, и вы, вероятно, прочитали их все. Так что давайте не будем зацикливаться на теории, а просто взглянем на основную идею. ДИ может быть 3-х видов:
В Symfony структуру внедрения можно определить с помощью простых конфигурационных файлов. Вот как можно настроить эти 3 типа впрыска:
services: my_service: class: MyClass constructor_injection_service: class: SomeClass1 arguments: ["@my_service"] method_injection_service: class: SomeClass2 calls: - [ setProperty, "@my_service" ] property_injection_service: class: SomeClass3 properties: property: "@my_service"
Начальный проект
Давайте создадим нашу базовую структуру приложения. Пока мы этим занимаемся, мы установим компонент Symfony DI-контейнера.
$ mkdir trueDI $ cd trueDI $ composer init $ composer require symfony/dependency-injection $ composer require symfony/config $ composer require symfony/yaml $ mkdir config $ mkdir www $ mkdir src
Чтобы заставить автозагрузчик композитора найти наши собственные классы в папке src, мы можем добавить свойство autoloader в файл composer.json:
{ // ... "autoload": { "psr-4": { "": "src/" } } }
А давайте создадим свой контейнерный билдер и запретим инъекцию контейнеров.
// in src/TrueContainer.php use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\ContainerInterface; class TrueContainer extends ContainerBuilder { public static function buildContainer($rootPath) { $container = new self(); $container->setParameter('app_root', $rootPath); $loader = new YamlFileLoader( $container, new FileLocator($rootPath . '/config') ); $loader->load('services.yml'); $container->compile(); return $container; } public function get( $id, $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE ) { if (strtolower($id) == 'service_container') { if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE !== $invalidBehavior ) { return; } throw new InvalidArgumentException( 'The service definition "service_container" does not exist.' ); } return parent::get($id, $invalidBehavior); } }
Здесь мы используем компоненты Symfony Config и Yaml. Вы можете найти подробности в официальной документации здесь. Также на всякий случай мы определили параметр корневого пути app_root. Метод get перегружает поведение получения родительского класса по умолчанию и не позволяет контейнеру возвращать «service_container».
Далее нам нужна точка входа для приложения.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));
Этот предназначен для обработки http-запросов. У нас может быть больше точек входа для консольных команд, задач cron и многого другого. Каждая точка входа должна получать определенные сервисы и должна знать о структуре DI-контейнера. Это единственное место, где мы можем запросить услуги из контейнера. С этого момента мы попробуем собрать это приложение только с использованием конфигурационных файлов DI-контейнера.
HttpKernel
HttpKernel (не ядро фреймворка с проблемой локатора сервисов) будет нашим базовым компонентом для веб-части приложения. Вот типичный рабочий процесс HttpKernel:
Зеленые квадраты — это события.
HttpKernel использует компонент HttpFoundation для объектов Request и Response и компонент EventDispatcher для системы событий. Нет проблем с их инициализацией конфигурационными файлами DI-контейнера. HttpKernel должен быть инициализирован службами EventDispatcher, ControllerResolver и, при необходимости, службами RequestStack (для подзапросов).
Вот конфигурация контейнера для него:
# in config/events.yml services: dispatcher: class: Symfony\Component\EventDispatcher\EventDispatcher
# in config/kernel.yml services: request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] request_stack: class: Symfony\Component\HttpFoundation\RequestStack resolver: class: Symfony\Component\HttpKernel\Controller\ControllerResolver http_kernel: class: Symfony\Component\HttpKernel\HttpKernel arguments: ["@dispatcher", "@resolver", "@request_stack"]
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' }
Как видите, мы используем свойство factory для создания службы запросов. Служба HttpKernel получает только объект запроса и возвращает объект ответа. Это можно сделать во фронт-контроллере.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $HTTPKernel = $container->get('http_kernel'); $request = $container->get('request'); $response = $HTTPKernel->handle($request); $response->send();
Или ответ можно определить как службу в конфигурации, используя свойство «фабрика».
# in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]
И тогда мы просто получаем его во фронт-контроллере.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();
Служба распознавателя контроллера получает свойство _controller из атрибутов службы запросов для разрешения контроллера. Эти атрибуты можно определить в конфигурации контейнера, но это выглядит немного сложнее, потому что мы должны использовать объект ParameterBag вместо простого массива.
# in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, \App\Controller\DefaultController::defaultAction ]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...
А вот и класс DefaultController с методом defaultAction.
// in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Response; class DefaultController { function defaultAction() { return new Response("Hello cruel world"); } }
Со всем этим у нас должно быть работающее приложение.
Этот контроллер довольно бесполезен, потому что у него нет доступа к какой-либо службе. В среде Symfony эта проблема решается внедрением DI-контейнера в контроллер и использованием его в качестве локатора сервисов. Мы не будем этого делать. Итак, давайте определим контроллер как сервис и внедрим в него сервис запросов. Вот конфигурация:
# in config/controllers.yml services: controller.default: class: App\Controller\DefaultController arguments: [ "@request"]
# in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, ["@controller.default", defaultAction ]]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' }
И код контроллера:
// in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class DefaultController { /** @var Request */ protected $request; function __construct(Request $request) { $this->request = $request; } function defaultAction() { $name = $this->request->get('name'); return new Response("Hello $name"); } }
Теперь у контроллера есть доступ к сервису запросов. Как видите, эта схема имеет циклические зависимости. Это работает, потому что DI-контейнер разделяет службу после создания и до внедрения методов и свойств. Поэтому, когда создается служба контроллера, служба запросов уже существует.
Вот как это работает:
Но это работает только потому, что служба запросов создается первой. Когда мы получаем службу ответов во фронт-контроллере, служба запросов является первой инициализированной зависимостью. Если мы попытаемся сначала получить службу контроллера, это вызовет ошибку циклической зависимости. Это можно исправить с помощью внедрения методов или свойств.
Но есть еще одна проблема. DI-контейнер инициализирует каждый контроллер зависимостями. Таким образом, он инициализирует все существующие службы, даже если они не нужны. К счастью, контейнер имеет ленивую загрузку. Компонент Symfony DI использует 'ocramius/proxy-manager' для прокси-классов. Мы должны установить мост между ними.
$ composer require symfony/proxy-manager-bridge
И определить его на этапе сборки контейнера:
// in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
Теперь мы можем определить ленивые сервисы.
# in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]
Таким образом, контроллеры будут вызывать инициализацию зависимых служб только при вызове фактического метода. Кроме того, это позволяет избежать ошибки циклической зависимости, поскольку служба контроллера будет совместно использоваться до фактической инициализации; хотя мы по-прежнему должны избегать циклических ссылок. В этом случае мы не должны внедрять службу контроллера в службу запросов или службу запросов в службу контроллера. Очевидно, нам нужна служба запросов в контроллерах, поэтому давайте не будем внедрять службу запросов на этапе инициализации контейнера. Для этого в HttpKernel есть система событий.
Маршрутизация
Видимо мы хотим иметь разные контроллеры для разных запросов. Итак, нам нужна система маршрутизации. Давайте установим компонент маршрутизации Symfony.

$ composer require symfony/routing
Компонент маршрутизации имеет класс Router, который может использовать файлы конфигурации маршрутизации. Но эти конфигурации являются просто параметрами ключ-значение для класса Route. Фреймворк Symfony использует собственный преобразователь контроллеров из FrameworkBundle, который внедряет контейнер в контроллеры с интерфейсом ContainerAware. Это именно то, чего мы пытаемся избежать. Преобразователь контроллера HttpKernel возвращает объект класса как есть, если он уже существует в атрибуте «_controller» в виде массива с объектом контроллера и строкой метода действия (на самом деле, преобразователь контроллера вернет его как есть, если это просто массив). Таким образом, мы должны определить каждый маршрут как сервис и внедрить в него контроллер. Давайте добавим другой сервис контроллера, чтобы посмотреть, как он работает.
# in config/controllers.yml # ... controller.page: lazy: true class: App\Controller\PageController arguments: [ "@request"]
// in src/App/Controller/PageController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class PageController { /** @var Request */ protected $request; function __construct(Request $request) { $this->request = $request; } function defaultAction($id) { return new Response("Page $id doesn't exist"); } }
Компонент HttpKernel имеет класс RouteListener, который использует событие 'kernel.request'. Вот одна из возможных конфигураций с ленивыми контроллерами:
# in config/routes/default.yml services: route.home: class: Symfony\Component\Routing\Route arguments: path: / defaults: _controller: ["@controller.default", 'defaultAction'] route.page: class: Symfony\Component\Routing\Route arguments: path: /page/{id} defaults: _controller: ["@controller.page", 'defaultAction']
# in config/routing.yml imports: - { resource: 'routes/default.yml' } services: route.collection: class: Symfony\Component\Routing\RouteCollection calls: - [ add, ["route_home", "@route.home"] ] - [ add, ["route_page", "@route.page"] ] router.request_context: class: Symfony\Component\Routing\RequestContext calls: - [ fromRequest, ["@request"] ] router.matcher: class: Symfony\Component\Routing\Matcher\UrlMatcher arguments: [ "@route.collection", "@router.request_context" ] router.listener: class: Symfony\Component\HttpKernel\EventListener\RouterListener arguments: matcher: "@router.matcher" request_stack: "@request_stack" context: "@router.request_context"
# in config/events.yml service: dispatcher: class: Symfony\Component\EventDispatcher\EventDispatcher calls: - [ addSubscriber, ["@router.listener"]]
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' } - { resource: 'routing.yml' }
Также нам нужен генератор URL в нашем приложении. Вот:
# in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"
Генератор URL можно внедрить в контроллер и службы рендеринга. Теперь у нас есть базовое приложение. Любая другая служба может быть определена таким же образом, как файл конфигурации внедряется в определенные контроллеры или диспетчер событий. Например, вот несколько конфигураций для Twig и Doctrine.
Веточка
Twig — это механизм шаблонов по умолчанию в среде Symfony2. Многие компоненты Symfony2 могут использовать его без каких-либо адаптеров. Так что это очевидный выбор для нашего приложения.
$ composer require twig/twig $ mkdir src/App/View
# in config/twig.yml services: templating.twig_loader: class: Twig_Loader_Filesystem arguments: [ "%app_root%/src/App/View" ] templating.twig: class: Twig_Environment arguments: [ "@templating.twig_loader" ]
Доктрина
Doctrine — это ORM, используемая во фреймворке Symfony2. Мы можем использовать любой другой ORM, но компоненты Symfony2 уже могут использовать многие функции Docrine.
$ composer require doctrine/orm $ mkdir src/App/Entity
# in config/doctrine.yml parameters: doctrine.driver: "pdo_pgsql" doctrine.user: "postgres" doctrine.password: "postgres" doctrine.dbname: "true_di" doctrine.paths: ["%app_root%/src/App/Entity"] doctrine.is_dev: true services: doctrine.config: class: Doctrine\ORM\Configuration factory: [ Doctrine\ORM\Tools\Setup, createAnnotationMetadataConfiguration ] arguments: paths: "%doctrine.paths%" isDevMode: "%doctrine.is_dev%" doctrine.entity_manager: class: Doctrine\ORM\EntityManager factory: [ Doctrine\ORM\EntityManager, create ] arguments: conn: driver: "%doctrine.driver%" user: "%doctrine.user%" password: "%doctrine.password%" dbname: "%doctrine.dbname%" config: "@doctrine.config"
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' } - { resource: 'routing.yml' } - { resource: 'twig.yml' } - { resource: 'doctrine.yml' }
Мы также можем использовать файлы конфигурации сопоставления YML и XML вместо аннотаций. Нам просто нужно использовать методы createYAMLMetadataConfiguration и createXMLMetadataConfiguration и указать путь к папке с этими файлами конфигурации.
Внедрение каждой необходимой службы в каждый контроллер по отдельности может быстро стать очень раздражающим. Чтобы сделать его немного лучше, компонент DI-контейнера имеет абстрактные сервисы и наследование сервисов. Итак, мы можем определить некоторые абстрактные контроллеры:
# in config/controllers.yml services: controller.base_web: lazy: true abstract: true class: App\Controller\Base\WebController arguments: request: "@request" templating: "@templating.twig" entityManager: "@doctrine.entity_manager" urlGenerator: "@router.generator" controller.default: class: App\Controller\DefaultController parent: controller.base_web controller.page: class: App\Controller\PageController parent: controller.base_web
// in src/App/Controller/Base/WebController.php namespace App\Controller\Base; use Symfony\Component\HttpFoundation\Request; use Twig_Environment; use Doctrine\ORM\EntityManager; use Symfony\Component\Routing\Generator\UrlGenerator; abstract class WebController { /** @var Request */ protected $request; /** @var Twig_Environment */ protected $templating; /** @var EntityManager */ protected $entityManager; /** @var UrlGenerator */ protected $urlGenerator; function __construct( Request $request, Twig_Environment $templating, EntityManager $entityManager, UrlGenerator $urlGenerator ) { $this->request = $request; $this->templating = $templating; $this->entityManager = $entityManager; $this->urlGenerator = $urlGenerator; } } // in src/App/Controller/DefaultController // … class DefaultController extend WebController { // ... } // in src/App/Controller/PageController // … class PageController extend WebController { // ... }
Есть много других полезных компонентов Symfony, таких как Form, Command и Assets. Они разрабатывались как независимые компоненты, поэтому их интеграция с использованием DI-контейнера не должна вызывать затруднений.
Теги
DI-контейнер также имеет систему тегов. Теги могут обрабатываться классами Compiler Pass. Компонент Event Dispatcher имеет собственный Compiler Pass для упрощения подписки на прослушиватель событий, но он использует класс ContainerAwareEventDispatcher вместо класса EventDispatcher. Поэтому мы не можем его использовать. Но мы можем реализовать собственные проходы компилятора для событий, маршрутов, безопасности и любых других целей.
Например, давайте реализуем теги для системы маршрутизации. Теперь, чтобы определить маршрут, мы должны определить службу маршрута в файле конфигурации маршрута в папке config/routes, а затем добавить ее в службу сбора маршрутов в файле config/routing.yml. Это выглядит несогласованным, потому что мы определяем параметры маршрутизатора в одном месте, а имя маршрутизатора — в другом.
С помощью системы тегов мы можем просто определить имя маршрута в теге и добавить эту службу маршрута в коллекцию маршрутов, используя имя тега.
Компонент DI-контейнера использует классы передачи компилятора для внесения любых изменений в конфигурацию контейнера перед фактической инициализацией. Итак, давайте реализуем наш класс передачи компилятора для системы тегов маршрутизатора.
// in src/CompilerPass/RouterTagCompilerPass.php namespace CompilerPass; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; class RouterTagCompilerPass implements CompilerPassInterface { /** * You can modify the container here before it is dumped to PHP code. * * @param ContainerBuilder $container */ public function process(ContainerBuilder $container) { $routeTags = $container->findTaggedServiceIds('route'); $collectionTags = $container->findTaggedServiceIds('route_collection'); /** @var Definition[] $routeCollections */ $routeCollections = array(); foreach ($collectionTags as $serviceName => $tagData) $routeCollections[] = $container->getDefinition($serviceName); foreach ($routeTags as $routeServiceName => $tagData) { $routeNames = array(); foreach ($tagData as $tag) if (isset($tag['route_name'])) $routeNames[] = $tag['route_name']; if (!$routeNames) continue; $routeReference = new Reference($routeServiceName); foreach ($routeCollections as $collection) foreach ($routeNames as $name) $collection->addMethodCall('add', array($name, $routeReference)); } } }
// in src/TrueContainer.php //... use CompilerPass\RouterTagCompilerPass; // ... $container = new self(); $container->addCompilerPass(new RouterTagCompilerPass()); // ...
Теперь мы можем изменить нашу конфигурацию:
# in config/routing.yml # … route.collection: class: Symfony\Component\Routing\RouteCollection tags: - { name: route_collection } # ...
# in config/routes/default.yml services: route.home: class: Symfony\Component\Routing\Route arguments: path: / defaults: _controller: ["@controller.default", 'defaultAction'] tags: - { name: route, route_name: 'route_home' } route.page: class: Symfony\Component\Routing\Route arguments: path: /page/{id} defaults: _controller: ["@controller.page", 'defaultAction'] tags: - { name: route, route_name: 'route_page' }
Как видите, мы получаем коллекции маршрутов по имени тега, а не по имени службы, поэтому наша система тегов маршрута не зависит от фактической конфигурации. Кроме того, маршруты можно добавлять в любую службу сбора с помощью метода «добавить». Пассеры компилятора могут значительно упростить настройку зависимостей. Но они могут добавить неожиданное поведение DI-контейнеру, поэтому лучше не изменять существующую логику, например, изменять аргументы, вызовы методов или имена классов. Просто добавьте новый поверх существующего, как мы это сделали, используя теги.
Заворачивать
Теперь у нас есть приложение, которое использует только шаблон DI-контейнера, и оно построено с использованием только файлов конфигурации DI-контейнера. Как видите, нет никаких серьезных проблем при создании приложения Symfony таким образом. И вы можете просто визуализировать все зависимости вашего приложения. Единственная причина, по которой люди используют DI-контейнер в качестве локатора сервисов, заключается в том, что концепцию локатора сервисов легче понять. И огромная кодовая база с DI-контейнером, используемым в качестве локатора сервисов, вероятно, является следствием этой причины.
Вы можете найти исходный код этого приложения на GitHub.