True Dependency Injection z komponentami Symfony
Opublikowany: 2022-03-11Symfony2, wysokowydajny framework PHP, wykorzystuje wzorzec Dependency Injection Container, w którym komponenty zapewniają interfejs wstrzykiwania zależności dla kontenera DI. Dzięki temu każdy komponent nie przejmuje się innymi zależnościami. Klasa „Kernel” inicjuje kontener DI i wstrzykuje go do różnych komponentów. Ale oznacza to, że kontener DI może być używany jako lokalizator usług.
Symfony2 ma nawet do tego celu klasę 'ContainerAware'. Wielu uważa, że Service Locator jest antywzorcem w Symfony2. Osobiście się nie zgadzam. Jest to prostszy wzorzec w porównaniu do DI i jest dobry do prostych projektów. Ale wzór Service Locator i wzór DI-container połączone w jeden projekt to zdecydowanie antywzorzec.
W tym artykule postaramy się zbudować aplikację Symfony2 bez implementacji wzorca Service Locator. Podążamy za jedną prostą zasadą: tylko budowniczy kontenerów DI może wiedzieć o kontenerach DI.
Pojemnik DI
We wzorcu Dependency Injection, DI-container definiują zależności usług, a usługi mogą dawać tylko interfejs do wstrzykiwania. Istnieje wiele artykułów na temat Dependency Injection i prawdopodobnie przeczytałeś je wszystkie. Więc nie skupiajmy się na teorii i po prostu spójrzmy na podstawową ideę. DI może mieć 3 typy:
W Symfony strukturę wtrysku można zdefiniować za pomocą prostych plików konfiguracyjnych. Oto jak można skonfigurować te 3 rodzaje wtrysku:
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"
Projekt bootstrapowy
Stwórzmy naszą podstawową strukturę aplikacji. W tym czasie zainstalujemy komponent kontenera DI 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
Aby autoloader kompozytora znalazł nasze własne klasy w folderze src, możemy dodać właściwość 'autoloader' w pliku composer.json:
{ // ... "autoload": { "psr-4": { "": "src/" } } }
I stwórzmy naszego konstruktora kontenerów i zabrońmy wstrzykiwania kontenerów.
// 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); } }
Tutaj używamy komponentów Config i Yaml symfony. Szczegóły znajdziesz w oficjalnej dokumentacji tutaj. Na wszelki wypadek zdefiniowaliśmy również parametr ścieżki głównej „app_root”. Metoda get przeciąża domyślne zachowanie get klasy nadrzędnej i uniemożliwia kontenerowi zwrócenie „service_container”.
Następnie potrzebujemy punktu wejścia dla aplikacji.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));
Ten jest przeznaczony do obsługi żądań http. Możemy mieć więcej punktów wejścia dla poleceń konsoli, zadań cron i nie tylko. Każdy punkt wejścia ma otrzymać określone usługi i powinien wiedzieć o strukturze kontenera DI. To jedyne miejsce, w którym możemy zlecać usługi z kontenera. Od tego momentu będziemy starali się budować tę aplikację tylko przy użyciu plików konfiguracyjnych DI-container.
Jądro HTTP
HttpKernel (nie jądro frameworka z problemem lokalizatora usług) będzie naszym podstawowym komponentem dla części webowej aplikacji. Oto typowy przepływ pracy HttpKernel:
Zielone kwadraty to wydarzenia.
HttpKernel wykorzystuje komponent HttpFoundation dla obiektów Request i Response oraz komponent EventDispatcher dla systemu zdarzeń. Nie ma problemów z ich inicjalizacją za pomocą plików konfiguracyjnych DI-container. HttpKernel należy zainicjować za pomocą EventDispatcher, ControllerResolver i opcjonalnie za pomocą usługi RequestStack (w przypadku żądań podrzędnych).
Oto konfiguracja kontenera dla niego:
# 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' }
Jak widać, używamy właściwości 'factory' do tworzenia usługi żądania. Usługa HttpKernel pobiera tylko obiekt Request i zwraca obiekt Response. Można to zrobić w przednim kontrolerze.
// 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();
Lub odpowiedź można zdefiniować jako usługę w konfiguracji za pomocą właściwości „factory”.
# in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]
A potem po prostu dostajemy to do przedniego kontrolera.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();
Usługa rozpoznawania kontrolera pobiera właściwość „_controller” z atrybutów usługi Request w celu rozwiązania kontrolera. Te atrybuty można zdefiniować w konfiguracji kontenera, ale wygląda to trochę bardziej skomplikowanie, ponieważ zamiast prostej tablicy musimy użyć obiektu ParameterBag.
# in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, \App\Controller\DefaultController::defaultAction ]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...
A oto klasa DefaultController z metodą 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"); } }
Mając to wszystko na swoim miejscu, powinniśmy mieć działającą aplikację.
Ten kontroler jest dość bezużyteczny, ponieważ nie ma dostępu do żadnej usługi. We frameworku Symfony problem ten jest rozwiązywany przez wstrzyknięcie kontenera DI do kontrolera i użycie go jako lokalizatora usług. Nie zrobimy tego. Zdefiniujmy więc kontroler jako usługę i wstawmy do niego usługę żądania. Oto konfiguracja:
# 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' }
Oraz kod kontrolera:
// 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"); } }
Teraz kontroler ma dostęp do usługi żądania. Jak widać, ten schemat ma zależności kołowe. Działa, ponieważ kontener DI udostępnia usługę po utworzeniu, a przed wstrzyknięciem metody i właściwości. Tak więc, gdy usługa kontrolera jest tworzona, usługa żądania już istnieje.
Oto jak to działa:
Ale to działa tylko dlatego, że usługa żądań jest tworzona jako pierwsza. Gdy otrzymamy usługę odpowiedzi w kontrolerze frontowym, usługa żądania jest pierwszą zainicjowaną zależnością. Jeśli najpierw spróbujemy uzyskać usługę kontrolera, spowoduje to błąd zależności cyklicznej. Można to naprawić za pomocą iniekcji metody lub właściwości.
Ale jest inny problem. DI-container zainicjuje każdy kontroler z zależnościami. Więc zainicjuje wszystkie istniejące usługi, nawet jeśli nie są potrzebne. Na szczęście kontener ma funkcję leniwego ładowania. Komponent DI Symfony używa 'ocramius/proxy-manager' dla klas proxy. Musimy zainstalować most między nimi.
$ composer require symfony/proxy-manager-bridge
I zdefiniuj to na etapie budowy kontenera:
// in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
Teraz możemy zdefiniować leniwe usługi.
# in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]
Tak więc kontrolery spowodują inicjalizację usług zależnych tylko wtedy, gdy wywoływana jest właściwa metoda. Ponadto pozwala uniknąć cyklicznego błędu zależności, ponieważ usługa kontrolera zostanie udostępniona przed rzeczywistą inicjalizacją; chociaż nadal musimy unikać odniesień okrężnych. W takim przypadku nie powinniśmy wstrzykiwać usługi kontrolera w usłudze żądania ani usługi żądania do usługi kontrolera. Oczywiście potrzebujemy usługi request w kontrolerach, więc unikajmy wstrzykiwania usługi request na etapie inicjacji kontenera. HttpKernel posiada do tego celu system zdarzeń.
Rozgromienie
Najwyraźniej chcemy mieć różne kontrolery dla różnych żądań. Potrzebujemy więc systemu routingu. Zainstalujmy komponent routingu symfony.

$ composer require symfony/routing
Komponent routingu ma klasę Router, która może używać plików konfiguracyjnych routingu. Ale te konfiguracje są tylko parametrami klucz-wartość dla klasy Route. Framework Symfony używa własnego programu rozpoznawania kontrolerów z pakietu FrameworkBundle, który wstrzykuje kontener do kontrolerów z interfejsem 'ContainerAware'. Właśnie tego staramy się unikać. Przelicznik kontrolera HttpKernel zwraca obiekt klasy, jeśli już istnieje w atrybucie „_controller” jako tablicę z obiektem kontrolera i ciągiem metody akcji (w rzeczywistości, przelicznik kontrolera zwróci go tak, jak jest, jeśli jest to tylko tablica). Musimy więc zdefiniować każdą trasę jako usługę i wstrzyknąć do niej kontroler. Dodajmy inną usługę kontrolera, aby zobaczyć, jak to działa.
# 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"); } }
Komponent HttpKernel ma klasę RouteListener, która używa zdarzenia „kernel.request”. Oto jedna z możliwych konfiguracji z leniwymi kontrolerami:
# 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' }
W naszej aplikacji potrzebujemy również generatora adresów URL. Oto on:
# in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"
Generator URL można wstrzyknąć do kontrolera i usług renderujących. Teraz mamy aplikację podstawową. Dowolną inną usługę można zdefiniować w ten sam sposób, w jaki plik konfiguracyjny jest wstrzykiwany do określonych kontrolerów lub dyspozytora zdarzeń. Na przykład, oto kilka konfiguracji dla Twig i Doctrine.
Gałązka
Twig jest domyślnym silnikiem szablonów we frameworku Symfony2. Wiele komponentów Symfony2 może z niego korzystać bez żadnych adapterów. Jest to więc oczywisty wybór dla naszej aplikacji.
$ 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" ]
Doktryna
Doctrine to ORM używany we frameworku Symfony2. Możemy użyć dowolnego innego ORM, ale komponenty Symfony2 mogą już korzystać z wielu funkcji 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' }
Możemy również użyć plików konfiguracyjnych mapowania YML i XML zamiast adnotacji. Wystarczy użyć metod „createYAMLMetadataConfiguration” i „createXMLMetadataConfiguration” i ustawić ścieżkę do folderu z tymi plikami konfiguracyjnymi.
Wprowadzanie każdej potrzebnej usługi indywidualnie do każdego kontrolera może szybko stać się bardzo irytujące. Aby uczynić go trochę lepszym, komponent typu DI-container ma abstrakcyjne usługi i dziedziczenie usług. Możemy więc zdefiniować kilka abstrakcyjnych kontrolerów:
# 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 { // ... }
Istnieje wiele innych przydatnych komponentów Symfony, takich jak Form, Command i Assets. Zostały opracowane jako niezależne komponenty, więc ich integracja za pomocą kontenera DI nie powinna stanowić problemu.
Tagi
Pojemnik DI posiada również system znaczników. Tagi mogą być przetwarzane przez klasy Compiler Pass. Składnik Event Dispatcher ma własną przepustkę Compiler Pass, aby uprościć subskrypcję detektora zdarzeń, ale używa klasy ContainerAwareEventDispatcher zamiast klasy EventDispatcher. Więc nie możemy tego użyć. Ale możemy zaimplementować własne przepustki kompilatora dla wydarzeń, tras, bezpieczeństwa i innych celów.
Na przykład zaimplementujmy tagi dla systemu routingu. Teraz, aby zdefiniować trasę, musimy zdefiniować usługę trasy w pliku konfiguracyjnym trasy w folderze config/routes, a następnie dodać ją do usługi zbierania tras w pliku config/routing.yml. Wygląda to niespójnie, ponieważ w jednym miejscu definiujemy parametry routera, a w innym nazwę routera.
Dzięki systemowi tagów możemy po prostu zdefiniować nazwę trasy w tagu i dodać tę usługę trasy do kolekcji tras za pomocą nazwy tagu.
Komponent DI-container używa klas przepustek kompilatora, aby dokonać wszelkich modyfikacji w konfiguracji kontenera przed rzeczywistą inicjalizacją. Zaimplementujmy więc naszą klasę przepustową kompilatora dla systemu tagów routera.
// 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()); // ...
Teraz możemy zmodyfikować naszą konfigurację:
# 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' }
Jak widać, otrzymujemy kolekcje tras według nazwy tagu zamiast nazwy usługi, więc nasz system tagów tras nie zależy od rzeczywistej konfiguracji. Ponadto trasy można dodawać do dowolnej usługi zbierania za pomocą metody „dodaj”. Przepustki kompilatora mogą znacznie uprościć konfiguracje zależności. Mogą jednak dodać nieoczekiwane zachowanie do kontenera DI, więc lepiej nie modyfikować istniejącej logiki, takiej jak zmiana argumentów, wywołań metod lub nazw klas. Po prostu dodaj nowy, który już istniał, tak jak to zrobiliśmy za pomocą tagów.
Zakończyć
Mamy teraz aplikację, która używa tylko wzorca kontenera DI i jest zbudowana przy użyciu tylko plików konfiguracyjnych kontenera DI. Jak widać, budowanie w ten sposób aplikacji Symfony nie wiąże się z poważnymi wyzwaniami. I możesz po prostu zwizualizować wszystkie zależności aplikacji. Jedynym powodem, dla którego ludzie używają kontenera DI jako lokalizatora usług, jest to, że koncepcja lokalizatora usług jest łatwiejsza do zrozumienia. A ogromna baza kodów z kontenerem DI używanym jako lokalizator usług jest prawdopodobnie konsekwencją tego powodu.
Kod źródłowy tej aplikacji można znaleźć na GitHub.