La nouvelle configuration par défaut de Symfony 4

Un an de travail acharné de la communauté vient de s’achever. Symfony 4 est là ! Au menu, plein de nouvelles choses. Pour aujourd’hui, je vous propose de nous concentrer sur la configuration de vos applications. Qui dit configuration, dit injecteur de dépendances bien-sûr !

Commençons par installer un squelette Symfony 4 :

composer create-project symfony/skeleton beerfactory

La configuration de vos services s’effectue désormais dans le fichier config/services.yaml. Contrairement aux versions précédentes, ce fichier est réservé à la configuration de vos services, i.e. ceux dont l’implémentation réside dans votre dossier src/.

La configuration des fonctionnalités du framework ou des bundles tiers doit, elle, s’effectuer par autant de fichiers config/packages/*.yaml.

À quelques détails près, la configuration par défaut pour vos services est la suivante :

services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    App\:
        resource: '../src/*'

Voyons le sens de ces directives une par une.

Définir des conventions pour automatiser la configuration

Si vous êtes expert Symfony 2-3, vous avez pris l’habitude de faire du va-et-vient : chaque nouvelle implémentation de service ou chaque changement de signature PHP nécessite une modification équivalente en Yaml. Tout doit être explicite. Au détail près.

Avec Symfony 4, ce qui a fait la force du framework est toujours là : tout doit être explicite. Mais vous pouvez maintenant définir des conventions, plutôt que des détails. Une fois ces conventions en place, il suffit de les suivre pour ne plus avoir à configurer quoi que ce soit. Évidement, il y a toujours des cas particuliers. C’est à ce moment là seulement que vous devrez modifier votre Yaml : pour les choses qui ont la valeur de l’exception à la règle ; les marqueurs de différenciation de vos applications.

Chaque classe dans src/ est potentiellement un service

C’est la première convention que vous pourrez adopter, et celle que définissent ces lignes :

    App\:
        resource:'../src/*'

Ceci exprime la convention suivante : pour chaque fichier dans src/, créer un service du même nom que la classe qui s’y trouve, récursivement. Cela permet de ne plus avoir à se soucier de trouver un nom à vos services - leur classe suffit. Et cela permet également de n’avoir rien à changer pour enregister un nouveau service. Le container sera automatiquement mis à jour dès que vous créerez une nouvelle classe, et elle sera disponible en tant que service.

Évidement, toutes les classes n’ont pas vocation à être utilisées ainsi. C’est ce que dit la seconde convention.

Chaque service inutilisé doit être nettoyé

    _defaults:
        public: false

Lorsqu’un service est dit “privé”, on ne peut plus y accéder via l’appel $container->get(). La conséquence est qu’il est possible de le supprimer du container - ça ne fera aucune différence - ou bien de l’inliner s’il est requis par un autre service. Cette double optimisation existe depuis les débuts de Symfony 2. En l’activant par défaut, on tourne le mécanisme à notre avantage pour ne garder que les classes qui sont effectivement utilisées sous forme de service.

Le corrolaire architectural est une lapalissade : vous ne pouvez plus accéder à vos services via l’appel $container->get(). C’est une excellente chose : utiliser cette méthode est un anti-pattern qui masque vos dépendances, et qui par conséquent augmente significativement et instantanément votre dette technique.

Le rôle d’un framework est de favoriser les bonnes pratiques. C’est chose faite. Cette règle est tellement bénéfique en réalité qu’elle est activée par défaut depuis Symfony 4, et s’applique aussi bien à vos services qu’aux services du noyau et des bundles tiers. Vous pouvez donc supprimer cette ligne de votre fichier de configuration si le coeur vous en dit, le résultat sera le même.

Mais que signifie “être utilisé en tant que service” au fait ?

Chaque classe dérivant de Command est une … commande, etc.

    _defaults:
        autoconfigure: true

Le framework dispose d’une liste d’interfaces et de classes abstraites que vous pouvez implémenter pour par exemple créer une nouvelle commande pour la console, ou bien pour écouter les événements déclenchés lors du traitement d’une requête HTTP, etc.

Pour un bundle partagé - avec options de configuration et complexité liée à la réutilisation, ce ne sera pas toujours le cas ; mais de votre coté, si vous vous donnez la peine d’implémenter la classe de base Command, il y a de grandes chances que vous souhaitiez créer une commande.

L’auto-configuration est là pour ça. Lorsque cette option est activée, Symfony va regarder les types implémentés par chacun de vos services (classes et interfaces), et leur appliquer des tags automatiquement en fonction. Ainsi, si une de vos classes implémente Command, le service correspondant se verra attribuer le tag console.command.

À partir de ce moment, le service est enregistré dans le container - il ne pourra plus être supprimé par la règle précédente - et pourra par contre être inliné dans la liste des commandes disponibles.

En interne, les interfaces ou classes de base qui activent un comportement particulier sont définies grâce à l’appel $container->registerForAutoconfiguration(), fait dans le noyau ou bien dans les bundles tiers.

Chaque classe nécessitant un LoggerInterface doit recevoir le logger par défaut

    _defaults:
        autowiring: true

C’est le fameux autowiring. Cette directive dit à Symfony de regarder le constructeur et les éventuels “setters” de vos services, et d’y injecter le service correspondant au type requis par leurs arguments, lorsque c’est possible.

Se pose alors un problème de choix. Le service, mais lequel ? Lors de son introduction en version 2.8, la règle était de choisir le service, parmi tout ceux existants, qui implémentait le type requis. S’il en existait un et un seul pour un type donné, c’était l’élu. S’il en existait deux, une ambiguïté était levée sous forme d’exception.

Je dis “était” car ça n’est plus le cas. Vous trouverez encore des articles et peut-être même des collègues qui pensent que c’est ainsi. C’était de la magie noire. C’est obsolète.

Désormais, pour un type requis donné (par exemple Psr\Log\LoggerInterface), Symfony va injecter le service dont le nom est “Psr\Log\LoggerInterface”. Comme les noms sont uniques, il n’y a plus d’ambiguïté possible. Le type effectivement implémenté n’est tout simplement jamais considéré. Oui : cela signifie que vous pouvez nommer un service “Psr\Log\LoggerInterface”, et ne pas lui faire implémenter l’interface du même nom. Mais si vous faites ça, Symfony ne peut rien pour vous de toute façon. L’effet sera une erreur fatale à l’exécution pour cause de type incompatible - merci d’avoir joué.

Si la configuration est ainsi grandement simplifiée, il ne faut pas chercher à l’éclipser pour autant. S’il vous plait, ne créez pas des classes pour le plaisir de bénéficier de l’autowiring. Utilisez-le à bon escient : l’autowiring ne devrait pas influencer le design de vos classes. C’est toujours à la configuration de s’adapter au code, jamais l’inverse.

Vos conventions ne regardent que vous

    _defaults:

Cette directive permet de définir des conventions par défaut. Il est très important de retenir que sa portée est locale au fichier où elle est présente.

En aucun cas définir autowire: true ne va activer l’autowiring au sein des autres fichiers de configuration (pas même de ceux importés depuis ce fichier, le cas échéant).

Définir vos conventions particulières

Maintenant que vous savez comment fonctione l’autowiring, sachant par ailleurs que chaque classe dans src/ va créer un service du même nom, vous savez qu’il est trivial de référencer vos services entre eux : il suffit de typer vos arguments pour récupérer le service du même nom que le type ajouté.

Rien ne vous choque ? Typer une concrétion (une “classe”) est une pratique déconseillée par le principe d’inversion de contrôle (le “I” de “SOLID”). À la place, la bonne pratique est de typer des abstractions (des interfaces en PHP).

Comment faire puisque seules les classes sont chargées en tant que services ?

Il suffit d’appliquer la règle de l’autowiring : créer un service du même nom que le type donné. Cela signifie généralement définir un alias :

services:
    App\Wow\MaBelleInterface: '@App\Wow\MonBeauService'

Nous venons de définir une nouvelle convention : pour chaque argument typant MaBelleInterface, le service MonBeauService sera injecté par défaut. Si pour un service particulier cela ne convenait pas, il faudra alors avoir recours à la configuration explicite classique.

Pour nous simplifier la vie un peu plus, si votre application définit une interface donnée dans src/, et qu'une et une seule implémentation de cette interface est trouvée dans src/ toujours, alors l'alias sera créé automatiquement par le framework.

Il est également possible de définir partiellement un service, en laissant à l’autowiring le soin de compléter :

services:
    App\Wow\MonBeauService:
        arguments:
            $defaultLocale: '%kernel.default_locale%'

Cette configuration, bien que particulière à un service, permet de définir la valeur d’un argument du constructeur en le nommant. Les éventuels autres arguments peuvent être laissés à l’autowiring.

Si vous souhaitez généraliser cette règle et la transformer en convention, vous pouvez le faire ainsi :

services:
    _defaults:
        bind:
            $defaultLocale: '%kernel.default_locale%'

Désormais, pour tous les services définis dans le fichier courant, si un argument porte le nom $defaultLocale, sa valeur sera injectée comme spécifié.

Il est possible de référencer les arguments aussi bien par leur nom que par leur type :

services:
    _defaults:
        bind:
            App\Wow\MaBelleInterface: '@App\Wow\MonBeauService'

Cette convention injectera “MonBeauService” pour tous les arguments typant “MaBelleInterface”.

Quel différence avec l’alias précédement défini ? Sa portée ! Un alias est global. Il est capable de remplacer un service existant, même défini par le framework ou un bundle tiers. À l’inverse, un “binding” est local. Il ne s’applique que localement aux services du fichier de configuration actuel.

Auto-configuration et collections de services typés

Si le framework ou les bundles peuvent lier tags et interfaces, cette possibilité ne leur est pas réservée :

services:
    _instanceof:
        App\Wow\MaBelleInterface:
            tags: [wow]

Ainsi, chaque service défini localement et implémentant ”MaBelleInterface” se verra attribuer le tag “wow”.

À quoi cela peut-il servir ? Un cas classique est de créer une collection de services (exemples typiques pour le framework : une collection de voteurs, de commandes, etc.).

Avant Symfony 4, pour récupérer la liste de tous les services taggués “wow”, il fallait créer une passe de compilation. Ça n’est plus nécessaire pour les cas simples :

services:
    App\WowManager:
        arguments:
            $wowServices: !tagged wow

Si vous combinez ces lignes avec les précédentes, nous venons d’injecter la liste de tous les services qui implémentent “MaBelleInterface” dans “WowManager”. Wow !

Techniquement, l’argument $wowServices doit être typé iterable. Il recevra un itérateur, et les services seront créés au fur et à mesure du parcours de cet itérateur.

Injecter et transformer des variables d’environnement

Depuis la version 3.2, vous savez qu’il est possible de référencer des variables d’environnement :

parameters:
    my_api_key: '%env(API_KEY)%'

Le manifeste 12-factor en fait la promotion. En pratique pour Symfony, référencer une variable d’environnement permet de rendre l’application reconfigurable à la volée : un changement de clef d’API par exemple sera pris en compte immédiatement, sans recompilation du container.

Pourtant, les variables d’environnement ne sont pas recommandées pour stocker des secrets. Elles fuitent trop facilement. À la place, il est préférable de mettre vos clefs confidentielles dans le système de fichier (éventuellement virtuel), et donc de lire ces fichiers pour accéder aux valeurs.

parameters:
    my_api_key: '%env(file:API_KEY_FILE)%'

Avec Symfony 4, il est possible de transformer des variables d’environnement. Les processeurs disponibles par défaut sont file, json, base64, resolve, const, bool, float, int et string. Il est possible de les chaîner :

%env(json:file:API_KEY_FILE)% va lire le fichier spécifié par la variable API_KEY_FILE de l’environnement, puis appeler json_decode() sur son contenu.

À utiliser lorsque nécessaire, mais pas à toutes les sauces : les paramètres classiques sont toujours préconisés pour la configuration liée au comportement de l’application (par opposition à la configuration liée à l’infrastructure donc).

Et les performances ? Conclusion

Une caractéristique essentielle des applications Symfony est qu’elles sont compilées : la configuration une fois résolue génère une classe PHP, le container. C’est lui qui est ensuite utilisé en production, et recompilé automatiquement au besoin en dev.

Vous n’avez donc aucun soucis à vous faire pour les performances, la core-team a bossé d’arrache-pied pour l’améliorer encore et toujours. Je vous invite à consulter le blog symfony.com pour plus de détails à ce sujet.

Au delà de la configuration, l’injecteur de dépendances permet encore de belles choses. En vrac :

  • injecter des services en argument des actions de vos controleurs
  • injecter des “service locators” dédiés via ServiceSubscriberInterface
  • recycler vos services pour plusieurs requêtes HTTP de suite grâce au tag kernel.reset
  • configurer vos services en PHP plutôt qu’en Yaml
  • etc.

En espérant que tout cela vous plaira !