Symfony : ouvrons le capot

Dans cet article, je me propose de parcourir avec vous, un endroit souvent méconnu et pourtant indispensable : les mécanismes de démarrage et de traitement d’une requête HTTP par le framework.

Note : cet article est tiré d’une formation interne, donnée il y a plus de deux ans. La grande majorité des informations contenues dans cet article est restée d’actualités, il est cependant possible que certains mécanismes aient légèrement changés de forme. J’ai choisi de le publier ici, sous sa forme originale, afin de faire profiter le lecteur d’un travail sur les mécanismes internes de résolution d’une requête.

Composants centraux de Symfony

Les dépendances et l’autoloading

Les dépendances au sein d’un projet Symfony sont gérées par composer. Elles peuvent être explicites (déclarées dans le fichier composer.json) ou induites (requises par des composants nécessaires au projet). La résolution des dépendances induites est automatique sauf dans le cas ou vous spécifiez des dépendances personnelles (déclarée à l’aide d’une URI vers un dépôt de source et non via packagist) dans ce cas précis vous devez expliciter manuellement toutes les dépendances.

Notez que composer dispose d’une option trop peu connue pour accélérer la phase d’autoloading en production, il s’agit de la commande : 
composer dump-autoload --optimize.

Cette fonction va créer un fichier de mapping des classes présentes au sein de votre projet évitant ainsi le très coûteux appel à file_exists.

De plus si vous souhaitez installer un projet en production vous n’avez pas besoin des dépendances listées dans la section require-dev du fichier composer.json. Pour évitez que ces dépendances ne soient aussi installées vous pouvez utiliser l’option --no-dev lors de l’appel à la commande composer install ou composer update .

Une note sur la façon dont sont gérées les dépendances, beaucoup de projets utilisent la notion de bridge pour leurs Bundle Symfony. Le bridge permet d’exposer l’API d’une bibliothèque PHP au sein de Symfony. Plus spécifiquement, le bridge fait descendre la configuration définie au sein de Symfony dans la bibliothèque et expose au framework l’API de la bibliothèque à travers des services SF. Ainsi, si vous cherchez le code lié à une bibliothèque faites attention à bien avoir cette distinction en tête.

Enfin, Composer supporte les normes : PSR-0 et PSR-4 concernant l’autoloading, la norme PSR-4 permet d’éviter d’avoir une arborescence trop profonde lors du chargement des vendors.


Configuration

Composant Config

La configuration au sein du framework est gérée par le composant Config. Ce composant est chargé de trouver des fichiers de configuration les parser et les fournir à l’environnement Symfony. Il est capable de parser les formats Yaml, XML, INI ou de récupérer les valeurs de configuration au sein d’une base de données.

Le service de configuration utilise un FileLocator si le fichier de configuration se trouve au sein du filesystème. Il reçoit une collection de chemins à explorer pour chercher un nom de fichier fourni en paramètre. Il peut retourner soit le premier fichier trouvé, soit l’ensemble des fichiers correspondant au nom de fichier fourni.

En fonction du type de document (yml, ini, xml, …), un FileLoader spécifique est utilisé. Il est chargé de transformer le fichier en une représentation PHP objet.

Enfin un système de cache (ConfigCache) peut être activé pour stocker la configuration au sein du cache et éviter les phases de localisation et de chargement qui sont très coûteuses en temps. En mode debug un fichier .meta est créé à côté du fichier de cache, qui permet d’invalider le cache en cas de modification des valeurs cachées. En production, le cache n’est jamais invalidé sauf ordre explicite via la commande cache:clear.

Le système de cache dispose d’un système de validation des données de configuration : le TreeBuilder permet de définir une hiérarchie, expliciter les types, définir les valeurs par défauts, poser des contraintes, etc. sur les valeurs de configuration à charger.

La notion d’environnement au sein de Symfony

La configuration générale d’une application symfony dépend des environnements chaque environnement définit l’exécution de la même base de code, mais avec une configuration différente (et uniquement une configuration différente). Par défaut les environnements sont au nombre de trois (dev, test, prod), mais il est possible d’en définir de nouveau (par exemple par client, ce que nous utilisons pour faire du multitenant).

Par défaut les fichiers de configurations se trouvent dans le répertoire app/config il est possible de modifier la classe AppKernel pour changer la localisation de la configuration : il suffit de redéfinir la méthode registerContainerConfiguration :

public function registerContainerConfiguration(
LoaderInterface $loader
)
{
$loader->load(
__DIR__.’/config/config_’.$this->getEnvironment().’.yml’
);
}

Les environnements sont déclarés explicitement dans les fichiers web/app.php et web/app_dev.php lors de l’instanciation de AppKernel.

Attention la notion de mode debug est distincte de la notion d’environnement, elle est définie lors de l’instanciation de AppKernel comme deuxième argument du constructeur :

//active debug mode in prod environment
$kernel = new AppCache(new AppKernel(‘prod’, true));

Les environnements modifient aussi l’emplacement des caches, par défaut un répertoire de cache est créé par environnement au sein du répertoire : app/cache/{environment}. Vous pouvez modifier ce comportement en surchargeant la méthode getCacheDir du AppKernel (le fonctionnement est similaire pour les fichiers de logs) :

public function getCacheDir()
{
return $this->rootDir.’/’.$this->environment.’/cache/’;
}
public function getLogDir()
{
return $this->rootDir.’/’.$this->environment.’/logs/’;
}

Note : concernant les caches, il est possible de les supprimer à la main à l’aide d’une simple suppression du contenu des répertoires correspondants. Vous êtes cependant invités à utiliser la commande :
cache:clear --env={environment}
En effet, lors du premier appel si les caches sont vides, la ressource demandée (page) peut mettre un certain temps à apparaître : l’application doit régénérer l’ensemble de sa configuration (config + routes + traduction + entité + services + …) ce qui prend un certain temps. 
Lors de l’appel à la commande cache:clear, Symfony déclenche un mécanisme appelé warmup, qui consiste à régénérer une grande partie des caches, pour éviter de faire subir cette latence à un visiteur de votre site. Vous pouvez déclencher cette opération manuellement en appelant la commande cache:warmup.

Le chargement des valeurs de configuration

Comme nous venons de le voir, les valeurs de configurations sont spécifiques à un environnement. Cependant une partie de cette configuration est commune aux différents environnements : elle est stockée au sein du fichier app/config/config.yml.

Le fichier config.yml contenant la configuration commune aux environnements n’est pas directement appelé par le framework, mais importé par les différents fichiers de configuration à l’aide de la notation :

imports:
— { resource: config.yml }

Ce mécanisme est aussi utilisé pour le fichier parameters.ini que l’on trouve dans la distribution standard, il est simplement importé. Il faut savoir que toute variable de configuration devient disponible sous la forme %node.key% au sein d’un fichier de configuration.

Vous pouvez aussi définir des valeurs de configuration externe à votre projet en utilisant les variables d’environnements. En effet toute variable d’environnement préfixée par SYMFONY__ est automatiquement convertie en paramètre disponible par le conteneur de service.

Par exemple, les variables d’environnements peuvent être définir au sein de la configuration virtualHost d’apache :

<VirtualHost *:80>
DocumentRoot “/path/to/symfony_2_app/web”
SetEnv SYMFONY__DATABASE__USER user
#…

N’oubliez pas que si votre système utilise les variables d’environnements vous devez aussi les déclarer au sein de votre shell pour utiliser la console symfony.

Vous pouvez alors les utiliser au sein de vos fichiers de configuration : user: “%database.user%".


Routage

Le routeur

Le routage est géré au sein de Symfony par le composant Routing. Le routage est séparé en trois parties :

  • Une RouteCollection qui contient l’ensemble des routes
  • Un RequestContext contenant le contexte de la requête
  • Un UrlMatcher qui fait correspondre la requête à une route (et non à un contrôleur)

Une route dans Symfony contient 7 parties (dont certaines optionnelles) :

  • Un pattern d’URL (pouvant contenir des zones variables)
  • Un tableau de valeur par défaut envoyée avec la requête si la route est atteinte
  • Un tableau de contraintes sur les valeurs variables de l’URL
  • Un tableau d’options peut contenir des paramètres internes pour la route
  • Un hôte permet d’atteindre une route en fonction de l’hôte d’appel
  • Un tableau de schéma HTTP (http, https)
  • Un tableau de méthode HTTP (get, post, head, put, …)

Vous pouvez attacher une collection de routes à une autre route de façon récursive en spécifiant un préfixe. De cette façon si le préfixe est atteint, la résolution de la route se fait dans la collection liée.

Le chargement des routes depuis un fichier est effectué par le composant de configuration et supporte donc l’ensemble des formats acceptés par celui-ci. En plus de la définition de routes au sein d’un fichier, vous pouvez également les définir à l’aide d’annotation placée au sein de vos contrôleurs.

La définition des routes au sein de Symfony2

Contrairement à ce que l’on pourrait penser, le système de routing n’importe pas automatiquement de nouvelle collection de routes (par exemple venant d’un Bundle) tout est explicitement importé depuis le fichier utilisé par défaut : app/config.yml (dans le cas de la distribution standard).

Les routes sont résolues dans l’ordre cela signifie que la première route qui correspond au pattern de l’URL est utilisée. Faites donc très attention à l’ordre dans lequel les routes sont chargées (et importées).

Pour des raisons de performances, il est possible de confier le routing à apache à l’aide du mod_rewrite. Pour cela appelez la commande router:dump-apache -e=prod --no-debug et placez le résultat au sein de votre fichier .htaccess.

Si vous laissez apache se charger du routage, vous pouvez encore accélérer la phase d’analyse de la requête en modifiant le fichier web/app.php pour lui faire utiliser les ApacheRequest au lieu de Request :

//…
use Symfony\Component\HttpFoundation\ApacheRequest;
$kernel = new AppKernel(‘prod’, false);
//…
$kernel->handle(ApacheRequest::createFromGlobals())->send();

Contrôleur

La résolution du contrôleur est effectuée par le composant ControllerResolver lui-même appelé par le HTTPKernel (qui est chargé de gérer les I/O avec le client HTTP). 
Le ControllerResolver va rechercher la clef _controller au sein de la propriété attributes de l’objet Request.
La clef _controller est placée au sein de l’objet Request par le RouterListener.

La chaîne de caractère contenu dans la clef _controller est convertie selon les conventions de nommages de Symfony : AcmeDemoBundle:Default:index devenant : Acme\DemoBundle\Controller\DefaultController::indexAction.

Enfin si le contrôleur implémente l’interface ContainerAwareInteface, sa méthode SetContainer est appelée avec le conteneur de service en argument.

Arguments de requêtes

Lors de l’appel de la méthode action correspondante à la ressource demandée, une introspection par réflexion est effectuée sur ses paramètres :
Si un paramètre a le même nom qu’une clef du conteneur d’attributs de l’objet Request (Attributes bag) il est passé comme argument à la méthode. Vous noterez que le conteneur d’attribut contient les attributs de la requête HTTP, mais contient aussi des informations complémentaires ajoutées par les systèmes de résolution précédents.

Si la méthode attend un objet de type Request alors l’objet Request lui est passé en direct.

Exemple :
//the current Request objet is given to the function
public function indexAction(Request $request) {}
//if a name attribute exist in the Request’s parameters bag his value is given to the method
public function indexAction($name)

Views

Les vues peuvent être construites par l’action du contrôleur ou, si l’action ne retourne pas d’objet Response, lors de l’appel à l’événement kernel.view (par exemple le SensioFrameworkExtraBundle ajoute un écouteur sur cet événement afin de résoudre une vue déclarée en annotation.
Le FosRestBundle est aussi branché dessus pour construire les différents types de réponses : html, json, xml, … ).

Exceptions

En plus de la gestion classique de la résolution des Exceptions Symfony intègre une stack de résolution complémentaire :

Toute exception non capturée pendant la phase de traitement au sein du contrôleur (HttpKernel::handle) est capturée par le HttpKernelqui va alors déclencher un événement kernel.exception.

Au sein de Symfony deux principaux systèmes écoutent les exceptions :

  • Le HttpKernel\EventListener\ExceptionListener il se charge de transformer l’exception en un retour lisible par le client (par la création d’un objet FlattenException). Si l’exception implémente la HttpExceptionInterface alors l’exception sert à construire un retour avec un code d’erreur Http spécifique (récupéré à l’aide de la méthode getStatusCode de l’exception).
  • Le Symfony\Component\Security\Http\Firewall\ExceptionListener est chargé de gérer les exceptions de sécurité (tentative d’accès à des zones non autorisés). C’est lui aussi qui se charge de rediriger l’utilisateur vers la page de login si nécessaire.

Traduction

Le composant de traduction est chargé d’adapter la langue affichée à l’utilisateur en fonction d’une variable de localisation.

Attention : Il ne faut pas confondre le système de traduction avec le système de localisation bien que ces deux systèmes fonctionnent de concert. En effet le système de traduction propose la même interface en plusieurs langues quand le système de localisation adapte les affichages en fonction des coutumes comme le format de date, la monnaie, l’affichage des chiffres, etc.

La locale (la langue) est définie au sein de l’objet Request défini par l’attribut _locale, il vous est possible de la définir au sein du système de routing.

La résolution des traductions se fait à partir de la locale de l’utilisateur (fr\_FR si vous êtes français, fr\_BE si vous êtes belge…) si celle-ci n’a pas de fichier de traduction correspondant le système tente de trouver une traduction dans la langue correspondante (les valeurs fr\_FR et fr\_BE deviennent fr). Enfin si la langue n’est pas trouvée, le système passe sur la locale par défaut stocké au sein du paramètre translator: {fallback: en} du fichier config.yml.

Les fichiers de traductions doivent avoir la forme messages.{langue}.yml (ex: messages.fr.yml). Le terme de messages peut être modifié, mais il ne s’agit pas du sujet de cette présentation. Ils peuvent être placés aux emplacements :

  • Resources/translations
  • Resources/{BundleName}/translations
  • Resources/translations au sein d’un Bundle spécifique

Ces fichiers sont bien évidemment fusionnés dans cet ordre, ainsi une clé de traduction placée dans Resources/translations écrasera la même clé si elle existe dans Resources/{BundleName}/translation et ainsi de suite.

Encore une fois le système de traduction se base sur le système de gestion de configuration de Symfony et notamment la LoaderInterface ainsi vous pouvez définit vos traductions en XML, Yml ou PHP.

Diagramme de résolution d’une requête HTTP au sein du framework Symfony, licence Creative Commons BY-SA 3.0 publié initialement sur le site de Symfony par SensioLabs.

J’espère que cet article vous permettra d’éclaircir certains points de la résolution d’une requête HTTP par Symfony.
Si vous l’avez apprécié, n’hésitez pas à le partager !

Vous pouvez me retrouver sur Twitter.