Véritable injection de dépendance avec les composants Symfony
Publié: 2022-03-11Symfony2, un framework PHP hautes performances, utilise le modèle Dependency Injection Container où les composants fournissent une interface d'injection de dépendances pour le conteneur DI. Cela permet à chaque composant de ne pas se soucier des autres dépendances. La classe 'Kernel' initialise le conteneur DI et l'injecte dans différents composants. Mais cela signifie que le conteneur DI peut être utilisé comme localisateur de service.
Symfony2 a même la classe 'ContainerAware' pour cela. Beaucoup pensent que Service Locator est un anti-modèle dans Symfony2. Personnellement, je ne suis pas d'accord. C'est un modèle plus simple que DI et il convient aux projets simples. Mais le modèle Service Locator et le modèle DI-container combinés dans un seul projet sont définitivement un anti-modèle.
Dans cet article, nous allons essayer de construire une application Symfony2 sans implémenter le modèle Service Locator. Nous suivrons une règle simple : seul le constructeur de conteneur DI peut connaître le conteneur DI.
DI-conteneur
Dans le modèle d'injection de dépendance, le conteneur DI définit les dépendances de service et les services ne peuvent donner qu'une interface pour l'injection. Il existe de nombreux articles sur l'injection de dépendance, et vous les avez probablement tous lus. Ne nous concentrons donc pas sur la théorie et examinons simplement l'idée de base. DI peut être de 3 types :
Dans Symfony, la structure d'injection peut être définie à l'aide de fichiers de configuration simples. Voici comment ces 3 types d'injection peuvent être configurés :
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"
Projet d'amorçage
Créons notre structure d'application de base. Pendant que nous y sommes, nous allons installer le composant 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
Pour que composer autoloader trouve nos propres classes dans le dossier src, nous pouvons ajouter la propriété 'autoloader' dans le fichier composer.json :
{ // ... "autoload": { "psr-4": { "": "src/" } } }
Et créons notre constructeur de conteneurs et interdisons les injections de conteneurs.
// 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); } }
Ici, nous utilisons les composants Symfony Config et Yaml. Vous pouvez trouver des détails dans la documentation officielle ici. Nous avons également défini le paramètre de chemin racine 'app_root' juste au cas où. La méthode get surcharge le comportement get par défaut de la classe parent et empêche le conteneur de renvoyer le « service_container ».
Ensuite, nous avons besoin d'un point d'entrée pour l'application.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));
Celui-ci est destiné à gérer les requêtes http. Nous pouvons avoir plus de points d'entrée pour les commandes de la console, les tâches cron et plus encore. Chaque point d'entrée est censé obtenir certains services et doit connaître la structure du conteneur DI. C'est le seul endroit où nous pouvons demander des services au conteneur. À partir de ce moment, nous essaierons de construire cette application uniquement en utilisant les fichiers de configuration du conteneur DI.
HttpKernel
HttpKernel (pas le noyau du framework avec le problème de localisateur de service) sera notre composant de base pour la partie Web de l'application. Voici un flux de travail HttpKernel typique :
Les carrés verts sont des événements.
HttpKernel utilise le composant HttpFoundation pour les objets Request et Response et le composant EventDispatcher pour le système d'événements. Il n'y a aucun problème à les initialiser avec les fichiers de configuration du conteneur DI. HttpKernel doit être initialisé avec EventDispatcher, ControllerResolver et éventuellement avec les services RequestStack (pour les sous-requêtes).
Voici la configuration du conteneur :
# 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' }
Comme vous pouvez le voir, nous utilisons la propriété 'factory' pour créer le service de requête. Le service HttpKernel obtient uniquement l'objet Request et renvoie l'objet Response. Cela peut être fait dans le contrôleur frontal.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $HTTPKernel = $container->get('http_kernel'); $request = $container->get('request'); $response = $HTTPKernel->handle($request); $response->send();
Ou la réponse peut être définie comme un service dans la configuration en utilisant la propriété 'factory'.
# in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]
Et puis nous l'obtenons simplement dans le contrôleur frontal.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();
Le service de résolution de contrôleur obtient la propriété '_controller' des attributs du service Request pour résoudre le contrôleur. Ces attributs peuvent être définis dans la configuration du conteneur, mais cela semble un peu plus délicat car nous devons utiliser un objet ParameterBag au lieu d'un simple tableau.
# 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" # ...
Et voici la classe DefaultController avec la méthode 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"); } }
Avec tout cela en place, nous devrions avoir une application fonctionnelle.
Ce contrôleur est assez inutile car il n'a accès à aucun service. Dans le framework Symfony, ce problème est résolu en injectant un conteneur DI dans un contrôleur et en l'utilisant comme localisateur de service. Nous ne ferons pas cela. Définissons donc le contrôleur comme un service et injectons-y le service de requête. Voici la configuration :
# 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' }
Et le code du contrôleur :
// 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"); } }
Le contrôleur a maintenant accès au service de requête. Comme vous pouvez le voir, ce schéma a des dépendances circulaires. Cela fonctionne parce que le conteneur DI partage le service après la création et avant les injections de méthode et de propriété. Ainsi, lors de la création du service de contrôleur, le service de requête existe déjà.
Voici comment cela fonctionne:
Mais cela ne fonctionne que parce que le service de requête est créé en premier. Lorsque nous obtenons un service de réponse dans le contrôleur frontal, le service de requête est la première dépendance initialisée. Si nous essayons d'abord d'obtenir le service de contrôleur, cela provoquera une erreur de dépendance circulaire. Il peut être corrigé en utilisant des injections de méthode ou de propriété.
Mais il y a un autre problème. Le conteneur DI initialisera chaque contrôleur avec des dépendances. Ainsi, il initialisera tous les services existants même s'ils ne sont pas nécessaires. Heureusement, le conteneur dispose d'une fonctionnalité de chargement paresseux. Le composant Symfony DI utilise 'ocramius/proxy-manager' pour les classes proxy. Nous devons installer un pont entre eux.
$ composer require symfony/proxy-manager-bridge
Et définissez-le au stade de la construction du conteneur :
// in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
Nous pouvons maintenant définir les services paresseux.
# in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]
Ainsi, les contrôleurs provoqueront l'initialisation des services dépendants uniquement lorsqu'une méthode réelle est appelée. En outre, cela évite les erreurs de dépendance circulaire car un service de contrôleur sera partagé avant l'initialisation réelle ; bien que nous devions encore éviter les références circulaires. Dans ce cas, nous ne devons pas injecter le service de contrôleur dans le service de requête ou le service de requête dans le service de contrôleur. Évidemment, nous avons besoin d'un service de requête dans les contrôleurs, évitons donc une injection dans le service de requête à l'étape d'initiation du conteneur. HttpKernel a un système d'événements à cet effet.
Routage
Apparemment, nous voulons avoir différents contrôleurs pour différentes demandes. Nous avons donc besoin d'un système de routage. Installons le composant de routage symfony.

$ composer require symfony/routing
Le composant de routage a la classe Router qui peut utiliser des fichiers de configuration de routage. Mais ces configurations ne sont que des paramètres clé-valeur pour la classe Route. Le framework Symfony utilise son propre résolveur de contrôleur à partir du FrameworkBundle qui injecte un conteneur dans les contrôleurs avec l'interface 'ContainerAware'. C'est exactement ce que nous essayons d'éviter. Le résolveur de contrôleur HttpKernel renvoie l'objet de classe tel quel s'il existe déjà dans l'attribut '_controller' en tant que tableau avec l'objet contrôleur et la chaîne de méthode d'action (en fait, le résolveur de contrôleur le renverra tel quel s'il ne s'agit que d'un tableau). Nous devons donc définir chaque route comme un service et y injecter un contrôleur. Ajoutons un autre service de contrôleur pour voir comment cela fonctionne.
# 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"); } }
Le composant HttpKernel a la classe RouteListener qui utilise l'événement 'kernel.request'. Voici une configuration possible avec des contrôleurs paresseux :
# 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' }
Nous avons également besoin d'un générateur d'URL dans notre application. C'est ici:
# in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"
Le générateur d'URL peut être injecté dans le contrôleur et les services de rendu. Nous avons maintenant une application de base. Tout autre service peut être défini de la même manière que le fichier de configuration est injecté dans certains contrôleurs ou répartiteurs d'événements. Par exemple, voici quelques configurations pour Twig et Doctrine.
Brindille
Twig est le moteur de template par défaut du framework Symfony2. De nombreux composants Symfony2 peuvent l'utiliser sans aucun adaptateur. C'est donc un choix évident pour notre application.
$ 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" ]
Doctrine
Doctrine est un ORM utilisé dans le framework Symfony2. Nous pouvons utiliser n'importe quel autre ORM, mais les composants Symfony2 peuvent déjà utiliser de nombreuses fonctionnalités 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' }
Nous pouvons également utiliser des fichiers de configuration de mappage YML et XML au lieu d'annotations. Nous avons juste besoin d'utiliser les méthodes 'createYAMLMetadataConfiguration' et 'createXMLMetadataConfiguration' et de définir le chemin d'accès à un dossier avec ces fichiers de configuration.
Il peut rapidement devenir très ennuyeux d'injecter chaque service nécessaire dans chaque contrôleur individuellement. Pour le rendre un peu meilleur, le composant DI-container a des services abstraits et un héritage de service. Nous pouvons donc définir des contrôleurs abstraits :
# 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 { // ... }
Il existe de nombreux autres composants Symfony utiles tels que Form, Command et Assets. Ils ont été développés en tant que composants indépendants, de sorte que leur intégration à l'aide d'un conteneur DI ne devrait pas poser de problème.
Mots clés
DI-container dispose également d'un système de balises. Les balises peuvent être traitées par les classes Compiler Pass. Le composant Event Dispatcher possède son propre Compiler Pass pour simplifier l'abonnement à l'écouteur d'événements, mais il utilise la classe ContainerAwareEventDispatcher au lieu de la classe EventDispatcher. Nous ne pouvons donc pas l'utiliser. Mais nous pouvons implémenter nos propres passes de compilateur pour les événements, les itinéraires, la sécurité et tout autre objectif.
Par exemple, implémentons des balises pour le système de routage. Maintenant, pour définir une route, nous devons définir un service de route dans un fichier de configuration de route dans le dossier config/routes, puis l'ajouter au service de collecte de routes dans le fichier config/routing.yml. Cela semble incohérent car nous définissons les paramètres du routeur à un endroit et un nom de routeur à un autre.
Avec le système de balises, nous pouvons simplement définir un nom de route dans une balise et ajouter ce service de route à la collection de routes en utilisant un nom de balise.
Le composant DI-container utilise des classes de passe de compilateur pour apporter toute modification à une configuration de conteneur avant l'initialisation réelle. Implémentons donc notre classe de passe de compilateur pour le système de balises de routeur.
// 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()); // ...
Nous pouvons maintenant modifier notre configuration :
# 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' }
Comme vous pouvez le voir, nous obtenons les collections de routes par le nom de la balise au lieu du nom du service, de sorte que notre système de balises de route ne dépend pas de la configuration réelle. De plus, les itinéraires peuvent être ajoutés à n'importe quel service de collecte avec une méthode "add". Les passeurs de compilateur peuvent considérablement simplifier les configurations des dépendances. Mais ils peuvent ajouter un comportement inattendu au conteneur DI, il est donc préférable de ne pas modifier la logique existante comme la modification des arguments, des appels de méthode ou des noms de classe. Il suffit d'en ajouter un nouveau qui existait comme nous l'avons fait en utilisant des balises.
Emballer
Nous avons maintenant une application qui utilise uniquement le modèle de conteneur DI, et elle est construite en utilisant uniquement les fichiers de configuration du conteneur DI. Comme vous pouvez le voir, il n'y a pas de défis sérieux à construire une application Symfony de cette façon. Et vous pouvez simplement visualiser toutes les dépendances de vos applications. La seule raison pour laquelle les gens utilisent DI-container comme localisateur de service est qu'un concept de localisateur de service est plus facile à comprendre. Et une énorme base de code avec un conteneur DI utilisé comme localisateur de service est probablement une conséquence de cette raison.
Vous pouvez trouver le code source de cette application sur GitHub.