Iniezione di vera dipendenza con i componenti di Symfony

Pubblicato: 2022-03-11

Symfony2, un framework PHP ad alte prestazioni, utilizza il modello Dependency Injection Container in cui i componenti forniscono un'interfaccia di iniezione delle dipendenze per il DI-container. Ciò consente a ciascun componente di non preoccuparsi di altre dipendenze. La classe 'Kernel' inizializza il contenitore DI e lo inserisce in diversi componenti. Ma questo significa che DI-container può essere utilizzato come localizzatore di servizi.

Symfony2 ha anche la classe 'ContainerAware' per questo. Molti ritengono che Service Locator sia un anti-pattern in Symfony2. Personalmente non sono d'accordo. È uno schema più semplice rispetto a DI ed è buono per progetti semplici. Ma il pattern Service Locator e il pattern DI-container combinati in un unico progetto è sicuramente un anti-pattern.

Iniezione di vera dipendenza con i componenti di Symfony

In questo articolo proveremo a costruire un'applicazione Symfony2 senza implementare il pattern Service Locator. Seguiremo una semplice regola: solo il costruttore di contenitori DI può conoscere il contenitore DI.

Contenitore DI

Nel modello di iniezione delle dipendenze, il contenitore DI definisce le dipendenze del servizio e i servizi possono solo fornire un'interfaccia per l'iniezione. Ci sono molti articoli su Dependency Injection e probabilmente li hai letti tutti. Quindi non concentriamoci sulla teoria e diamo solo un'occhiata all'idea di base. DI può essere di 3 tipi:

In Symfony, la struttura dell'iniezione può essere definita usando semplici file di configurazione. Ecco come possono essere configurati questi 3 tipi di iniezione:

 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"

Progetto di bootstrap

Creiamo la nostra struttura dell'applicazione di base. Già che ci siamo, installeremo il 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

Per fare in modo che il caricatore automatico del compositore trovi le nostre classi nella cartella src, possiamo aggiungere la proprietà 'caricatore automatico' nel file composer.json:

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

E creiamo il nostro generatore di contenitori e vietiamo le iniezioni di contenitori.

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

Qui usiamo i componenti Config e Yaml di symfony. Puoi trovare i dettagli nella documentazione ufficiale qui. Abbiamo anche definito il parametro del percorso di root 'app_root' per ogni evenienza. Il metodo get sovraccarica il comportamento get predefinito della classe padre e impedisce al contenitore di restituire "service_container".

Successivamente, abbiamo bisogno di un punto di ingresso per l'applicazione.

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

Questo ha lo scopo di gestire le richieste http. Possiamo avere più punti di ingresso per i comandi della console, le attività cron e altro. Ogni punto di ingresso dovrebbe ottenere determinati servizi e dovrebbe conoscere la struttura del contenitore DI. Questo è l'unico posto dove possiamo richiedere servizi dal container. Da questo momento cercheremo di creare questa applicazione utilizzando solo i file di configurazione del contenitore DI.

HttpKernel

HttpKernel (non il kernel del framework con il problema del localizzatore di servizi) sarà il nostro componente di base per la web part dell'applicazione. Ecco un tipico flusso di lavoro HttpKernel:

I quadrati verdi sono eventi.

HttpKernel utilizza il componente HttpFoundation per gli oggetti di richiesta e risposta e il componente EventDispatcher per il sistema di eventi. Non ci sono problemi nell'inizializzazione con i file di configurazione del contenitore DI. HttpKernel deve essere inizializzato con EventDispatcher, ControllerResolver e, facoltativamente, con i servizi RequestStack (per richieste secondarie).

Ecco la configurazione del contenitore:

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

Come puoi vedere, utilizziamo la proprietà 'factory' per creare il servizio di richiesta. Il servizio HttpKernel ottiene solo l'oggetto Request e restituisce l'oggetto Response. Può essere fatto nel controller frontale.

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

Oppure la risposta può essere definita come un servizio nella configurazione utilizzando la proprietà 'factory'.

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

E poi lo inseriamo nel controller anteriore.

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

Il servizio di risoluzione del controller ottiene la proprietà '_controller' dagli attributi del servizio di richiesta per risolvere il controller. Questi attributi possono essere definiti nella configurazione del contenitore, ma sembra un po' più complicato perché dobbiamo usare un oggetto ParameterBag invece di un semplice array.

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

Ed ecco la classe DefaultController con il metodo 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 tutti questi in atto, dovremmo avere un'applicazione funzionante.

Questo controller è piuttosto inutile perché non ha accesso a nessun servizio. Nel framework Symfony, questo problema viene risolto iniettando un DI-container in un controller e usandolo come localizzatore di servizi. Non lo faremo. Quindi definiamo il controller come un servizio e inseriamo in esso il servizio di richiesta. Ecco la configurazione:

 # 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 il codice del controller:

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

Ora il controller ha accesso al servizio di richiesta. Come puoi vedere, questo schema ha dipendenze circolari. Funziona perché il contenitore DI condivide il servizio dopo la creazione e prima delle iniezioni di metodi e proprietà. Pertanto, durante la creazione del servizio controller, il servizio di richiesta esiste già.

Ecco come funziona:

Ma questo funziona solo perché il servizio di richiesta viene creato prima. Quando otteniamo il servizio di risposta nel front controller, il servizio di richiesta è la prima dipendenza inizializzata. Se proviamo a ottenere prima il servizio del controller, verrà generato un errore di dipendenza circolare. Può essere risolto utilizzando metodi o iniezioni di proprietà.

Ma c'è un altro problema. DI-container inizializzerà ogni controller con le dipendenze. Quindi inizializzerà tutti i servizi esistenti anche se non sono necessari. Fortunatamente, il contenitore ha la funzionalità di caricamento lento. Il componente Symfony DI usa 'ocramius/proxy-manager' per le classi proxy. Dobbiamo installare un ponte tra di loro.

 $ composer require symfony/proxy-manager-bridge

E definiscilo in fase di costruzione del container:

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

Ora possiamo definire i servizi pigri.

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

Quindi i controller causeranno l'inizializzazione dei servizi dipendenti solo quando viene chiamato un metodo effettivo. Inoltre, evita l'errore di dipendenza circolare perché un servizio controller verrà condiviso prima dell'inizializzazione effettiva; anche se dobbiamo ancora evitare i riferimenti circolari. In questo caso non dovremmo inserire il servizio del controller nel servizio di richiesta o il servizio di richiesta nel servizio del controller. Ovviamente abbiamo bisogno di un servizio di richiesta nei controller, quindi evitiamo un'iniezione nel servizio di richiesta nella fase di avvio del contenitore. HttpKernel ha un sistema di eventi per questo scopo.

Instradamento

Apparentemente vogliamo avere controller diversi per richieste diverse. Quindi abbiamo bisogno di un sistema di routing. Installiamo il componente di routing di symfony.

 $ composer require symfony/routing

Il componente di routing ha la classe Router che può utilizzare i file di configurazione di routing. Ma queste configurazioni sono solo parametri chiave-valore per la classe Route. Il framework Symfony usa il proprio controller resolver dal FrameworkBundle che inietta il container nei controller con l'interfaccia 'ContainerAware'. Questo è esattamente ciò che stiamo cercando di evitare. Il risolutore del controller HttpKernel restituisce l'oggetto classe così com'è se esiste già nell'attributo '_controller' come array con oggetto controller e stringa del metodo di azione (in realtà, il risolutore del controller lo restituirà come se fosse solo un array). Quindi dobbiamo definire ogni percorso come un servizio e iniettarvi un controller. Aggiungiamo qualche altro servizio di controller per vedere come funziona.

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

Il componente HttpKernel ha la classe RouteListener che utilizza l'evento 'kernel.request'. Ecco una possibile configurazione con controller pigri:

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

Inoltre abbiamo bisogno di un generatore di URL nella nostra applicazione. Ecco qui:

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

Il generatore di URL può essere inserito nel controller e nei servizi di rendering. Ora abbiamo un'applicazione di base. Qualsiasi altro servizio può essere definito nello stesso modo in cui il file di configurazione viene iniettato in determinati controller o dispatcher di eventi. Ad esempio, ecco alcune configurazioni per Twig e Doctrine.

Ramoscello

Twig è il motore di template predefinito nel framework Symfony2. Molti componenti di Symfony2 possono usarlo senza adattatori. Quindi è una scelta ovvia per la nostra applicazione.

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

Dottrina

Doctrine è un ORM utilizzato nel framework Symfony2. Possiamo usare qualsiasi altro ORM, ma i componenti di Symfony2 possono già usare molte funzionalità di 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' }

Possiamo anche usare i file di configurazione della mappatura YML e XML invece delle annotazioni. Dobbiamo solo usare i metodi "createYAMLMetadataConfiguration" e "createXMLMetadataConfiguration" e impostare il percorso di una cartella con questi file di configurazione.

Può diventare rapidamente molto fastidioso iniettare ogni servizio necessario in ogni controller individualmente. Per renderlo un po' migliore, il componente DI-container ha servizi astratti ed ereditarietà del servizio. Quindi possiamo definire alcuni controller astratti:

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

Ci sono molti altri utili componenti di Symfony come Form, Command e Assets. Sono stati sviluppati come componenti indipendenti, quindi la loro integrazione tramite DI-container non dovrebbe essere un problema.

Tag

Il contenitore DI ha anche un sistema di tag. I tag possono essere elaborati dalle classi Compiler Pass. Il componente Event Dispatcher ha il proprio Compiler Pass per semplificare la sottoscrizione del listener di eventi, ma usa la classe ContainerAwareEventDispatcher invece della classe EventDispatcher. Quindi non possiamo usarlo. Ma possiamo implementare i nostri pass del compilatore per eventi, percorsi, sicurezza e qualsiasi altro scopo.

Ad esempio, implementiamo i tag per il sistema di routing. Ora per definire un percorso dobbiamo definire un servizio di percorso in un file di configurazione di percorso nella cartella config/routes e quindi aggiungerlo al servizio di raccolta di percorsi nel file config/routing.yml. Sembra incoerente perché definiamo i parametri del router in un posto e il nome del router in un altro.

Con il sistema di tag, possiamo semplicemente definire un nome di percorso in un tag e aggiungere questo servizio di percorso alla raccolta di percorsi utilizzando un nome di tag.

Il componente DI-container utilizza le classi pass del compilatore per apportare modifiche alla configurazione di un container prima dell'effettiva inizializzazione. Quindi implementiamo la nostra classe pass per il compilatore per il sistema di tag del router.

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

Ora possiamo modificare la nostra configurazione:

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

Come puoi vedere, otteniamo raccolte di rotte in base al nome del tag anziché al nome del servizio, quindi il nostro sistema di tag di percorso non dipende dalla configurazione effettiva. Inoltre, i percorsi possono essere aggiunti a qualsiasi servizio di raccolta con un metodo "aggiungi". I passanti del compilatore possono semplificare notevolmente le configurazioni delle dipendenze. Ma possono aggiungere un comportamento imprevisto al contenitore DI, quindi è meglio non modificare la logica esistente come cambiare argomenti, chiamate di metodi o nomi di classi. Basta aggiungerne uno nuovo come abbiamo fatto noi usando i tag.

Incartare

Ora abbiamo un'applicazione che utilizza solo il modello di contenitore DI ed è creata utilizzando solo i file di configurazione del contenitore DI. Come puoi vedere, non ci sono sfide serie nella creazione di un'applicazione Symfony in questo modo. E puoi semplicemente visualizzare tutte le dipendenze delle tue applicazioni. L'unico motivo per cui le persone usano DI-container come localizzatore di servizi è che il concetto di localizzatore di servizi è più facile da capire. E un'enorme base di codice con DI-container utilizzato come localizzatore di servizi è probabilmente una conseguenza di questo motivo.

Puoi trovare il codice sorgente di questa applicazione su GitHub.