Echte Abhängigkeitsinjektion mit Symfony-Komponenten
Veröffentlicht: 2022-03-11Symfony2, ein Hochleistungs-PHP-Framework, verwendet das Dependency Injection Container-Muster, bei dem Komponenten eine Dependency Injection-Schnittstelle für den DI-Container bereitstellen. Dadurch kann sich jede Komponente nicht um andere Abhängigkeiten kümmern. Die Klasse „Kernel“ initialisiert den DI-Container und injiziert ihn in verschiedene Komponenten. Aber das bedeutet, dass DI-Container als Service Locator verwendet werden können.
Dafür hat Symfony2 sogar die Klasse ‚ContainerAware‘. Viele sind der Meinung, dass Service Locator ein Anti-Pattern in Symfony2 ist. Ich persönlich stimme dem nicht zu. Es ist ein einfacheres Muster im Vergleich zu DI und eignet sich gut für einfache Projekte. Aber das Service Locator-Muster und das DI-Container-Muster in Kombination in einem einzigen Projekt sind definitiv ein Anti-Muster.
In diesem Artikel werden wir versuchen, eine Symfony2-Anwendung zu erstellen, ohne das Service Locator-Muster zu implementieren. Wir werden eine einfache Regel befolgen: Nur DI-Container-Ersteller können über DI-Container Bescheid wissen.
DI-Behälter
Im Abhängigkeitsinjektionsmuster definieren DI-Container Dienstabhängigkeiten und Dienste können nur eine Schnittstelle für die Injektion bereitstellen. Es gibt viele Artikel über Dependency Injection, und Sie haben wahrscheinlich alle gelesen. Konzentrieren wir uns also nicht auf die Theorie und werfen wir einen Blick auf die Grundidee. DI kann von 3 Arten sein:
In Symfony kann die Injektionsstruktur mit einfachen Konfigurationsdateien definiert werden. So können diese 3 Injektionsarten konfiguriert werden:
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"
Bootstrapping-Projekt
Lassen Sie uns unsere Basisanwendungsstruktur erstellen. Wenn wir schon dabei sind, werden wir die Symfony DI-Container-Komponente installieren.
$ 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
Damit der Composer Autoloader unsere eigenen Klassen im src-Ordner findet, können wir die Eigenschaft „autoloader“ in der Datei composer.json hinzufügen:
{ // ... "autoload": { "psr-4": { "": "src/" } } }
Und lassen Sie uns unseren Container-Builder erstellen und Container-Injektionen verbieten.
// 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); } }
Hier verwenden wir die Config- und die Yaml-Symfony-Komponenten. Details finden Sie in der offiziellen Dokumentation hier. Außerdem haben wir für alle Fälle den Root-Pfad-Parameter ‚app_root‘ definiert. Die get-Methode überlädt das standardmäßige get-Verhalten der übergeordneten Klasse und verhindert, dass der Container den „service_container“ zurückgibt.
Als nächstes benötigen wir einen Einstiegspunkt für die Anwendung.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));
Dieser soll HTTP-Anfragen verarbeiten. Wir können mehr Einstiegspunkte für Konsolenbefehle, Cron-Tasks und mehr haben. Jeder Einstiegspunkt soll bestimmte Dienste erhalten und die DI-Container-Struktur kennen. Dies ist der einzige Ort, an dem wir Dienste aus dem Container anfordern können. Von diesem Moment an werden wir versuchen, diese Anwendung nur mit DI-Container-Konfigurationsdateien zu erstellen.
HttpKernel
HttpKernel (nicht der Framework-Kernel mit dem Service-Locator-Problem) wird unsere Basiskomponente für den Webpart der Anwendung sein. Hier ist ein typischer HttpKernel-Workflow:
Grüne Quadrate sind Ereignisse.
HttpKernel verwendet die HttpFoundation-Komponente für Request- und Response-Objekte und die EventDispatcher-Komponente für das Ereignissystem. Es gibt keine Probleme, sie mit DI-Container-Konfigurationsdateien zu initialisieren. HttpKernel muss mit den Diensten EventDispatcher, ControllerResolver und optional mit RequestStack (für Unteranforderungen) initialisiert werden.
Hier ist die Containerkonfiguration dafür:
# 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' }
Wie Sie sehen können, verwenden wir die Eigenschaft „factory“, um den Anforderungsdienst zu erstellen. Der HttpKernel-Dienst ruft nur das Request-Objekt ab und gibt das Response-Objekt zurück. Dies kann im Front-Controller erfolgen.
// 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();
Oder die Antwort kann als Dienst in der Konfiguration definiert werden, indem die Eigenschaft „factory“ verwendet wird.
# in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]
Und dann bekommen wir es einfach in den vorderen Controller.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();
Der Controller-Resolver-Dienst erhält die Eigenschaft „_controller“ aus Attributen des Request-Dienstes, um den Controller aufzulösen. Diese Attribute können in der Containerkonfiguration definiert werden, aber es sieht etwas kniffliger aus, weil wir ein ParameterBag-Objekt anstelle eines einfachen Arrays verwenden müssen.
# 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" # ...
Und hier ist die Klasse DefaultController mit der Methode 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"); } }
Wenn all dies vorhanden ist, sollten wir eine funktionierende Anwendung haben.
Dieser Controller ist ziemlich nutzlos, da er keinen Zugriff auf einen Dienst hat. Im Symfony-Framework wird dieses Problem gelöst, indem ein DI-Container in einen Controller eingefügt und als Service-Locator verwendet wird. Das werden wir nicht tun. Lassen Sie uns also den Controller als Dienst definieren und den Anforderungsdienst darin einfügen. Hier ist die Konfiguration:
# 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' }
Und der Controller-Code:
// 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"); } }
Jetzt hat der Verantwortliche Zugriff auf den Anfragedienst. Wie Sie sehen können, hat dieses Schema zirkuläre Abhängigkeiten. Es funktioniert, weil der DI-Container den Dienst nach der Erstellung und vor der Injektion von Methoden und Eigenschaften teilt. Wenn also der Controller-Dienst erstellt wird, ist der Anforderungsdienst bereits vorhanden.
So funktioniert das:
Das funktioniert aber nur, weil zuerst der Request-Service erstellt wird. Wenn wir den Response-Service im Front-Controller erhalten, ist der Request-Service die erste initialisierte Abhängigkeit. Wenn wir zuerst versuchen, den Controller-Dienst abzurufen, führt dies zu einem zirkulären Abhängigkeitsfehler. Es kann durch Methoden- oder Eigenschaftsinjektionen behoben werden.
Aber es gibt noch ein weiteres Problem. Der DI-Container initialisiert jeden Controller mit Abhängigkeiten. Daher werden alle vorhandenen Dienste initialisiert, auch wenn sie nicht benötigt werden. Glücklicherweise verfügt der Container über Lazy-Loading-Funktionalität. Symfony DI-Komponente verwendet 'ocramius/proxy-manager' für Proxy-Klassen. Wir müssen eine Brücke zwischen ihnen bauen.
$ composer require symfony/proxy-manager-bridge
Und definieren Sie es in der Phase des Containerbaus:
// in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
Jetzt können wir faule Dienste definieren.
# in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]
Daher veranlassen Controller nur dann die Initialisierung abhängiger Dienste, wenn eine tatsächliche Methode aufgerufen wird. Außerdem wird ein zirkulärer Abhängigkeitsfehler vermieden, da ein Controller-Dienst vor der eigentlichen Initialisierung gemeinsam genutzt wird; obwohl wir immer noch Zirkelbezüge vermeiden müssen. In diesem Fall sollten wir den Controller-Dienst nicht in den Anfragedienst oder den Anfragedienst in den Controller-Dienst einfügen. Offensichtlich brauchen wir einen Request-Service in Controllern, also vermeiden wir eine Injektion in den Request-Service in der Container-Initiierungsphase. HttpKernel verfügt zu diesem Zweck über ein Ereignissystem.

Routing
Anscheinend wollen wir unterschiedliche Controller für unterschiedliche Anfragen haben. Wir brauchen also ein Routing-System. Lassen Sie uns die Symfony-Routing-Komponente installieren.
$ composer require symfony/routing
Die Routing-Komponente hat die Klasse Router, die Routing-Konfigurationsdateien verwenden kann. Aber diese Konfigurationen sind nur Schlüsselwertparameter für die Route-Klasse. Das Symfony-Framework verwendet einen eigenen Controller-Resolver aus dem FrameworkBundle, der Container in Controller mit der 'ContainerAware'-Schnittstelle einfügt. Genau das versuchen wir zu vermeiden. Der HttpKernel-Controller-Resolver gibt das Klassenobjekt unverändert zurück, wenn es bereits im Attribut „_controller“ als Array mit dem Controller-Objekt und der Aktionsmethodenzeichenfolge vorhanden ist (tatsächlich gibt der Controller-Resolver es so zurück, wie es ist, wenn es nur ein Array ist). Wir müssen also jede Route als Dienst definieren und einen Controller darin einfügen. Lassen Sie uns einen anderen Controller-Dienst hinzufügen, um zu sehen, wie er funktioniert.
# 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"); } }
Die HttpKernel-Komponente hat die RouteListener-Klasse, die das Ereignis „kernel.request“ verwendet. Hier ist eine mögliche Konfiguration mit faulen Controllern:
# 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' }
Außerdem benötigen wir einen URL-Generator in unserer Anwendung. Hier ist es:
# in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"
Der URL-Generator kann in Controller- und Rendering-Dienste eingefügt werden. Jetzt haben wir eine Basisanwendung. Jeder andere Dienst kann auf die gleiche Weise definiert werden, wie die Konfigurationsdatei in bestimmte Controller oder Event-Dispatcher eingefügt wird. Hier sind zum Beispiel einige Konfigurationen für Twig und Doctrine.
Zweig
Twig ist die Standard-Template-Engine im Symfony2-Framework. Viele Symfony2-Komponenten können ohne Adapter verwendet werden. Es ist also eine offensichtliche Wahl für unsere Anwendung.
$ 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" ]
Lehre
Doctrine ist ein ORM, das im Symfony2-Framework verwendet wird. Wir können jedes andere ORM verwenden, aber Symfony2-Komponenten können bereits viele Docrine-Funktionen verwenden.
$ 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' }
Anstelle von Anmerkungen können wir auch YML- und XML-Mapping-Konfigurationsdateien verwenden. Wir müssen nur die Methoden „createYAMLMetadataConfiguration“ und „createXMLMetadataConfiguration“ verwenden und den Pfad zu einem Ordner mit diesen Konfigurationsdateien festlegen.
Es kann schnell sehr lästig werden, jeden benötigten Dienst einzeln in jeden Controller einzuspeisen. Um es ein wenig besser zu machen, hat die DI-Container-Komponente abstrakte Dienste und Dienstvererbung. So können wir einige abstrakte Controller definieren:
# 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 { // ... }
Es gibt viele andere nützliche Symfony-Komponenten wie Form, Command und Assets. Sie wurden als eigenständige Komponenten entwickelt, sodass ihre Einbindung mittels DI-Container kein Problem darstellen sollte.
Stichworte
DI-Container hat auch ein Tag-System. Tags können von Compiler Pass-Klassen verarbeitet werden. Die Event-Dispatcher-Komponente verfügt über einen eigenen Compiler-Pass, um das Abonnement von Ereignis-Listenern zu vereinfachen, verwendet jedoch die ContainerAwareEventDispatcher-Klasse anstelle der EventDispatcher-Klasse. Also können wir es nicht verwenden. Aber wir können unsere eigenen Compiler-Pässe für Ereignisse, Routen, Sicherheit und andere Zwecke implementieren.
Lassen Sie uns zum Beispiel Tags für das Routing-System implementieren. Um nun eine Route zu definieren, müssen wir einen Routendienst in einer Routenkonfigurationsdatei im Ordner config/routes definieren und ihn dann dem Routenerfassungsdienst in der Datei config/routing.yml hinzufügen. Es sieht inkonsistent aus, weil wir an einer Stelle Router-Parameter und an einer anderen einen Router-Namen definieren.
Mit dem Tag-System können wir einfach einen Routennamen in einem Tag definieren und diesen Routendienst mithilfe eines Tag-Namens zur Routensammlung hinzufügen.
Die DI-Container-Komponente verwendet Compiler-Pass-Klassen, um vor der eigentlichen Initialisierung Änderungen an einer Container-Konfiguration vorzunehmen. Lassen Sie uns also unsere Compiler-Pass-Klasse für das Router-Tag-System implementieren.
// 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()); // ...
Jetzt können wir unsere Konfiguration ändern:
# 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' }
Wie Sie sehen können, erhalten wir Routensammlungen anhand des Tag-Namens anstelle des Dienstnamens, sodass unser Routen-Tag-System nicht von der tatsächlichen Konfiguration abhängt. Außerdem können Routen zu jedem Sammeldienst mit einer „Hinzufügen“-Methode hinzugefügt werden. Compiler-Passer können die Konfiguration von Abhängigkeiten erheblich vereinfachen. Aber sie können dem DI-Container ein unerwartetes Verhalten hinzufügen, daher ist es besser, vorhandene Logik wie das Ändern von Argumenten, Methodenaufrufen oder Klassennamen nicht zu ändern. Fügen Sie einfach ein neues hinzu, wie wir es durch die Verwendung von Tags getan haben.
Einpacken
Wir haben jetzt eine Anwendung, die nur DI-Container-Muster verwendet und nur mit DI-Container-Konfigurationsdateien erstellt wird. Wie Sie sehen können, gibt es keine ernsthaften Herausforderungen beim Erstellen einer Symfony-Anwendung auf diese Weise. Und Sie können alle Ihre Anwendungsabhängigkeiten einfach visualisieren. Der einzige Grund, warum Menschen DI-Container als Service-Locator verwenden, ist, dass ein Service-Locator-Konzept einfacher zu verstehen ist. Und eine riesige Codebasis mit DI-Containern, die als Service-Locator verwendet werden, ist wahrscheinlich eine Folge dieses Grundes.
Den Quellcode dieser Anwendung finden Sie auf GitHub.