Symfony2, un framework PHP haute performance, utilise un modèle de conteneur d'injection de dépendances 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-pattern dans Symfony2. Personnellement, je ne suis pas d'accord. C'est un modèle plus simple que DI et il est bon pour les projets simples. Mais le modèle de localisateur de service et le modèle de conteneur DI combinés dans un seul projet sont définitivement un anti-modèle.
Dans cet article, nous allons essayer de créer une application Symfony2 sans implémenter le modèle Service Locator. Nous suivrons une règle simple: seul le constructeur de conteneurs DI peut connaître le conteneur DI.
Dans le modèle d'injection de dépendances, 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 Injection de dépendance , et vous les avez probablement tous lus. Ne nous concentrons donc pas sur la théorie et jetons simplement un œil à l’idée de base. DI peut être de 3 types:
Dans Symfony, la structure d'injection peut être définie à l'aide de simples fichiers de configuration. 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'
Créons notre structure d’application de base. Pendant que nous y sommes, nous installerons 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 l’autoloader du compositeur 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 SymfonyComponentDependencyInjectionContainerBuilder; use SymfonyComponentConfigFileLocator; use SymfonyComponentDependencyInjectionLoaderYamlFileLoader; use SymfonyComponentDependencyInjectionContainerInterface; 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 Config et Yaml symfony. Vous pouvez trouver des détails dans la documentation officielle Ici . Nous avons également défini le paramètre de chemin racine «racine_app» au cas où. La méthode get surcharge le comportement get par défaut de la classe parente 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. A partir de ce moment, nous allons essayer de construire cette application uniquement en utilisant les fichiers de configuration du conteneur DI.
HttpKernel (pas le noyau du framework avec le problème du 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 lors de leur initialisation 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 pour cela:
# in config/events.yml services: dispatcher: class: SymfonyComponentEventDispatcherEventDispatcher
# in config/kernel.yml services: request: class: SymfonyComponentHttpFoundationRequest factory: [ SymfonyComponentHttpFoundationRequest, createFromGlobals ] request_stack: class: SymfonyComponentHttpFoundationRequestStack resolver: class: SymfonyComponentHttpKernelControllerControllerResolver http_kernel: class: SymfonyComponentHttpKernelHttpKernel 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 demande. 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 à l’aide de la propriété ‘factory’.
cadre d'entité où dans la liste
# in config/kernel.yml # ... response: class: SymfonyComponentHttpFoundationResponse factory: [ '@http_kernel', handle] arguments: ['@request']
Et puis nous l'avons juste dans le contrôleur avant.
// 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» à partir des attributs du service de requête 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: SymfonyComponentHttpFoundationParameterBag calls: - [ set, [ _controller, AppControllerDefaultController::defaultAction ]] request: class: SymfonyComponentHttpFoundationRequest factory: [ SymfonyComponentHttpFoundationRequest, createFromGlobals ] properties: attributes: '@request_attributes' # ...
Et voici la classe DefaultController avec la méthode defaultAction.
// in src/App/Controller/DefaultController.php namespace AppController; use SymfonyComponentHttpFoundationResponse; 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 en tant que service et y injectons le service de requête. Voici la configuration:
# in config/controllers.yml services: controller.default: class: AppControllerDefaultController arguments: [ '@request']
# in config/kernel.yml # ... request_attributes: class: SymfonyComponentHttpFoundationParameterBag calls: - [ set, [ _controller, ['@controller.default', defaultAction ]]] request: class: SymfonyComponentHttpFoundationRequest factory: [ SymfonyComponentHttpFoundationRequest, 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 AppController; use SymfonyComponentHttpFoundationRequest; use SymfonyComponentHttpFoundationResponse; 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 demande. 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, lorsque le service de contrôleur est en cours de création, le service de demande existe déjà.
Voici comment cela fonctionne:
quel adjectif est le plus approprié pour la gestion de projet agile ?
Mais cela ne fonctionne que parce que le service de demande 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 du 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. DI-container initialisera chaque contrôleur avec des dépendances. Il initialisera donc 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 SymfonyBridgeProxyManagerLazyProxyInstantiatorRuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
Maintenant, nous pouvons définir des services paresseux.
# in config/controllers.yml services: controller.default: lazy: true class: AppControllerDefaultController 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; même si nous devons encore éviter les références circulaires. Dans ce cas, nous ne devons pas injecter le service de contrôleur dans le service de demande ou le service de demande 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.
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 de FrameworkBundle qui injecte le 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 retournera tel quel s’il s’agit simplement 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: AppControllerPageController arguments: [ '@request']
// in src/App/Controller/PageController.php namespace AppController; use SymfonyComponentHttpFoundationRequest; use SymfonyComponentHttpFoundationResponse; 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: SymfonyComponentRoutingRoute arguments: path: / defaults: _controller: ['@controller.default', 'defaultAction'] route.page: class: SymfonyComponentRoutingRoute arguments: path: /page/{id} defaults: _controller: ['@controller.page', 'defaultAction']
# in config/routing.yml imports: - { resource: ’routes/default.yml' } services: route.collection: class: SymfonyComponentRoutingRouteCollection calls: - [ add, ['route_home', '@route.home'] ] - [ add, ['route_page', '@route.page'] ] router.request_context: class: SymfonyComponentRoutingRequestContext calls: - [ fromRequest, ['@request'] ] router.matcher: class: SymfonyComponentRoutingMatcherUrlMatcher arguments: [ '@route.collection', '@router.request_context' ] router.listener: class: SymfonyComponentHttpKernelEventListenerRouterListener arguments: matcher: '@router.matcher' request_stack: '@request_stack' context: '@router.request_context'
# in config/events.yml service: dispatcher: class: SymfonyComponentEventDispatcherEventDispatcher 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: SymfonyComponentRoutingGeneratorUrlGenerator arguments: routes: '@route.collection' context: '@router.request_context'
Le générateur d'URL peut être injecté dans les services de contrôleur et 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.
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 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 de 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: DoctrineORMConfiguration factory: [ DoctrineORMToolsSetup, createAnnotationMetadataConfiguration ] arguments: paths: '%doctrine.paths%' isDevMode: '%doctrine.is_dev%' doctrine.entity_manager: class: DoctrineORMEntityManager factory: [ DoctrineORMEntityManager, 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 devons simplement utiliser les méthodes «createYAMLMetadataConfiguration» et «createXMLMetadataConfiguration» et définir le chemin vers un dossier contenant 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 de conteneur DI a des services abstraits et un héritage de service. Nous pouvons donc définir quelques contrôleurs abstraits:
# in config/controllers.yml services: controller.base_web: lazy: true abstract: true class: AppControllerBaseWebController arguments: request: '@request' templating: '@templating.twig' entityManager: '@doctrine.entity_manager' urlGenerator: '@router.generator' controller.default: class: AppControllerDefaultController parent: controller.base_web controller.page: class: AppControllerPageController parent: controller.base_web
// in src/App/Controller/Base/WebController.php namespace AppControllerBase; use SymfonyComponentHttpFoundationRequest; use Twig_Environment; use DoctrineORMEntityManager; use SymfonyComponentRoutingGeneratorUrlGenerator; 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 en utilisant DI-container ne devrait pas être un problème.
DI-container dispose également d'un système d'étiquettes. Les balises peuvent être traitées par les classes Compiler Pass. Le composant Event Dispatcher possède sa propre passe de compilation 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 routes, 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 routage à la collection de routes en utilisant un nom de balise.
Le composant DI-container utilise des classes pass du compilateur pour apporter toute modification à une configuration de conteneur avant l'initialisation réelle. Alors implémentons notre classe pass du compilateur pour le système de balises de routeur.
// in src/CompilerPass/RouterTagCompilerPass.php namespace CompilerPass; use SymfonyComponentDependencyInjectionCompilerCompilerPassInterface; use SymfonyComponentDependencyInjectionContainerBuilder; use SymfonyComponentDependencyInjectionDefinition; use SymfonyComponentDependencyInjectionReference; 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 CompilerPassRouterTagCompilerPass; // ... $container = new self(); $container->addCompilerPass(new RouterTagCompilerPass()); // ...
Nous pouvons maintenant modifier notre configuration:
# in config/routing.yml # … route.collection: class: SymfonyComponentRoutingRouteCollection tags: - { name: route_collection } # ...
# in config/routes/default.yml services: route.home: class: SymfonyComponentRoutingRoute arguments: path: / defaults: _controller: ['@controller.default', 'defaultAction'] tags: - { name: route, route_name: 'route_home' } route.page: class: SymfonyComponentRoutingRoute 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. En outre, des itinéraires peuvent être ajoutés à n’importe quel service de collecte avec une méthode «ajouter». Les passeurs du compilateur peuvent simplifier considérablement 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. Ajoutez simplement un nouveau sur existait comme nous l'avons fait en utilisant des balises.
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 de conteneur DI. Comme vous pouvez le voir, il n'y a pas de défis sérieux dans création d'une application Symfony Par ici. Et vous pouvez simplement visualiser toutes vos dépendances applicatives. La seule raison pour laquelle les gens utilisent DI-container comme localisateur de services est qu'un concept de localisateur de services est plus facile à comprendre. Et une énorme base de code avec un conteneur DI utilisé comme localisateur de services est probablement une conséquence de cette raison.
Vous pouvez trouver le code source de cette application sur GitHub .