True Dependency Injection cu componente Symfony
Publicat: 2022-03-11Symfony2, un cadru PHP de înaltă performanță, folosește modelul Dependency Injection Container în care componentele oferă o interfață de injectare a dependenței pentru containerul DI. Acest lucru permite fiecărei componente să nu-i pese de alte dependențe. Clasa „Kernel” inițializează containerul DI și îl injectează în diferite componente. Dar acest lucru înseamnă că containerul DI poate fi folosit ca localizator de servicii.
Symfony2 are chiar și clasa „ContainerAware” pentru asta. Mulți sunt de părere că Service Locator este un anti-model în Symfony2. Personal, nu sunt de acord. Este un model mai simplu în comparație cu DI și este bun pentru proiecte simple. Dar modelul Service Locator și modelul DI-container combinate într-un singur proiect este cu siguranță un anti-model.
În acest articol vom încerca să construim o aplicație Symfony2 fără a implementa modelul Service Locator. Vom urma o regulă simplă: numai constructorul de containere DI poate ști despre containerul DI.
Container DI
În modelul Dependency Injection, DI-container definește dependențele de servicii și serviciile pot oferi doar o interfață pentru injectare. Există multe articole despre Dependency Injection și probabil le-ați citit pe toate. Deci, să nu ne concentrăm pe teorie și să aruncăm o privire asupra ideii de bază. DI poate fi de 3 tipuri:
În Symfony, structura de injecție poate fi definită folosind fișiere de configurare simple. Iată cum pot fi configurate aceste 3 tipuri de injecție:
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"
Proiect de bootstrapping
Să ne creăm structura de bază a aplicației. În timp ce suntem la asta, vom instala componenta 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
Pentru a face compozitorul autoloader să găsească propriile noastre clase în folderul src, putem adăuga proprietatea „autoloader” în fișierul composer.json:
{ // ... "autoload": { "psr-4": { "": "src/" } } }
Și să creăm constructorul nostru de containere și să interzicem injecțiile de containere.
// 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); } }
Aici folosim componentele Config și Yaml symfony. Puteți găsi detalii în documentația oficială aici. De asemenea, am definit parametrul de cale rădăcină „app_root” pentru orice eventualitate. Metoda get supraîncărcă comportamentul implicit get al clasei părinte și împiedică containerul să returneze „service_container”.
Apoi, avem nevoie de un punct de intrare pentru aplicație.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));
Acesta este menit să gestioneze solicitările http. Putem avea mai multe puncte de intrare pentru comenzile consolei, sarcini cron și multe altele. Fiecare punct de intrare ar trebui să primească anumite servicii și ar trebui să știe despre structura containerului DI. Acesta este singurul loc unde putem solicita servicii din container. Din acest moment vom încerca să construim această aplicație numai folosind fișierele de configurare ale containerului DI.
HttpKernel
HttpKernel (nu nucleul cadru cu problema de localizare a serviciului) va fi componenta noastră de bază pentru partea web a aplicației. Iată un flux de lucru tipic HttpKernel:
Pătratele verzi sunt evenimente.
HttpKernel folosește componenta HttpFoundation pentru obiectele Request and Response și componenta EventDispatcher pentru sistemul de evenimente. Nu există probleme în inițializarea lor cu fișierele de configurare a containerului DI. HttpKernel trebuie inițializat cu EventDispatcher, ControllerResolver și, opțional, cu serviciile RequestStack (pentru sub-cereri).
Iată configurația containerului pentru acesta:
# 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' }
După cum puteți vedea, folosim proprietatea „fabrică” pentru a crea serviciul de solicitare. Serviciul HttpKernel primește doar obiectul Request și returnează obiectul Response. Se poate face în controlerul 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();
Sau răspunsul poate fi definit ca un serviciu în configurație folosind proprietatea „factory”.
# in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]
Și apoi îl primim doar în controlerul frontal.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();
Serviciul de rezolvare a controlerului primește proprietatea „_controller” din atributele serviciului Request pentru a rezolva controlerul. Aceste atribute pot fi definite în configurația containerului, dar pare puțin mai complicat, deoarece trebuie să folosim un obiect ParameterBag în loc de un simplu tablou.
# 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" # ...
Și aici este clasa DefaultController cu metoda 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"); } }
Cu toate acestea la locul lor, ar trebui să avem o aplicație funcțională.
Acest controler este destul de inutil pentru că nu are acces la niciun serviciu. În cadrul Symfony, această problemă este rezolvată prin injectarea unui container DI într-un controler și folosindu-l ca locator de servicii. Nu vom face asta. Deci, să definim controlerul ca un serviciu și să injectăm serviciul de solicitare în el. Iată configurația:
# 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' }
Și codul controlerului:
// 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"); } }
Acum, controlorul are acces la serviciul de solicitare. După cum puteți vedea, această schemă are dependențe circulare. Funcționează deoarece containerul DI partajează serviciul după creare și înainte de injecțiile de metodă și de proprietate. Deci, atunci când serviciul de controler se creează, serviciul de solicitare există deja.
Iată cum funcționează:
Dar acest lucru funcționează doar pentru că serviciul de solicitare este creat mai întâi. Când primim serviciul de răspuns în controlerul frontal, serviciul de solicitare este prima dependență inițializată. Dacă încercăm să obținem mai întâi serviciul de controler, va provoca o eroare de dependență circulară. Poate fi remediat prin utilizarea metodei sau a injecțiilor de proprietate.
Dar mai este o problemă. DI-container va inițializa fiecare controler cu dependențe. Deci va inițializa toate serviciile existente chiar dacă nu sunt necesare. Din fericire, containerul are funcționalitate de încărcare leneșă. Componenta Symfony DI folosește „ocramius/proxy-manager” pentru clasele proxy. Trebuie să instalăm o punte între ele.
$ composer require symfony/proxy-manager-bridge
Și definiți-l în etapa de construire a containerului:
// in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
Acum putem defini serviciile leneșe.
# in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]
Deci, controlerele vor provoca inițializarea serviciilor dependente numai atunci când este apelată o metodă reală. De asemenea, evită eroarea de dependență circulară, deoarece un serviciu de controler va fi partajat înainte de inițializarea efectivă; deși trebuie totuși să evităm referințele circulare. În acest caz, nu ar trebui să injectăm serviciul de controlor în serviciul de solicitare sau serviciul de solicitare în serviciul de controlor. Evident că avem nevoie de un serviciu de solicitare în controlere, așa că să evităm o injecție în serviciul de solicitare în faza de inițiere a containerului. HttpKernel are un sistem de evenimente în acest scop.

Dirijare
Se pare că vrem să avem controlere diferite pentru cereri diferite. Deci avem nevoie de un sistem de rutare. Să instalăm componenta de rutare symfony.
$ composer require symfony/routing
Componenta de rutare are clasa Router care poate folosi fișiere de configurare a rutare. Dar aceste configurații sunt doar parametri cheie-valoare pentru clasa Route. Cadrul Symfony folosește propriul său solutor de control de la FrameworkBundle, care injectează container în controlere cu interfața „ContainerAware”. Este exact ceea ce încercăm să evităm. Soluția controlerului HttpKernel returnează obiectul de clasă așa cum este, dacă acesta există deja în atributul „_controller” ca matrice cu obiectul controlerului și șirul metodei de acțiune (de fapt, rezolutorul controlerului îl va returna așa cum este, dacă este doar o matrice). Deci trebuie să definim fiecare rută ca un serviciu și să injectăm un controler în ea. Să adăugăm un alt serviciu de controler pentru a vedea cum funcționează.
# 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"); } }
Componenta HttpKernel are clasa RouteListener care utilizează evenimentul „kernel.request”. Iată o configurație posibilă cu controlere leneșe:
# 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' }
De asemenea, avem nevoie de un generator de URL în aplicația noastră. Iată-l:
# in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"
Generatorul de URL poate fi injectat în controler și servicii de redare. Acum avem o aplicație de bază. Orice alt serviciu poate fi definit în același mod în care fișierul de configurare este injectat în anumite controlere sau dispecer de evenimente. De exemplu, iată câteva configurații pentru Twig și Doctrine.
Crenguţă
Twig este motorul de șablon implicit în cadrul Symfony2. Multe componente Symfony2 îl pot folosi fără adaptoare. Deci, este o alegere evidentă pentru aplicația noastră.
$ 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" ]
Doctrină
Doctrine este un ORM folosit în cadrul Symfony2. Putem folosi orice alt ORM, dar componentele Symfony2 pot folosi deja multe caracteristici 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' }
De asemenea, putem folosi fișiere de configurare de mapare YML și XML în loc de adnotări. Trebuie doar să folosim metodele „createYAMLMetadataConfiguration” și „createXMLMetadataConfiguration” și să setăm calea către un folder cu aceste fișiere de configurare.
Poate deveni rapid foarte enervant să injectezi fiecare serviciu necesar în fiecare controler individual. Pentru a face un pic mai bun, componenta DI-container are servicii abstracte și moștenire de servicii. Deci putem defini niște controlere abstracte:
# 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 { // ... }
Există multe alte componente Symfony utile, cum ar fi Form, Command și Assets. Au fost dezvoltate ca componente independente, astfel încât integrarea lor folosind containerul DI nu ar trebui să fie o problemă.
Etichete
Containerul DI are și un sistem de etichete. Etichetele pot fi procesate de clasele Compiler Pass. Componenta Event Dispatcher are propriul Compiler Pass pentru a simplifica abonamentul la ascultatorul de evenimente, dar folosește clasa ContainerAwareEventDispatcher în loc de clasa EventDispatcher. Deci nu-l putem folosi. Dar putem implementa propriile permise de compilare pentru evenimente, rute, securitate și orice alt scop.
De exemplu, să implementăm etichete pentru sistemul de rutare. Acum, pentru a defini o rută, trebuie să definim un serviciu de rută într-un fișier de configurare a rutei din folderul config/routes și apoi să-l adăugăm la serviciul de colectare a rutei în fișierul config/routing.yml. Pare inconsecvent deoarece definim parametrii routerului într-un loc și un nume de router în altul.
Cu sistemul de etichete, putem defini doar un nume de rută într-o etichetă și putem adăuga acest serviciu de rută la colecția de rute folosind un nume de etichetă.
Componenta DI-container folosește clase de trecere a compilatorului pentru a face orice modificare a configurației unui container înainte de inițializarea efectivă. Deci, să implementăm clasa noastră de trecere a compilatorului pentru sistemul de etichete de 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()); // ...
Acum ne putem modifica configurația:
# 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' }
După cum puteți vedea, primim colecții de rute după numele etichetei în loc de numele serviciului, astfel încât sistemul nostru de etichete de rută nu depinde de configurația reală. De asemenea, rutele pot fi adăugate la orice serviciu de colectare cu metoda „adăugați”. Trecătorii compilatorului pot simplifica semnificativ configurațiile dependențelor. Dar pot adăuga un comportament neașteptat containerului DI, așa că este mai bine să nu modificați logica existentă, cum ar fi schimbarea argumentelor, apelurilor de metode sau numelor de clasă. Doar adăugați unul nou care a existat, așa cum am făcut noi, folosind etichete.
Învelire
Acum avem o aplicație care folosește numai modelul de container DI și este construită folosind numai fișierele de configurare ale containerului DI. După cum puteți vedea, nu există provocări serioase în construirea unei aplicații Symfony în acest fel. Și puteți vizualiza pur și simplu toate dependențele aplicației dvs. Singurul motiv pentru care oamenii folosesc DI-container ca locator de servicii este că un concept de localizare de servicii este mai ușor de înțeles. Și o bază de cod uriașă cu container DI folosit ca localizator de servicii este probabil o consecință a acestui motiv.
Puteți găsi codul sursă al acestei aplicații pe GitHub.