Inyección de dependencia real con componentes de Symfony

Publicado: 2022-03-11

Symfony2, un marco PHP de alto rendimiento, utiliza un patrón de contenedor de inyección de dependencia donde los componentes proporcionan una interfaz de inyección de dependencia para el contenedor DI. Esto permite que cada componente no se preocupe por otras dependencias. La clase 'Kernel' inicializa el contenedor DI y lo inyecta en diferentes componentes. Pero esto significa que el contenedor DI se puede usar como un localizador de servicios.

Symfony2 incluso tiene la clase 'ContainerAware' para eso. Muchos opinan que Service Locator es un antipatrón en Symfony2. Personalmente, no estoy de acuerdo. Es un patrón más simple en comparación con DI y es bueno para proyectos simples. Pero el patrón Service Locator y el patrón DI-container combinados en un solo proyecto es definitivamente un antipatrón.

Inyección de dependencia real con componentes de Symfony

En este artículo intentaremos construir una aplicación Symfony2 sin implementar el patrón Service Locator. Seguiremos una regla simple: solo el constructor de contenedores DI puede saber sobre los contenedores DI.

contenedor DI

En el patrón de inyección de dependencia, el contenedor DI define las dependencias del servicio y los servicios solo pueden proporcionar una interfaz para la inyección. Hay muchos artículos sobre Inyección de Dependencia, y probablemente los hayas leído todos. Así que no nos centremos en la teoría y solo echemos un vistazo a la idea básica. DI puede ser de 3 tipos:

En Symfony, la estructura de inyección se puede definir usando archivos de configuración simples. Así es como se pueden configurar estos 3 tipos de inyección:

 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"

Proyecto de arranque

Vamos a crear nuestra estructura de aplicación base. Mientras estamos en eso, instalaremos el componente de contenedor DI de Symfony.

 $ 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

Para hacer que el autocargador del compositor encuentre nuestras propias clases en la carpeta src, podemos agregar la propiedad 'autocargador' en el archivo composer.json:

 { // ... "autoload": { "psr-4": { "": "src/" } } }

Y creemos nuestro generador de contenedores y prohibamos las inyecciones de contenedores.

 // 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); } }

Aquí usamos los componentes Config y Yaml de Symfony. Puede encontrar detalles en la documentación oficial aquí. También definimos el parámetro de ruta raíz 'app_root' por si acaso. El método get sobrecarga el comportamiento get predeterminado de la clase principal y evita que el contenedor devuelva el "service_container".

A continuación, necesitamos un punto de entrada para la aplicación.

 // in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));

Este está destinado a manejar solicitudes http. Podemos tener más puntos de entrada para comandos de consola, tareas cron y más. Se supone que cada punto de entrada obtiene ciertos servicios y debe conocer la estructura del contenedor DI. Este es el único lugar donde podemos solicitar servicios del contenedor. A partir de este momento, intentaremos compilar esta aplicación solo con los archivos de configuración del contenedor DI.

HttpKernel

HttpKernel (no el núcleo del marco con el problema del localizador de servicios) será nuestro componente base para la parte web de la aplicación. Aquí hay un flujo de trabajo típico de HttpKernel:

Los cuadrados verdes son eventos.

HttpKernel utiliza el componente HttpFoundation para los objetos de solicitud y respuesta y el componente EventDispatcher para el sistema de eventos. No hay problemas para inicializarlos con los archivos de configuración del contenedor DI. HttpKernel debe inicializarse con EventDispatcher, ControllerResolver y, opcionalmente, con los servicios RequestStack (para subsolicitudes).

Aquí está la configuración del contenedor para ello:

 # 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' }

Como puede ver, usamos la propiedad 'factory' para crear el servicio de solicitud. El servicio HttpKernel solo obtiene el objeto Solicitud y devuelve el objeto Respuesta. Se puede hacer en el controlador frontal.

 // 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();

O la respuesta se puede definir como un servicio en la configuración usando la propiedad 'fábrica'.

 # in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]

Y luego lo obtenemos en el controlador frontal.

 // in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();

El servicio de resolución del controlador obtiene la propiedad '_controller' de los atributos del servicio de solicitud para resolver el controlador. Estos atributos se pueden definir en la configuración del contenedor, pero parece un poco más complicado porque tenemos que usar un objeto ParameterBag en lugar de una matriz simple.

 # 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" # ...

Y aquí está la clase DefaultController con el método 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"); } }

Con todo esto en su lugar, deberíamos tener una aplicación que funcione.

Este controlador es bastante inútil porque no tiene acceso a ningún servicio. En el marco de Symfony, este problema se resuelve inyectando un contenedor DI en un controlador y usándolo como un localizador de servicios. No haremos eso. Entonces, definamos el controlador como un servicio e inyectemos el servicio de solicitud en él. Aquí está la configuración:

 # 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' }

Y el código del controlador:

 // 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"); } }

Ahora el controlador tiene acceso al servicio de solicitud. Como puede ver, este esquema tiene dependencias circulares. Funciona porque el contenedor DI comparte el servicio después de la creación y antes de las inyecciones de métodos y propiedades. Entonces, cuando el servicio del controlador se está creando, el servicio de solicitud ya existe.

Así es como funciona:

Pero esto funciona solo porque el servicio de solicitud se crea primero. Cuando obtenemos el servicio de respuesta en el controlador frontal, el servicio de solicitud es la primera dependencia inicializada. Si tratamos de obtener primero el servicio del controlador, se producirá un error de dependencia circular. Se puede arreglar usando métodos o inyecciones de propiedades.

Pero hay otro problema. DI-container inicializará cada controlador con dependencias. Por lo tanto, inicializará todos los servicios existentes, incluso si no son necesarios. Afortunadamente, el contenedor tiene una función de carga diferida. El componente Symfony DI usa 'ocramius/proxy-manager' para las clases de proxy. Tenemos que instalar un puente entre ellos.

 $ composer require symfony/proxy-manager-bridge

Y defínalo en la etapa de construcción del contenedor:

 // in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...

Ahora podemos definir servicios perezosos.

 # in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]

Por lo tanto, los controladores provocarán la inicialización de los servicios dependientes solo cuando se llame a un método real. Además, evita el error de dependencia circular porque se compartirá un servicio de controlador antes de la inicialización real; aunque todavía tenemos que evitar las referencias circulares. En este caso, no debemos inyectar el servicio de controlador en el servicio de solicitud o el servicio de solicitud en el servicio de controlador. Obviamente, necesitamos un servicio de solicitud en los controladores, así que evitemos una inyección en el servicio de solicitud en la etapa de inicio del contenedor. HttpKernel tiene un sistema de eventos para este propósito.

Enrutamiento

Aparentemente queremos tener diferentes controladores para diferentes solicitudes. Así que necesitamos un sistema de enrutamiento. Instalemos el componente de enrutamiento de Symfony.

 $ composer require symfony/routing

El componente de enrutamiento tiene la clase Enrutador que puede usar archivos de configuración de enrutamiento. Pero estas configuraciones son solo parámetros clave-valor para la clase Ruta. El framework Symfony usa su propio sistema de resolución de controladores de FrameworkBundle que inyecta contenedores en los controladores con la interfaz 'ContainerAware'. Esto es exactamente lo que estamos tratando de evitar. El sistema de resolución del controlador HttpKernel devuelve el objeto de clase tal como está si ya existe en el atributo '_controller' como una matriz con el objeto del controlador y la cadena del método de acción (en realidad, el sistema de resolución del controlador lo devolverá tal como está si es solo una matriz). Así que tenemos que definir cada ruta como un servicio e inyectarle un controlador. Agreguemos algún otro servicio de controlador para ver cómo funciona.

 # 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"); } }

El componente HttpKernel tiene la clase RouteListener que usa el evento 'kernel.request'. Aquí hay una posible configuración con controladores perezosos:

 # 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' }

También necesitamos un generador de URL en nuestra aplicación. Aquí lo tienes:

 # in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"

El generador de URL se puede inyectar en el controlador y los servicios de representación. Ahora tenemos una aplicación base. Cualquier otro servicio se puede definir de la misma manera que el archivo de configuración se inyecta en ciertos controladores o despachadores de eventos. Por ejemplo, aquí hay algunas configuraciones para Twig y Doctrine.

Ramita

Twig es el motor de plantillas predeterminado en el marco Symfony2. Muchos componentes de Symfony2 pueden usarlo sin adaptadores. Así que es una opción obvia para nuestra aplicación.

 $ 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" ]

Doctrina

Doctrine es un ORM utilizado en el marco Symfony2. Podemos usar cualquier otro ORM, pero los componentes de Symfony2 ya pueden usar muchas características de 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' }

También podemos usar archivos de configuración de mapeo YML y XML en lugar de anotaciones. Solo necesitamos usar los métodos 'createYAMLMetadataConfiguration' y 'createXMLMetadataConfiguration' y establecer la ruta a una carpeta con estos archivos de configuración.

Rápidamente puede volverse muy molesto inyectar cada servicio necesario en cada controlador individualmente. Para hacerlo un poco mejor, el componente DI-container tiene servicios abstractos y herencia de servicios. Entonces podemos definir algunos controladores abstractos:

 # 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 { // ... }

Hay muchos otros componentes útiles de Symfony como Form, Command y Assets. Se desarrollaron como componentes independientes, por lo que su integración mediante el contenedor DI no debería ser un problema.

Etiquetas

El contenedor DI también tiene un sistema de etiquetas. Las etiquetas pueden ser procesadas por clases de Compiler Pass. El componente Event Dispatcher tiene su propio Compiler Pass para simplificar la suscripción del detector de eventos, pero usa la clase ContainerAwareEventDispatcher en lugar de la clase EventDispatcher. Así que no podemos usarlo. Pero podemos implementar nuestros propios pases de compilador para eventos, rutas, seguridad y cualquier otro propósito.

Por ejemplo, implementemos etiquetas para el sistema de enrutamiento. Ahora, para definir una ruta, debemos definir un servicio de ruta en un archivo de configuración de ruta en la carpeta config/routes y luego agregarlo al servicio de recopilación de rutas en el archivo config/routing.yml. Parece inconsistente porque definimos los parámetros del enrutador en un lugar y el nombre del enrutador en otro.

Con el sistema de etiquetas, podemos simplemente definir un nombre de ruta en una etiqueta y agregar este servicio de ruta a la colección de rutas usando un nombre de etiqueta.

El componente DI-container utiliza clases de paso del compilador para realizar cualquier modificación en la configuración de un contenedor antes de la inicialización real. Así que implementemos nuestra clase de paso del compilador para el sistema de etiquetas del enrutador.

 // 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()); // ...

Ahora podemos modificar nuestra configuración:

 # 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' }

Como puede ver, obtenemos colecciones de rutas por el nombre de la etiqueta en lugar del nombre del servicio, por lo que nuestro sistema de etiquetas de ruta no depende de la configuración real. Además, las rutas se pueden agregar a cualquier servicio de recolección con un método 'agregar'. Los transeúntes del compilador pueden simplificar significativamente las configuraciones de las dependencias. Pero pueden agregar un comportamiento inesperado al contenedor DI, por lo que es mejor no modificar la lógica existente, como cambiar argumentos, llamadas a métodos o nombres de clases. Simplemente agregue uno nuevo sobre el existente como lo hicimos usando etiquetas.

Envolver

Ahora tenemos una aplicación que usa solo un patrón de contenedor DI y está construida usando solo archivos de configuración de contenedor DI. Como puedes ver, no hay desafíos serios en construir una aplicación Symfony de esta manera. Y simplemente puede visualizar todas las dependencias de su aplicación. La única razón por la que las personas usan el contenedor DI como un localizador de servicios es que el concepto de localizador de servicios es más fácil de entender. Y una gran base de código con contenedor DI utilizado como localizador de servicios es probablemente una consecuencia de esa razón.

Puede encontrar el código fuente de esta aplicación en GitHub.