Makina Blog
Processus de traitement d'une requête HTTP par Symfony
Vous utilisez Symfony pour vos développements mais vous n'en connaissez pas vraiment le fonctionnement interne ? Suivez le guide, c'est parti pour la visite !
Introduction
En tant qu'utilisateur d'un outil, nous ne prenons pas toujours le temps de comprendre comment il fonctionne. On sait interagir avec lui pour obtenir un résultat, mais de sa mécanique interne nous n'en avons finalement qu'une vague idée, voire nous en ignorons tout.
Je vous propose dans cet article d'explorer les rouages de Symfony et plus précisément ceux constituant son cœur de métier, à savoir réceptionner une requête HTTP et y répondre. Nous y parcourons, étape par étape, son processus de traitement d'une requête HTTP. Chaque étape sera numérotée et illustrée d'un schéma. Ainsi, les chiffres apparaissant dans les schémas servent à numéroter l'étape en cours ou à faire référence à une autre étape.
Le schéma complet et annoté du processus est disponible ici.
Le processus dans les grandes lignes
Avant d'entrer dans les détails, appréhendons d'abord le processus dans ses grandes lignes.
Au commencement, un internaute tape l'URL de notre application dans la barre d'adresse de son navigateur et presse la touche « Entrée » de son clavier. Le navigateur génère alors la requête HTTP appropriée et l'envoie sur le réseau à destination de notre serveur web. L'application serveur HTTP en service sur notre machine (Apache, Nginx, etc.) réceptionne la requête et, parce que nous l'avons configurée ainsi, sollicite PHP pour qu'il exécute le fichier public/index.php de notre application.
Voilà, nous venons de franchir le seuil de notre application, nous sommes dans le fichier sus-cité que l'on nomme le front controller. Il est le point d'entrée unique (en contexte HTTP) de notre application. Quelle que soit la page ou l'opération demandée par l'internaute, c'est toujours ce même fichier que PHP exécutera pour démarrer l'application.
Le front controller poursuit alors le processus en instanciant le « kernel », c'est-à-dire le noyau de l'application. Ce dernier va ensuite prendre en charge la requête afin qu'il en résulte une réponse. Il devra pour cela déterminer puis invoquer le controller destiné à construire cette réponse.
Le lancement de l'application
Étape 1 : le front controller
Si nous ouvrons le fichier public/index.php de notre projet, voici ce que nous découvrons :
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
Un léger désarroi pourrait alors vous envahir puisque rien dans ces quelques lignes de code n'évoque le lancement d'un processus… L'instant de stupéfaction écoulé, on présume alors que la magie doit se trouver dans le fichier vendor/autoload_runtime.php. Toujours est-il qu'à l'ouverture de celui-ci, la lumière n'en jaillit pas instantanément…
Depuis la version 5.3 de Symfony, le front controller ne fait lui-même plus grand chose (v5.2 vs v5.3). Il s'appuie désormais en grande partie sur le composant symfony/runtime qui est à l'origine du fichier vendor/autoload_runtime.php (généré automatiquement par son plugin Composer). C'est à ce fichier que la fonction anonyme retournée par le front controller est justement destinée.
Au sein du fichier autoload_runtime.php, un objet que l'on appellera simplement le runtime est instancié pour prendre en charge la fonction anonyme en provenance du front controller. (Par défaut, ce runtime est une instance de la classe Symfony\Component\Runtime\SymfonyRuntime.) Cette « prise en charge » va consister à :
- résoudre les arguments attendus par la fonction,
- appeler la fonction avec les arguments résolus pour récupérer en retour une « application »,
- déterminer le runner (lanceur) capable de lancer l'application,
- lancer l'application via le runner pour réaliser l'opération souhaitée par l'utilisateur.
Le but de la fonction anonyme retournée par le front controller est donc de fournir une « application » au runtime qu'il se chargera de lancer.
Dans le cas d'une application Symfony classique, la fonction anonyme du front controller retourne une instance de la classe App\Kernel que le runtime lancera ensuite à l'aide d'un runner de type HttpKernelRunner.
Si vous souhaitez en savoir plus sur le sujet, je vous invite à lire la documentation du composant Runtime.
Étape 2 : le kernel, ou plutôt les kernels…
Sur un projet fraîchement initialisé, le premier et unique fichier PHP que vous trouverez à la racine du dossier src/ est le fichier Kernel.php contenant la classe App\Kernel précédemment mentionnée :
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
}
Bon, là non plus le code n'est pas très bavard, mais c'est normal puisque notre classe Kernel s'appuie totalement sur les implémentations par défaut suivantes :
- La classe abstraite Kernel fournie par le composant HttpKernel
- Le trait MicroKernelTrait fourni par le FrameworkBundle
De ces implémentations découlent la structure et les mécaniques centrales habituelles d'un projet Symfony. Cela signifie donc que si nous souhaitions modifier cette structure et/ou ces mécaniques, il se pourrait bien que nous ayons à surcharger certaines de leurs méthodes au sein de notre classe Kernel, voire à tout réimplémenter.
Cette classe App\Kernel, matérialisant le kernel de notre application, peut être vu comme la fusion de deux autres : le kernel Symfony et le kernel HTTP. C'est ce dernier qui va particulièrement nous intéresser dans le cadre de cet article. Il est le noyau du « moteur HTTP » ayant pour rôle de répondre à la requête reçue. Quant au kernel Symfony, sur lequel nous ne nous étendrons pas outre mesure, celui-ci consiste à initialiser les systèmes de base propres au framework, comme le service container ou les bundles. Le kernel HTTP lui-même fera usage de ces systèmes pour produire sa réponse.
Concentrons-nous donc sur la notion de kernel HTTP définie techniquement par le contrat de l'interface Symfony\Component\HttpKernel\HttpKernelInterface :
namespace Symfony\Component\HttpKernel;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface HttpKernelInterface
{
public const MAIN_REQUEST = 1;
public const SUB_REQUEST = 2;
public function handle(
Request $request,
int $type = self::MAIN_REQUEST,
bool $catch = true
): Response;
}
(Les commentaires ont été retirés du code pour obtenir un aperçu concis.)
Il existe par défaut trois implémentations du kernel HTTP :
- Symfony\Component\HttpKernel\Kernel, déjà évoquée plus haut du fait que le kernel de l'application (App\Kernel) en hérite directement
- Symfony\Component\HttpKernel\HttpCache\HttpCache, qui ajoute un cache optionnel au processus
- Symfony\Component\HttpKernel\HttpKernel, qui se chargera concrètement de produire la réponse
La première implémentation s'appuie essentiellement sur les deux autres : quand le cache HTTP est activé (via la configuration du FrameworkBundle) elle fait appel à la seconde, HttpCache, sinon elle fait directement appel à la troisième, HttpKernel. L'implémentation HttpCache quant à elle n'est capable de restituer une réponse que lorsque celle-ci a déjà été produite et stockée en cache, elle sollicite donc l'implémentation HttpKernel pour produire toute réponse qu'elle ne retrouve pas en cache.
Bref, vous l'aurez compris, la suite du processus pour aboutir à une réponse va se dérouler dans la classe HttpKernel.
Le traitement de la requête
Nous sommes à présent rendu dans le kernel HTTP où nous allons enfin pouvoir aborder le vif du sujet. À partir d'ici, le processus va se concentrer sur l'objectif d'obtenir une réponse. Bien entendu, ce n'est pas le kernel HTTP lui-même qui va construire cette réponse, il va pour cela faire appel aux autres systèmes de l'application. Un nouveau composant Symfony entre alors notamment en jeu : le composant EventDispatcher. Grâce à ce dernier, le kernel émettra des évènements que les divers systèmes pourront écouter dans le but d'intervenir à des moments clés du processus.
L'ensemble de ces événements sont implémentés au sein du composant HttpKernel sous forme de classes étendant toutes la classe parente KernelEvent.
Étape 3 : annonce de la requête et identification de la route
Le premier évènement émis par le kernel est le RequestEvent. Il annonce la réception de la requête, donnant ainsi l'opportunité aux systèmes annexes de la modifier, voire de fournir une réponse précoce au kernel pour des cas de figure ne nécessitant pas de poursuivre le traitement.
Une première étape importante dans la quête d'une réponse est réalisée à cette occasion par le RouterListener : il s'agit de l'identification de la route correspondant à la requête. Une fois identifiée, les informations de la route sont alors ajoutées à la requête sous forme d'attributs de façon à être disponibles dans la suite du processus. Lorsqu'aucune route n'a été identifiée, une exception est lancée pour interrompre le processus. Nous verrons comment sont gérés les cas d'erreur plus loin dans l'article.
Dans le cas où un listener venait à transmettre une réponse au kernel via l'évènement en question, toutes les étapes à suivre précédant l'étape 8 seraient alors sautées.
Étape 4 : résolution du controller
Parmi les informations relatives à la route dont la requête est désormais porteuse, se trouve l'attribut _controller, ayant pour valeur une référence du controller à invoquer pour générer la réponse. Si vous utilisez le format YAML pour déclarer vos routes, cette valeur n'est autre que celle que vous avez vous-même renseignée dans votre définition de la route, par exemple :
user_login:
path: /login
controller: App\Controller\UserController::login # <- Cette valeur ci.
C'est également une chaîne au format "class_name::method_name" qui est utilisée quand la route est déclarée à l'aide de l'attribut PHP #[Route]. Mais d'autres types de valeur sont possibles : chaîne contenant le nom d'une fonction, closure ou encore tableau (respectant le format suivant : ['class_name', 'method_name']).
Toutes ces considérations ne sont cependant pas du ressort du kernel HTTP qui orchestre le processus sans en connaître les détails de l'implémentation. C'est le controller resolver, auquel il fait appel suite au RequestEvent, qui va récupérer la valeur de l'attribut _controller depuis la requête pour la transformer (si besoin) en un callable que le kernel pourra invoquer.
Si le controller resolver ne parvient pas à produire un callable à partir de l'attribut, une exception NotFoundHttpException est lancée. Nous ne citerons pas toutes les exceptions qui pourraient potentiellement survenir durant le processus. Vous aurez compris l'idée : à tout moment il peut être interrompu par une exception qui sera alors prise en charge par un système secondaire que nous détaillerons par la suite.
Suite à la résolution du controller, un deuxième évènement, le ControllerEvent, est lancé. Il est l'occasion de réaliser des initialisations en vue des opérations à suivre, voire de remplacer complètement le controller.
Étape 5 : résolution des arguments
Nous disposons donc maintenant de notre controller sous la forme adéquate d'un callable, mais pour l'invoquer, celui-ci va certainement nécessiter que nous lui passions certains arguments. Arguments qui lui seront utiles pour construire la réponse.
C'est alors au tour de l'argument resolver d'être sollicité par le kernel afin de déterminer et de collecter l'ensemble des valeurs attendues par le controller en tant qu'arguments. En arrière plan, l'argument resolver s'appuie sur les multiples implémentations de l'interface ValueResolverInterface dédiées à cet effet. Les arguments auxquels on peut faire appel à l'écriture d'un controller dépendent donc directement de ces implémentations. Parmi elles, certaines vont par exemple permettre de récupérer des services depuis le conteneur de dépendances, d'autres d'obtenir des données contenues dans la requête (implémentations liées aux attributs #[MapQueryParameter], #[MapQueryString] et #[MapRequestPayload]).
Un fois l'ensemble des arguments résolu, l'évènement ControllerArgumentsEvent est propagé. On peut s'y abonner afin d'intervenir sur les arguments ou, comme avec le précédent, réaliser des opérations préparatoires pour la suite du processus, voire remplacer le controller.
Étape 6 : invocation du controller
Le kernel est désormais en capacité d'invoquer le controller, l'instant fatidique de lui faire générer la réponse est enfin arrivé. Le processus se poursuit alors avec cette ligne on ne peut plus parlante :
$response = $controller(...$arguments);
Suite à cet appel du controller, nous devrions théoriquement être en possession d'une réponse. Néanmoins, cette réponse pourrait ne pas être une instance de la classe Symfony\Component\HttpFoundation\Response, le kernel laissant le controller libre de retourner n'importe quelle sorte de donnée. En ce cas, un évènement ViewEvent est lancé dans l'objectif qu'un listener transforme la réponse obtenue en une instance de la classe Response.
On pourrait alors imaginer que le controller puisse retourner directement le markup d'une page sous la forme d'une chaîne, ou même une entité Doctrine, et que des listeners soient chargés, à partir de telles données, de produire la réponse finale.
Quelle que soit la réponse du controller, cette étape ne peut se terminer par une réponse autre qu'une instance de la classe Response, sans quoi une exception est jetée.
Étape 7 : cas d'erreur
Puisque la suite du processus sera la même, qu'une erreur soit survenue ou non durant les étapes précédentes, c'est le moment opportun pour jeter un œil à la tournure des choses en cas d'erreur.
Lorsqu'une exception est lancée au cours des étapes 3 à 6, le processus tel qu'il est décrit ci-dessus s'interrompt. L'exception est interceptée par le kernel qui déclenche alors un évènement ExceptionEvent dans le but de recueillir une réponse appropriée au cas d'erreur survenu.
Le ErrorListener présent dans le composant HttpKernel, abonné par défaut à l'évènement, est le point de départ du système de gestion des erreurs de Symfony. Ce système va consister à obtenir une réponse en faisant de nouveau appel au kernel HTTP. La requête alors transmise au kernel dans cette situation est une copie de la requête initiale, préconfigurée avec un controller dédié aux cas d'erreur (via l'attribut _controller), à laquelle est également ajoutée l'exception capturée.
Le scénario décrit tout au long de cet article est donc rejoué une seconde fois à partir de l'étape 3 en considérant cette copie. Mais cette fois-ci, le listener indiquera au kernel qu'il s'agit alors d'une « sous-requête » (voir HttpKernelInterface et sa constante SUB_REQUEST) afin de la distinguer de la requête initiale (MAIN_REQUEST), évitant ainsi que des opérations destinées uniquement à cette dernière ne soient exécutées.
Si suite à sa diffusion, l'évènement ExceptionEvent n'est pas porteur d'une réponse, le kernel émet au préalable l'évènement FinishRequestEvent que nous préciserons plus bas, puis il relance l'exception afin de stopper net l'application.
Dans le cas contraire, le processus continue à l'étape suivante, exactement comme il en eut été si aucune exception n'avait été jetée.
Étape 8 : derniers préparatifs avant envoi
Nous avons à ce stade obtenu notre réponse. Mais avant qu'elle soit transmise à l'internaute, le kernel lance l'évènement ResponseEvent offrant la possibilité de la modifier, en y ajoutant par exemple des entêtes ou des cookies. Pour ne citer qu'eux, les entêtes relatifs au cache spécifiés via l'attribut PHP #[Cache] au sein du controller sont ajoutés à la réponse lors de cet évènement.
Le kernel enchaîne ensuite avec l'émission de l'évènement FinishRequestEvent, destiné notamment à la réinitialisation ou la restauration de certains états potentiellement modifiés pendant le traitement de la requête.
Son travail prend alors fin tandis qu'il restitue sa réponse au runner.
Étape 9 : envoi de la réponse
De retour au sein du runner après que le kernel ait accompli sa tâche, l'envoi de la réponse est effectué directement depuis l'instance de la classe Response issue du kernel par un simple appel à sa méthode send().
Avant que l'application n'arrive à son terme, le runner propage un dernier évènement, le TerminateEvent, moment adéquat pour effectuer des traitements complémentaires qui, réalisés plus tôt, auraient retardé la réponse.
The end
Notre visite guidée au cœur de Symfony est terminée. J'espère qu'elle aura su vous éclairer quant au fonctionnement de son processus central :-)
Merci à Excalidraw, grâce auquel j'ai pu réaliser l'ensemble des schémas qui nous ont guidés tout au long de l'article.
Pour rappel, le schéma complet et annoté est disponible ici.
Formations associées
Formations Outils et bases de données
Formation Git, gestionnaire de versions
Toulouse Du 19 au 20 novembre 2024
Voir la formationFormations Outils et bases de données
Formation PostgreSQL
Nantes Du 11 au 13 décembre 2024
Voir la formationActualités en lien
DbToolsBundle : sortie de la version 1.2
Découvrez les nouveautés de cette nouvelle version ainsi que les fonctionnalités à venir de la prochaine version majeure.
Access Control, bibliothèque PHP pour gérer des droits d’accès
Suite à un projet de gestion métier opérationnel dont la durée de vie et la maintenance sont à long termes, nous avons expérimenté un passage de celui-ci sur l’architecture hexagonale et la clean architecture.
Créer une application Symfony/Vue.js
Pour faire des essais ou bien démarrer un nouveau projet, vous avez besoin de créer rapidement une application Symfony couplée avec un front Vue.js ? Suivez le guide !