Injeção de dependência verdadeira com componentes do Symfony

Publicados: 2022-03-11

Symfony2, um framework PHP de alto desempenho, usa o padrão Dependency Injection Container onde os componentes fornecem uma interface de injeção de dependência para o DI-container. Isso permite que cada componente não se importe com outras dependências. A classe 'Kernel' inicializa o DI-container e o injeta em diferentes componentes. Mas isso significa que o contêiner DI pode ser usado como um localizador de serviços.

O Symfony2 ainda tem a classe 'ContainerAware' para isso. Muitos acreditam que o Service Locator é um anti-padrão no Symfony2. Pessoalmente, não concordo. É um padrão mais simples comparado ao DI e é bom para projetos simples. Mas o padrão Service Locator e o padrão DI-container combinados em um único projeto é definitivamente um antipadrão.

Injeção de dependência verdadeira com componentes do Symfony

Neste artigo tentaremos construir uma aplicação Symfony2 sem implementar o padrão Service Locator. Seguiremos uma regra simples: apenas o construtor de contêiner DI pode saber sobre o contêiner DI.

recipiente DI

No padrão de injeção de dependência, o contêiner DI define as dependências do serviço e os serviços podem fornecer apenas uma interface para injeção. Existem muitos artigos sobre injeção de dependência, e você provavelmente já leu todos eles. Portanto, não vamos nos concentrar na teoria e apenas dar uma olhada na ideia básica. DI pode ser de 3 tipos:

No Symfony, a estrutura de injeção pode ser definida usando arquivos de configuração simples. Veja como esses 3 tipos de injeção podem ser configurados:

 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"

Projeto de inicialização

Vamos criar nossa estrutura de aplicação base. Enquanto estamos nisso, vamos instalar o componente Symfony DI-container.

 $ 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 fazer o composer autoloader encontrar nossas próprias classes na pasta src, podemos adicionar a propriedade 'autoloader' no arquivo composer.json:

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

E vamos criar nosso construtor de contêineres e proibir injeções de contêineres.

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

Aqui usamos os componentes Config e Yaml do symfony. Você pode encontrar detalhes na documentação oficial aqui. Também definimos o parâmetro de caminho raiz 'app_root' apenas por precaução. O método get sobrecarrega o comportamento get padrão da classe pai e impede que o contêiner retorne o “service_container”.

Em seguida, precisamos de um ponto de entrada para o aplicativo.

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

Este destina-se a lidar com solicitações http. Podemos ter mais pontos de entrada para comandos de console, tarefas cron e muito mais. Cada ponto de entrada deve obter determinados serviços e deve conhecer a estrutura do contêiner DI. Este é o único local onde podemos solicitar serviços do container. A partir deste momento, tentaremos construir esta aplicação apenas usando arquivos de configuração de contêiner DI.

HttpKernelGenericName

HttpKernel (não o kernel do framework com o problema do localizador de serviço) será nosso componente base para a parte web do aplicativo. Aqui está um fluxo de trabalho HttpKernel típico:

Quadrados verdes são eventos.

HttpKernel usa o componente HttpFoundation para objetos Request e Response e o componente EventDispatcher para o sistema de eventos. Não há problemas em inicializá-los com arquivos de configuração de contêiner DI. HttpKernel deve ser inicializado com EventDispatcher, ControllerResolver e, opcionalmente, com serviços RequestStack (para sub-solicitações).

Aqui está a configuração do contêiner para ele:

 # 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 você pode ver, usamos a propriedade 'factory' para criar o serviço de solicitação. O serviço HttpKernel obtém apenas o objeto Request e retorna o objeto Response. Isso pode ser feito no 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();

Ou a resposta pode ser definida como um serviço na configuração usando a propriedade 'factory'.

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

E então nós apenas o colocamos no controlador frontal.

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

O serviço de resolução do controlador obtém a propriedade '_controller' dos atributos do serviço de solicitação para resolver o controlador. Esses atributos podem ser definidos na configuração do contêiner, mas parece um pouco mais complicado porque temos que usar um objeto ParameterBag em vez de um array simples.

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

E aqui está a classe DefaultController com o 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"); } }

Com tudo isso no lugar, devemos ter um aplicativo de trabalho.

Este controlador é bastante inútil porque não tem acesso a nenhum serviço. No framework Symfony, esse problema é resolvido injetando um DI-container em um controlador e usando-o como um localizador de serviço. Não faremos isso. Então, vamos definir o controlador como um serviço e injetar o serviço de solicitação nele. Aqui está a configuração:

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

E o código do 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"); } }

Agora o controlador tem acesso ao serviço de solicitação. Como você pode ver, esse esquema tem dependências circulares. Funciona porque o contêiner DI compartilha o serviço após a criação e antes das injeções de método e propriedade. Portanto, quando o serviço do controlador está sendo criado, o serviço de solicitação já existe.

Veja como funciona:

Mas isso funciona apenas porque o serviço de solicitação é criado primeiro. Quando obtemos o serviço de resposta no front controller, o serviço de solicitação é a primeira dependência inicializada. Se tentarmos obter o serviço do controlador primeiro, isso causará um erro de dependência circular. Ele pode ser corrigido usando injeções de método ou propriedade.

Mas há outro problema. O contêiner DI inicializará cada controlador com dependências. Portanto, ele inicializará todos os serviços existentes, mesmo que não sejam necessários. Felizmente, o contêiner tem a funcionalidade de carregamento lento. O componente DI do Symfony usa 'ocramius/proxy-manager' para classes de proxy. Temos que instalar uma ponte entre eles.

 $ composer require symfony/proxy-manager-bridge

E defina-o no estágio de construção do contêiner:

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

Agora podemos definir serviços preguiçosos.

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

Portanto, os controladores causarão a inicialização de serviços dependentes somente quando um método real for chamado. Além disso, evita o erro de dependência circular porque um serviço do controlador será compartilhado antes da inicialização real; embora ainda tenhamos que evitar referências circulares. Nesse caso, não devemos injetar o serviço do controlador no serviço de solicitação ou o serviço de solicitação no serviço do controlador. Obviamente precisamos de um serviço de requisição nos controladores, então vamos evitar uma injeção no serviço de requisição na fase de iniciação do container. HttpKernel possui sistema de eventos para esta finalidade.

Roteamento

Aparentemente, queremos ter controladores diferentes para solicitações diferentes. Então, precisamos de um sistema de roteamento. Vamos instalar o componente de roteamento do symfony.

 $ composer require symfony/routing

O componente de roteamento tem a classe Router que pode usar arquivos de configuração de roteamento. Mas essas configurações são apenas parâmetros de valor-chave para a classe Route. O framework Symfony usa seu próprio resolvedor de controlador do FrameworkBundle que injeta container em controladores com a interface 'ContainerAware'. É exatamente isso que estamos tentando evitar. O resolvedor do controlador HttpKernel retorna o objeto de classe como está se ele já existir no atributo '_controller' como array com o objeto do controlador e a string do método de ação (na verdade, o resolvedor do controlador o retornará como está se for apenas um array). Então temos que definir cada rota como um serviço e injetar um controlador nela. Vamos adicionar algum outro serviço de controlador para ver como 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"); } }

O componente HttpKernel tem a classe RouteListener que usa o evento 'kernel.request'. Aqui está uma configuração possível com controladores preguiçosos:

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

Também precisamos de um gerador de URL em nosso aplicativo. Aqui está:

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

O gerador de URL pode ser injetado no controlador e nos serviços de renderização. Agora temos uma aplicação base. Qualquer outro serviço pode ser definido da mesma forma que o arquivo de configuração é injetado em determinados controllers ou event dispatcher. Por exemplo, aqui estão algumas configurações para Twig e Doctrine.

Galho

Twig é o mecanismo de template padrão no framework Symfony2. Muitos componentes do Symfony2 podem usá-lo sem nenhum adaptador. Portanto, é uma escolha óbvia para nossa aplicação.

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

Doutrina

Doctrine é um ORM usado no framework Symfony2. Podemos usar qualquer outro ORM, mas os componentes do Symfony2 já podem usar muitos recursos do 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' }

Também podemos usar arquivos de configuração de mapeamento YML e XML em vez de anotações. Só precisamos usar os métodos 'createYAMLMetadataConfiguration' e 'createXMLMetadataConfiguration' e definir o caminho para uma pasta com esses arquivos de configuração.

Pode tornar-se rapidamente muito irritante injetar todos os serviços necessários em cada controlador individualmente. Para torná-lo um pouco melhor, o componente DI-container tem serviços abstratos e herança de serviço. Assim, podemos definir alguns controladores abstratos:

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

Existem muitos outros componentes úteis do Symfony como Form, Command e Assets. Eles foram desenvolvidos como componentes independentes, portanto, sua integração usando contêiner DI não deve ser um problema.

Tag

O DI-container também possui um sistema de tags. As tags podem ser processadas por classes do Compiler Pass. O componente Event Dispatcher tem seu próprio Compiler Pass para simplificar a assinatura do ouvinte de eventos, mas usa a classe ContainerAwareEventDispatcher em vez da classe EventDispatcher. Então não podemos usar. Mas podemos implementar nossos próprios passos de compilador para eventos, rotas, segurança e qualquer outra finalidade.

Por exemplo, vamos implementar tags para o sistema de roteamento. Agora, para definir uma rota, temos que definir um serviço de rota em um arquivo de configuração de rota na pasta config/routes e adicioná-lo ao serviço de coleta de rota no arquivo config/routing.yml. Parece inconsistente porque definimos os parâmetros do roteador em um lugar e o nome do roteador em outro.

Com o sistema de tags, podemos apenas definir um nome de rota em uma tag e adicionar esse serviço de rota à coleção de rotas usando um nome de tag.

O componente DI-container usa classes de passagem do compilador para fazer qualquer modificação em uma configuração de contêiner antes da inicialização real. Então, vamos implementar nossa classe de passagem do compilador para o sistema de tags do roteador.

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

Agora podemos modificar nossa configuração:

 # 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 você pode ver, obtemos coleções de rotas pelo nome da tag em vez do nome do serviço, portanto, nosso sistema de tags de rota não depende da configuração real. Além disso, as rotas podem ser adicionadas a qualquer serviço de coleta com um método 'add'. Passadores de compilador podem simplificar significativamente as configurações das dependências. Mas eles podem adicionar um comportamento inesperado ao contêiner DI, portanto, é melhor não modificar a lógica existente, como alterar argumentos, chamadas de métodos ou nomes de classes. Basta adicionar um novo que já existia, como fizemos usando tags.

Embrulhar

Agora temos um aplicativo que usa apenas o padrão de contêiner DI e é construído usando apenas arquivos de configuração de contêiner DI. Como você pode ver, não há desafios sérios em construir uma aplicação Symfony desta forma. E você pode simplesmente visualizar todas as dependências do seu aplicativo. A única razão pela qual as pessoas usam o DI-container como um localizador de serviço é que o conceito de localizador de serviço é mais fácil de entender. E uma enorme base de código com contêiner DI usado como localizador de serviço é provavelmente uma consequência desse motivo.

Você pode encontrar o código-fonte deste aplicativo no GitHub.