Licence CC BY-NC-SA

Le gestionnaire d'évènements de Symfony2

N'avez-vous jamais rêvé d'exécuter un certain code à chaque page consultée de votre site internet ? D'enregistrer chaque connexion des utilisateurs dans une base de données ? De modifier la réponse retournée au visiteur selon certains critères ? Eh bien, les développeurs de Symfony2 ne se sont pas contentés d'en rêver, ils l'ont fait !

En effet, comment réaliseriez-vous les cas précédents ? Avec un if sauvagement placé au fin fond d'un fichier ? Allons, un peu de sérieux, avec Symfony2 on va passer à la vitesse supérieure, et utiliser ce qu'on appelle le gestionnaire d'évènements.

Des évènements ? Pour quoi faire ?

Qu'est-ce qu'un évènement ?

Un évènement correspond à un moment clé dans l'exécution d'une page. Il en existe plusieurs, par exemple l'évènement kernel.request qui est déclenché avant que le contrôleur ne soit exécuté. Cet évènement est déclenché à chaque page, mais il en existe d'autres qui ne le sont que lors d'actions particulières, par exemple l'évènement security.interactive_login, qui correspond à l'identification d'un utilisateur.

Tous les évènements sont déclenchés à des endroits stratégiques de l'exécution d'une page Symfony2, et vont nous permettre de réaliser nos rêves de façon classe et surtout découplée.

Si vous avez déjà fait un peu de JavaScript, alors vous avez sûrement déjà traité des évènements. Par exemple, l'évènement onClick doit vous parler. Il s'agit d'un évènement qui est déclenché lorsque l’utilisateur clique quelque part, et on y associe une action (quelque chose à faire). Bien sûr, en PHP vous ne serez pas capables de détecter le clic utilisateur, c'est un langage serveur ! Mais l'idée est exactement la même.

Qu'est-ce que le gestionnaire d'évènements ?

J'ai parlé à l'instant de découplage. C'est la principale raison de l'utilisation d'un gestionnaire d'évènements ! Par code découplé, j'entends que celui qui écoute l'évènement ne dépend pas du tout de celui qui déclenche l'évènement. Je m'explique :

  • On parle de déclencher un évènement lorsqu'on signale au gestionnaire d'évènements : « Tel évènement vient de se produire, préviens tout le monde, s'il-te-plaît. » Pour reprendre l'exemple de l'évènement kernel.request, c'est, vous l'aurez deviné, le Kernel qui déclenche l'évènement.
  • On parle d'écouter un évènement lorsqu'on signale au gestionnaire d'évènements : « Je veux que tu me préviennes dès que tel évènement se produira, s'il-te-plaît. »

Ainsi, lorsqu'on écoute un évènement que le Kernel va déclencher, on ne touche pas au Kernel, on ne vient pas perturber son fonctionnement. On se contente d'exécuter du code de notre côté, en ne comptant que sur le déclenchement de l'évènement ; c'est le rôle du gestionnaire d'évènements de nous prévenir. Le code est donc totalement découplé, et en tant que bons développeurs, on aime ce genre de code !

Au niveau du vocabulaire, un service qui écoute un évènement s'appelle un listener (personne qui écoute, en français).

Pour bien comprendre le mécanisme, je vous propose un schéma sur la figure suivante montrant les deux étapes :

  • Dans un premier temps, des services se font connaître du gestionnaire d'évènements pour écouter tel ou tel évènement. Ils deviennent des listener ;
  • Dans un deuxième temps, quelqu'un (qui que ce soit) déclenche un évènement, c'est-à-dire qu'il prévient le gestionnaire d'évènements qu'un certain évènement vient de se produire. A partir de là, le gestionnaire d'évènement exécute chaque service qui s'est préalablement inscrit pour écouter cet évènement précis.

Fonctionnement du gestionnaire d'évènements

Certains d'entre vous auront peut-être reconnu le pattern Observer. En effet, le gestionnaire d'évènements de Symfony2 n'est qu'une implémentation de ce pattern.

Écouter les évènements

Notre exemple

Dans un premier temps, nous allons apprendre à écouter des évènements. Pour cela je vais me servir d'un exemple simple : l'ajout d'une bannière « bêta » sur notre site, qui est encore en bêta car nous n'avons pas fini son développement ! L'objectif est donc de modifier chaque page retournée au visiteur pour ajouter cette balise.

L'exemple est simple, mais vous montre déjà le code découplé qu'il est possible de faire. En effet, pour afficher un « bêta » sur chaque page, il suffirait d'ajouter un ou plusieurs petits if dans la vue. Mais ce ne serait pas très joli, et le jour où votre site passe en stable il ne faudra pas oublier de retirer l'ensemble de ces if, bref, il y a un risque. Avec la technique d'un listener unique, il suffira de désactiver celui-ci.

Créer un listener

La première chose à faire est de créer le listener, car c'est lui qui va contenir la logique de ce qu'on veut exécuter. Une fois cela fait, il ne restera plus qu'à exécuter son code aux moments que l'on souhaite.

Pour savoir où placer le listener dans notre bundle, il faut se poser la question suivante : « À quelle fonction répond mon listener ? » La réponse est « À définir la version bêta », on va donc placer le listener dans le répertoire Beta, tout simplement. Pour l'instant il sera tout seul dans ce répertoire, mais si plus tard on doit rajouter un autre fichier qui concernera la même fonction, on le mettra avec.

Je vous invite donc à créer cette classe :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<?php
// src/Sdz/BlogBundle/Beta/BetaListener.php

namespace Sdz\BlogBundle\Beta;
use Symfony\Component\HttpFoundation\Response;

class BetaListener
{
  // La date de fin de la version bêta :
  // - Avant cette date, on affichera un compte à rebours (J-3 par exemple)
  // - Après cette date, on n'affichera plus le « bêta »
  protected $dateFin;

  public function __construct($dateFin)
  {
    $this->dateFin = new \Datetime($dateFin);
  }

  // Méthode pour ajouter le « bêta » à une réponse
  protected function displayBeta(Response $response, $joursRestant)
  {
    $content = $response->getContent();

    // Code à rajouter
    $html = '<span style="color: red; font-size: 0.5em;"> - Beta J-'.(int) $joursRestant.' !</span>';

    // Insertion du code dans la page, dans le <h1> du header
    $content = preg_replace('#<h1>(.*?)</h1>#iU',
                            '<h1>$1'.$html.'</h1>',
                            $content,
                            1);

    // Modification du contenu dans la réponse
    $response->setContent($content);

    return $response;
  }
}

Pour l'instant, c'est une classe tout simple, qui n'écoute personne pour le moment. On dispose d'une méthode displayBeta prête à l'emploi pour modifier la réponse lorsqu'on la lui donnera.

Voici donc la base de tout listener : une classe capable de remplir une ou plusieurs fonctions. À nous ensuite d'exécuter la ou les méthodes aux moments opportuns.

Écouter un évènement

Vous le savez maintenant, pour que notre classe précédente écoute quelque chose, il faut la présenter au gestionnaire d'évènements. Il existe deux manières de le faire : manipuler directement le gestionnaire d'évènements, ou passer par les services.

Je ne vous cache pas qu'on utilisera très rarement la première méthode, mais je vais vous la présenter en premier, car elle permet de bien comprendre ce qu'il se passe dans la deuxième.

Vu que nous avons besoin de modifier la réponse retournée par les contrôleurs, nous allons écouter l'évènement kernel.response. Je vous dresse plus loin une liste complète des évènements avec les informations clés pour déterminer lequel écouter. Pour le moment, suivons notre exemple.

Méthode 1 : Manipuler directement le gestionnaire d'évènements

Cette première méthode, un peu brute, consiste à passer notre objet BetaListener au gestionnaire d'évènements. Ce gestionnaire existe en tant que service sous Symfony, il s'agit de l'EventDispatcher. Plus simple, ça n'existe pas. Concrètement, voici comment faire :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php
// Depuis un contrôleur

use Sdz\BlogBundle\Beta\BetaListener;

// …

// On instancie notre listener
$betaListener = new BetaListener('2013-08-19');

// On récupère le gestionnaire d'évènements, qui heureusement est un service !
$dispatcher = $this->get('event_dispatcher');

// On dit au gestionnaire d'exécuter la méthode onKernelResponse de notre listener
// Lorsque l'évènement kernel.response est déclenché
$dispatcher->addListener('kernel.response', array($betaListener, 'onKernelResponse'));

À partir de maintenant, dès que l'évènement kernel.response est déclenché, le gestionnaire d'évènements exécutera $betaListener->onKernelResponse(). En effet, cette méthode n'existe pas encore dans notre listener, on en reparle dans quelques instants.

Bien évidemment, avec cette méthode, le moment où vous exécutez ce code est important ! En effet, si vous prévenez le gestionnaire d'évènements après que l'évènement qui vous intéresse s'est produit, votre listener ne sera pas exécuté !

Méthode 2 : Définir son listener comme service

Comme je vous l'ai dit, c'est cette méthode qu'on utilisera 99 % du temps. Elle est beaucoup plus simple et permet d'éviter le problème d'évènement qui se produit avant l'enregistrement de votre listener dans le gestionnaire d'évènements.

Mettons en place cette méthode pas à pas. Tout d'abord, définissez votre listener en tant que service, comme ceci :

1
2
3
4
5
6
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdzblog.beta_listener:
        class: Sdz\BlogBundle\Beta\BetaListener
        arguments: ["2013-08-19"]

À partir de maintenant, votre listener est accessible via le conteneur de services. Pour aller plus loin, il faut définir le tag kernel.event_listener sur ce service. Le processus est le suivant : une fois le gestionnaire d'évènements instancié par le conteneur de services, il va récupérer tous les services qui ont ce tag, et exécuter le code de la méthode 1 qu'on vient de voir afin d'enregistrer les listeners dans lui-même. Tout se fait automatiquement !

Voici donc le tag en question à rajouter à notre service :

1
2
3
4
5
6
7
8
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdzblog.beta_listener:
        class: Sdz\BlogBundle\Beta\BetaListener
        arguments: ["2013-08-19"]
        tags:
            - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse }

Il y a deux paramètres à définir dans le tag, qui sont les deux paramètres qu'on a utilisés précédemment dans la méthode $dispatcher->addListener() :

  • event : c'est le nom de l'évènement que le listener veut écouter ;
  • method : c'est le nom de la méthode du listener à exécuter lorsque l'évènement est déclenché.

C'est tout ! Avec uniquement cette définition de service et le bon tag associé, votre listener sera exécuté à chaque déclenchement de l'évènement kernel.response !

Bien entendu, votre listener peut tout à fait écouter plusieurs évènements. Il suffit pour cela d'ajouter un autre tag avec des paramètres différents. Voici ce que cela donnerait si on voulait écouter l'évènement kernel.controller :

1
2
3
4
5
6
7
8
9
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdzblog.beta_listener:
        class: Sdz\BlogBundle\Beta\BetaListener
        arguments: ["2013-08-19"]
        tags:
            - { name: kernel.event_listener, event: kernel.response,   method: onKernelResponse }
            - { name: kernel.event_listener, event: kernel.controller, method: onKernelController }

Maintenant, passons à cette fameuse méthode onKernelResponse.

Création de la méthode à exécuter du listener

Mais pourquoi exécuter la méthode onKernelResponse alors qu'on avait déjà codé la méthode displayBeta ?

Réponse : par convention !

Plus sérieusement, dans votre esprit il faut bien distinguer deux points : d'un côté la fonction que remplit votre classe, et de l'autre la façon dont elle remplit sa fonction. Ici, notre classe BetaListener remplit parfaitement sa fonction avec la méthode displayBeta, c'est elle la méthode reine. Tout ce qu'on fait ensuite n'est qu'une solution technique pour arriver à notre but. En l'occurrence, le gestionnaire d'évènements ne va pas savoir donner les bons arguments ($reponse et $joursRestant) à notre méthode : il est alors hors de question de modifier notre méthode reine pour l'adapter au gestionnaire d'évènements !

En pratique, le gestionnaire donne un unique argument aux méthodes qu'il exécute pour nous : il s'agit d'un objet Symfony\Component\EventDispatcher\Event, représentant l'évènement en cours. Dans notre cas de l'évènement kernel.response, on a le droit à un objet Symfony\Component\HttpKernel\Event\FilterResponseEvent, qui hérite bien évidemment du premier.

Pas d'inquiétude : je vous dresse plus loin une liste des évènements Symfony2 ainsi que les types d'argument que le gestionnaire d'évènements transmet. Vous n'avez pas à jouer aux devinettes !

Notre évènement FilterResponseEvent dispose des méthodes suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<?php
class FilterResponseEvent
{
  public function getResponse();
  public function setResponse(Response $response);
  public function getKernel();
  public function getRequest();
  public function getRequestType();
  public function isPropagationStopped();
  public function stopPropagation();
}

Dans notre cas, ce sont surtout les méthodes getResponse() et setResponse qui vont nous être utiles : elles permettent respectivement de récupérer la réponse et de la modifier, c'est exactement ce que l'on veut !

On a maintenant toutes les informations nécessaires, il est temps de construire la méthode onKernelResponse dans notre listener. Tout d'abord, voici le principe général pour ce type de listener qui vient modifier une partie de l'évènement (ici, la réponse) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?php
// src/Sdz/BlogBundle/Beta/BetaListener.php

namespace Sdz\BlogBundle\Beta;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;

class BetaListener
{
  // …

  public function onKernelResponse(FilterResponseEvent $event)
  {
    // On teste si la requête est bien la requête principale
    if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) {
      return;
    }

    // On récupère la réponse que le noyau a insérée dans l'évènement
    $response = $event->getResponse();

    // Ici on modifie comme on veut la réponse…

    // Puis on insère la réponse modifiée dans l'évènement
    $event->setResponse($response);
  }
}

Le premier if teste si la requête courante est bien la requête principale. En effet, souvenez-vous, on peut effectuer des sous-requêtes via la balise {% render %} de Twig ou alors la méthode $this->forward() d'un contrôleur. Cette condition permet de ne pas réexécuter le code lors d'une sous-requête (on ne va pas mettre des mentions « bêta » sur chaque sous-requête !). Bien entendu, si vous souhaitez que votre comportement s'applique même aux sous-requêtes, ne mettez pas cette condition.

Adaptons maintenant cette base à notre exemple, il suffit juste de rajouter l'appel à notre précédente méthode, voyez par vous-mêmes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?php
// src/Sdz/BlogBundle/Beta/BetaListener.php

namespace Sdz\BlogBundle\Beta;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;

class BetaListener
{
  // …

  public function onKernelResponse(FilterResponseEvent $event)
  {
    // On teste si la requête est bien la requête principale
    if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) {
      return;
    }

    // On récupère la réponse depuis l'évènement
    $response = $event->getResponse();

    $joursRestant = $this->dateFin->diff(new \Datetime())->days;

    if ($joursRestant > 0) {
      // On utilise notre méthode « reine »
      $response = $this->displayBeta($event->getResponse(), $joursRestant);
    }

    // On n'oublie pas d'enregistrer les modifications dans l'évènement
    $event->setResponse($response);
  }
}

N'oubliez pas de rajouter le use Symfony\Component\HttpKernel\Event\FilterResponseEvent; en début de fichier.

Voilà, votre listener est maintenant opérationnel ! Actualisez n'importe quelle page de votre site et vous verrez la mention « bêta » apparaître comme sur la figure suivante.

La mention « bêta » apparaît

Ici la bonne exécution de votre listener est évidente, car on a modifié l'affichage, mais parfois rien n'est moins sûr. Pour vérifier que votre listener est bien exécuté, allez dans l'onglet Events du Profiler, vous devez l'apercevoir dans le tableau visible à la figure suivante.

Notre listener figure dans la liste des listeners exécutés

Méthodologie

Vous connaissez maintenant la syntaxe pour créer un listener qui va écouter un évènement. Vous avez pu constater que le principe est assez simple, mais pour rappel voici la méthode à appliquer lorsque vous souhaitez écouter un évènement :

  • Tout d'abord, créez une classe qui va remplir la fonction que vous souhaitez. Si vous avez un service déjà existant, cela va très bien.
  • Ensuite, choisissez bien l'évènement que vous devez écouter. On a pris directement l'évènement kernel.response pour l'exemple, mais vous devez choisir correctement le vôtre dans la liste que je dresse plus loin.
  • Puis créez la méthode qui va faire le lien entre le déclenchement de l'évènement et le code que vous voulez exécuter, il s'agit de la méthode onKernelResponse que nous avons utilisée.
  • Enfin, définissez votre classe comme un service (sauf si c'en était déjà un), et ajoutez à la définition du service le bon tag pour que le gestionnaire d'évènements retrouve votre listener.

Les évènements Symfony2… et les nôtres !

Symfony2 déclenche déjà quelques évènements dans son processus interne. Mais il sera bien évidemment possible de créer puis déclencher nos propres évènements !

Les évènements Symfony2

L'évènement kernel.request

Cet évènement est déclenché très tôt dans l'exécution d'une page, avant même que le choix du contrôleur à exécuter ne soit fait. Son objectif est de permettre à un listener de retourner immédiatement une réponse, sans même passer par l'exécution d'un contrôleur donc. Il est également possible de définir des attributs dans la requête. Dans le cas où un listener définit une réponse, alors les listeners suivants ne seront pas exécutés ; on reparle de la priorité des listeners plus loin.

La classe de l'évènement donné en argument par le gestionnaire d'évènements est GetResponseEvent, dont les méthodes sont les suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php

class GetResponseEvent
{
  public function getResponse();
  public function setResponse(Response $response);
  public function hasResponse();
  public function getKernel();
  public function getRequest();
  public function getRequestType();
  public function getDispatcher();
  public function isPropagationStopped();
  public function stopPropagation();
}

L'évènement kernel.controller

Cet évènement est déclenché après que le contrôleur à exécuter a été défini, mais avant de l'exécuter effectivement. Son objectif est de permettre à un listener de modifier le contrôleur à exécuter. Généralement, c'est également l'évènement utilisé pour exécuter du code sur chaque page.

La classe de l'évènement donné en argument par le gestionnaire d'évènements est FilterControllerEvent, dont les méthodes sont les suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<?php

class FilterControllerEvent
{
  public function getController();
  public function setController($controller);
  public function getKernel();
  public function getRequest();
  public function getRequestType();
  public function getDispatcher();
  public function isPropagationStopped();
  public function stopPropagation();
}

Voici comment utiliser cet évènement depuis un listener pour modifier le contrôleur à exécuter sur la page en cours :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<?php

use Symfony\Component\HttpKernel\Event\FilterControllerEvent;

public function onKernelController(FilterControllerEvent $event)
{
  // Vous pouvez récupérer le contrôleur que le noyau avait l'intention d'exécuter
  $controller = $event->getController();

  // Ici vous pouvez modifier la variable $controller, etc.
  // $controller doit être de type PHP callable

  // Si vous avez modifié le contrôleur, prévenez le noyau qu'il faut exécuter le vôtre :
  $event->setController($controller);
}

L'évènement kernel.view

Cet évènement est déclenché lorsqu'un contrôleur n'a pas retourné d'objet Response. Son objectif est de permettre à un listener d'attraper le retour du contrôleur (s'il y en a un) pour soit construire une réponse lui-même, soit personnaliser l'erreur levée.

La classe de l'évènement donné en argument par le gestionnaire d'évènements est GetResponseForControllerResultEvent (rien que ça !), dont les méthodes sont les suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<?php

class GetResponseForControllerResultEvent
{
  public function getControllerResult();
  public function getResponse();
  public function setResponse(Response $response);
  public function hasResponse();
  public function getKernel();
  public function getRequest();
  public function getRequestType();
  public function getDispatcher();
  public function isPropagationStopped();
  public function stopPropagation();
}

Voici comment utiliser cet évènement depuis un listener pour construire une réponse à partir du retour du contrôleur de la page en cours :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php

use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpFoundation\Response;

public function onKernelView(GetResponseForControllerResultEvent $event)
{
  // Récupérez le retour du contrôleur (ce qu'il a mis dans son « return »)
  $val = $event->getControllerResult();

  // Créez une nouvelle réponse
  $response = new Response();

  // Construisez votre réponse comme bon vous semble…

  // Définissez la réponse dans l'évènement, qui la donnera au noyau qui, finalement, l'affichera
  $event->setResponse($response);
}

L'évènement kernel.response

Cet évènement est déclenché après qu'un contrôleur a retourné un objet Response ; c'est celui que nous avons utilisé dans notre exemple de listener. Son objectif, comme vous avez pu vous en rendre compte, est de permettre à un listener de modifier la réponse générée par le contrôleur avant de l'envoyer à l'internaute.

La classe de l'évènement donné en argument par le gestionnaire d'évènements est FilterResponseEvent, dont les méthodes sont les suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<?php

class FilterResponseEvent
{
  public function getControllerResult();
  public function getResponse();
  public function setResponse(Response $response);
  public function hasResponse();
  public function getKernel();
  public function getRequest();
  public function getRequestType();
  public function getDispatcher();
  public function isPropagationStopped();
  public function stopPropagation();
}

L'évènement kernel.exception

Cet évènement est déclenché lorsqu'une exception est levée. Son objectif est de permettre à un listener de modifier la réponse à renvoyer à l'internaute, ou bien de modifier l'exception.

La classe de l'évènement donné en argument par le gestionnaire d'évènements est GetResponseForExceptionEvent, dont les méthodes sont les suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php

class GetResponseForExceptionEvent
{
  public function getException();
  public function setException(\Exception $exception);
  public function getResponse();
  public function setResponse(Response $response);
  public function hasResponse();
  public function getKernel();
  public function getRequest();
  public function getRequestType();
  public function getDispatcher();
  public function isPropagationStopped();
  public function stopPropagation();
}

L'évènement security.interactive_login

Cet évènement est déclenché lorsqu'un utilisateur s'identifie via le formulaire de connexion. Son objectif est de permettre à un listener d'archiver une trace de l'identification, par exemple.

La classe de l'évènement donné en argument par le gestionnaire d'évènements est Symfony\Component\Security\Http\Event\InteractiveLoginEvent, dont les méthodes sont les suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php

class InteractiveLoginEvent
{
  public function getAuthenticationToken();
  public function getRequest();
  public function getDispatcher();
  public function isPropagationStopped();
  public function stopPropagation();
}

L'évènement security.authentication.success

Cet évènement est déclenché lorsqu'un utilisateur s'identifie avec succès, quelque soit le moyen utilisé (formulaire de connexion, cookies remember_me). Son objectif est de permettre à un listener d'archiver une trace de l'identification, par exemple.

La classe de l'évènement donné en argument par le gestionnaire d'évènements est Symfony\Component\Security\Core\Event\AuthenticationEvent, dont les méthodes sont les suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php

class AuthenticationEvent
{
  public function getAuthenticationToken();
  public function getRequest();
  public function getDispatcher();
  public function isPropagationStopped();
  public function stopPropagation();
}

L'évènement security.authentication.failure

Cet évènement est déclenché lorsqu'un utilisateur effectue une tentative d'identification échouée, quelque soit le moyen utilisé (formulaire de connexion, cookies remember_me). Son objectif est de permettre à un listener d'archiver une trace de la mauvaise identification, par exemple.

La classe de l'évènement donné en argument par le gestionnaire d'évènements est Symfony\Component\Security\Core\Event\AuthenticationFailureEvent, dont les méthodes sont les suivantes :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php

class AuthenticationFailureEvent
{
  public function getAuthenticationException();
  public function getRequest();
  public function getDispatcher();
  public function isPropagationStopped();
  public function stopPropagation();
}

Notez la méthode getDispatcher() définie dans tous les évènements : elle récupère le gestionnaire d'évènements. Cela permet à un listener qui écoute un évènement A d'exécuter le code de la méthode n°1 vu précédemment dans sa méthode onEvenementA, il se mettra ainsi à écouter un évènement B sur sa méthode onEvenementB.

Créer nos propres évènements

Les évènements Symfony2 couvrent la majeure partie du process d'exécution d'une page, ou alors du process d'identification d'un utilisateur. Cependant, on aura parfois besoin d'appliquer cette conception par évènement à notre propre code, notre propre logique. Cela permet encore une fois de bien découpler les différentes fonctions de notre site.

Nous allons suivre un autre exemple pour la création d'un évènement : celui d'un outil de surveillance des messages postés, qu'on appellera BigBrother. L'idée est d'avoir un outil qui permette de censurer les messages de certains utilisateurs et/ou de nous envoyer une notification lorsqu'ils postent des messages. L'avantage de passer par un évènement au lieu de modifier directement le contrôleur, c'est de pouvoir appliquer cet outil à plusieurs types de messages : les articles du blog, les commentaires du blog, les messages sur le forum si vous en avez un, etc.

Pour reproduire le comportement des évènements, il nous faut trois étapes :

  • D'abord, définir la liste de nos évènements possibles. Il peut bien entendu y en avoir qu'un seul.
  • Ensuite, construire la classe de l'évènement. Il faut pour cela définir les informations qui peuvent être échangées entre celui qui émet l'évènement et celui qui l'écoute.
  • Enfin, déclencher l'évènement bien entendu.

Pour l'exemple, je vais placer tous les fichiers de la fonctionnalité BigBrother dans le répertoire Bigbrother du bundle Blog. Mais en réalité, comme c'est une fonctionnalité qui s'appliquera à plusieurs bundles (le blog, le forum, et d'autres si vous en avez), il faudrait le mettre dans un bundle séparé. Soit un bundle commun dans votre site, du genre CoreBundle si vous en avez un, soit carrément dans son bundle à lui, du genre BigbrotherBundle, vu que c'est une fonctionnalité que vous pouvez tout à fait partager avec d'autres sites !

Définir la liste de nos évènements

Nous allons définir une classe avec juste des constantes qui contiennent le nom de nos évènements. Cette classe est facultative en soi, mais c'est une bonne pratique qui nous évitera d'écrire directement le nom de l'évènement. On utilisera ainsi le nom de la constante, défini à un seul endroit, dans cette classe. J'appelle cette classe BigbrotherEvents, mais c'est totalement arbitraire, voici son code :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?php
// src/Sdz/BlogBundle/Bigbrother/BigbrotherEvents.php

namespace Sdz\BlogBundle\Bigbrother;

final class BigbrotherEvents
{
  const onMessagePost = 'sdzblog.bigbrother.post_message';
  // Vos autres évènements…
}

Cette classe ne fait donc rien, elle ne sert qu'à faire la correspondance entre BigbrotherEvents::onMessagePost qu'on utilisera pour déclencher l'évènement et le nom de l'évènement en lui même sdzblog.bigbrother.post_message.

Construire la classe de l'évènement

La classe de l'évènement, c'est, rappelez-vous, la classe de l'objet que le gestionnaire d'évènements va transmettre aux listeners. En réalité on ne l'a pas encore vu, mais c'est celui qui déclenche l'évènement qui crée une instance de cette classe. Le gestionnaire d'évènements ne fait que la transmettre, il ne la crée pas.

Voici dans un premier temps le squelette commun à tous les évènements. On va appeler le nôtre MessagePostEvent :

1
2
3
4
5
6
7
8
9
<?php
// src/Sdz/BlogBundle/Bigbrother/MessagePostEvent.php

namespace Sdz\BlogBundle\Bigbrother;
use Symfony\Component\EventDispatcher\Event;

class MessagePostEvent extends Event
{
}

C'est tout simplement une classe vide qui étend la classe Event du composant EventDispatcher.

Ensuite, il faut rajouter la spécificité de notre évènement. On a dit que le but de la fonctionnalité BigBrother est de censurer le message de certains utilisateurs, on a donc deux informations à transmettre du déclencheur au listener : le message et l'utilisateur qui veut le poster. On doit donc rajouter ces deux attributs à l'évènement :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<?php
// src/Sdz/BlogBundle/Bigbrother/MessagePostEvent.php

namespace Sdz\BlogBundle\Bigbrother;
use Symfony\Component\EventDispatcher\Event;
use Symfony\Component\Security\Core\User\UserInterface;

class MessagePostEvent extends Event
{
  protected $message;
  protected $user;
  protected $autorise;

  public function __construct($message, UserInterface $user)
  {
    $this->message  = $message;
    $this->user     = $user;
  }

  // Le listener doit avoir accès au message
  public function getMessage()
  {
    return $this->message;
  }

  // Le listener doit pouvoir modifier le message
  public function setMessage($message)
  {
    return $this->message = $message;
  }

  // Le listener doit avoir accès à l'utilisateur
  public function getUser()
  {
    return $this->user;
  }

  // Pas de setUser, le listener ne peut pas modifier l'auteur du message !
}

Faites attention aux getters et setters, vous devez les définir soigneusement en fonction de la logique de votre évènement :

  • Un getter doit tout le temps être défini sur vos attributs. Car si votre listener n'a pas besoin d'un attribut (ce qui justifierait l'absence de getter), alors l'attribut ne sert à rien !
  • Un setter ne doit être défini que si le listener peut modifier la valeur de l'attribut. Ici c'est le cas du message. Cependant, on interdit au listener de modifier l'auteur du message, cela n'aurait pas de sens.

Déclencher l'évènement

Déclencher et utiliser un évènement se fait assez naturellement lorsqu'on a bien défini l'évènement et ses attributs. Reprenons le code de l'action du contrôleur BlogController qui permet d'ajouter un article. Voici comment on l'adapterait pour déclencher l'évènement avant l'enregistrement effectif de l'article :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php
// src/Sdz/BlogBundle/Controller/BlogController.php

namespace Sdz\BlogBundle\Controller;
use Sdz\BlogBundle\Bigbrother\BigbrotherEvents;
use Sdz\BlogBundle\Bigbrother\MessagePostEvent;
// …

class BlogController extends Controller
{
  public function ajouterAction()
  {
    // …

    if ($form->isValid()) {

      // On crée l'évènement avec ses 2 arguments
      $event = new MessagePostEvent($article->getContenu(), $article->getUser());

      // On déclenche l'évènement
      $this->get('event_dispatcher')
           ->dispatch(BigbrotherEvents::onMessagePost, $event);

      // On récupère ce qui a été modifié par le ou les listeners, ici le message
      $article->setContenu($event->getMessage());

      // On continue l'enregistrement habituel
      $em = $this->getDoctrine()->getManager();
      $em->persist($article);
      $em->flush();

      // …
    }
  }
}

C'est tout pour déclencher un évènement ! Vous n'avez plus qu'à reproduire ce comportement la prochaine fois que vous créerez une action qui permet aux utilisateurs d'ajouter un nouveau message (livre d'or, messagerie interne, etc.).

Écouter l'évènement

Comme vous avez pu le voir, on a déclenché l'évènement alors qu'il n'y a pas encore de listener. Cela ne pose pas de problème, bien au contraire : cela va nous permettre par la suite d'ajouter un ou plusieurs listeners qui seront alors exécutés au milieu de notre code. Ça, c'est du découplage !

Pour aller jusqu'au bout de l'exemple, voici ma proposition pour un listener. C'est juste un exemple, ne le prenez pas pour argent comptant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<?php
// src/Sdz/BlogBundle/Bigbrother/CensureListener.php

namespace Sdz\BlogBundle\Bigbrother;
use Symfony\Component\Security\Core\User\UserInterface;

class CensureListener
{
  // Liste des id des utilisateurs à surveiller
  protected $liste;
  protected $mailer;

  public function __construct(array $liste, \Swift_Mailer $mailer)
  {
    $this->liste  = $liste;
    $this->mailer = $mailer;
  }

  // Méthode « reine » 1
  protected function sendEmail($message, UserInterface $user)
  {
    $message = \Swift_Message::newInstance()
        ->setSubject("Nouveau message d'un utilisateur surveillé")
        ->setFrom('admin@votresite.com')
        ->setTo('admin@votresite.com')
        ->setBody("L'utilisateur surveillé '".$user->getUsername()."' a posté le message suivant : '".$message."'");

    $this->mailer->send($message);
  }

  // Méthode « reine » 2
  protected function censureMessage($message)
  {
    // Ici, totalement arbitraire :
    $message = str_replace(array('top secret', 'mot interdit'), '', $message);

    return $message;
  }

  // Méthode « technique » de liaison entre l'évènement et la fonctionnalité reine
  public function onMessagePost(MessagePostEvent $event)
  {
    // On active la surveillance si l'auteur du message est dans la liste
    if (in_array($event->getUser()->getId(), $this->liste)) {
      // On envoie un e-mail à l'administrateur
      $this->sendEmail($event->getMessage(), $event->getUser());

      // On censure le message
      $message = $this->censureMessage($event->getMessage());
      // On enregistre le message censuré dans l'event
      $event->setMessage($message);
    }
  }
}

Et bien sûr, la définition du service qui convient :

1
2
3
4
5
6
7
8
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdzblog.censure_listener:
        class: Sdz\BlogBundle\Bigbrother\CensureListener
        arguments: [[1, 2], @mailer]
        tags:
            - { name: kernel.event_listener, event: sdzblog.bigbrother.post_message, method: onMessagePost }

J'ai mis ici arbitrairement une liste [1, 2] pour les id des utilisateurs à surveiller, mais vous pouvez personnaliser cette liste ou même la rendre dynamique.

Allons un peu plus loin

Le gestionnaire d'évènements est assez simple à utiliser, et vous connaissez en réalité déjà tout ce qu'il faut savoir. Mais je ne pouvais pas vous laisser sans vous parler de trois points supplémentaires, qui peuvent être utiles.

Étudions donc les souscripteurs d'évènements, qui peuvent se mettre à écouter un évènement de façon dynamique, l'ordre d'exécution des listeners, ainsi que la propagation des évènements.

Les souscripteurs d'évènements

Les souscripteurs sont assez semblables aux listeners. La seule différence est la suivante : au lieu d'écouter toujours le même évènement défini dans un fichier de configuration, un souscripteur peut écouter dynamiquement un ou plusieurs évènements.

Concrètement, c'est l'objet souscripteur lui-même qui va dire au gestionnaire d'évènements les différents évènements qu'il veut écouter. Pour cela, un souscripteur doit implémenter l'interface EventSubscriberInterface, qui ne contient qu'une seule méthode : getSubscribedEvents(). Vous l'avez compris, cette méthode doit retourner les évènements que le souscripteur veut écouter.

Voici un simple exemple d'un souscripteur arbitraire dans notre bundle Blog :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<?php
// src/Sdz/BlogBundle/Event/TestSubscriber.php

namespace Sdz\BlogBundle\Event;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;

class TestSubscriber implements EventSubscriberInterface
{
  // La méthode de l'interface que l'on doit implémenter, à définir en static
  static public function getSubscribedEvents()
  {
    // On retourne un tableau « nom de l'évènement » => « méthode à exécuter »
    return array(
      'kernel.response' => 'onKernelResponse',
      'store.order'     => 'onStoreOrder',
    );
  }

  public function onKernelResponse(FilterResponseEvent $event)
  {
    // …
  }

  public function onStoreOrder(FilterOrderEvent $event)
  {
    // …
  }
}

Bien sûr, il faut ensuite déclarer ce souscripteur au gestionnaire d'évènements. À ce jour, il n'est pas possible de le faire grâce à un tag dans une définition de service. Il faut donc le faire manuellement, comme on a appris à le faire avec un simple listener :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<?php
// Depuis un contrôleur ou autre

use Sdz\BlogBundle\Event\TestSubscriber;

// On récupère le gestionnaire d'évènements
$dispatcher = $this->get('event_dispatcher');

// On instancie notre souscripteur
$subscriber = new TestSubscriber();

// Et on le déclare au gestionnaire d'évènements
$dispatcher->addSubscriber($subscriber);

L'ordre d'exécution des listeners

On peut définir l'ordre d'exécution des listeners grâce à un indice priority. Cet ordre aura ainsi une importance lorsqu'on verra comment stopper la propagation d'un évènement.

La priorité des listeners

Vous pouvez ajouter un indice de priorité à vos listeners, ce qui permet de personnaliser leur ordre d'exécution sur un même évènement. Plus cet indice de priorité est élevé, plus le listener sera exécuté tôt, c'est-à-dire avant les autres. Par défaut, si vous ne précisez pas la priorité, elle est de 0.

Vous pouvez la définir très simplement dans le tag de la définition du service. Ici, je l'ai définie à 2 :

1
2
3
4
5
6
7
8
# src/Sdz/BlogBundle/Resources/config/services.yml

services:
    sdzblog.beta_listener:
        class: Sdz\BlogBundle\Beta\BetaListener
        arguments: ["2013-08-19"]
        tags:
            - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse, priority: 2 }

Et voici pour le cas où vous enregistrez votre listener en manipulant directement le gestionnaire d'évènements :

1
2
<?php
$dispatcher->addListener('kernel.response', array($listener, 'onKernelResponse'), 2);

Vous pouvez également définir une priorité négative, ce qui aura pour effet d'exécuter votre listener relativement tard dans l'évènement. Je dis bien relativement, car s'il existe un autre listener avec une priorité de -128 alors que le vôtre est à -64, alors c'est lui qui sera exécuté après le vôtre.

La propagation des évènements

Si vous avez l'œil bien ouvert, vous avez pu remarquer que tous les évènements qu'on a vus précédemment avaient deux méthodes en commun : stopPropagation() et isPropagationStopped(). Eh bien, vous ne devinerez jamais, mais la première méthode permet à un listener de stopper la propagation de l'évènement en cours !

La conséquence est donc directe : tous les autres listeners qui écoutaient l'évènement et qui ont une priorité plus faible ne seront pas exécutés. D'où l'importance de l'indice de priorité que nous venons juste de voir !

Pour visualiser ce comportement, je vous propose de modifier légèrement notre BetaListener. Rajoutez cette ligne à la fin de sa méthode onKernelResponse :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<?php
// src/Sdz/BlogBundle/Beta/BetaListener.php

// …

public function onKernelResponse(FilterResponseEvent $event)
{
  // …

  // On stoppe la propagation de l'évènement en cours (ici, kernel.response)
  $event->stopPropagation();
}

Actualisez une page. Vous voyez une différence ? La barre d'outils a disparu du bas de la page ! En effet, cette barre est ajoutée avec un listener sur l'évènement kernel.response, exactement comme notre mention « bêta ». Pour votre culture, il s'agit de Symfony\Bundle\WebProfilerBundle\EventListener\WebDebugToolbarListener.

La deuxième méthode, isPropagationStopped(), permet de tester si la propagation a été stoppée dans le listener qui a stoppé l'évènement. Les autres listeners n'étant pas exécutés du tout, il est évidemment inutile de tester la propagation dans leur code (à moins qu'eux-mêmes ne stoppent la propagation bien sûr).


En résumé

  • Un évènement correspond à un moment clé dans l'exécution d'une page ou d'une action.
  • On parle de déclencher un évènement lorsqu'on signale au gestionnaire d'évènements qu'un certain évènement vient de se produire.
  • On dit qu'un listener écoute un évènement lorsqu'on signale au gestionnaire d'évènements qu'il faut exécuter ce listener dès qu'un certain évènement se produit.
  • Un listener est une classe qui remplit une fonction, et qui écoute un ou plusieurs évènements pour savoir quand exécuter sa fonction.
  • On définit les évènements à écouter via les tags du service listener.
  • Il existe plusieurs évènements de base dans Symfony2, et il est possible de créer les nôtres.