Michaël Gallego

This is my blog. What can you expect here? Well... Zend Framework 2, Amazon AWS...

Twitter

Google+

LinkedIn

Github

Last.fm

Comprendre le gestionnaire d'évènements de Zend Framework 2

Le gestionnaire d’évènements est sans nul doute l’un des composants le plus mal connu de Zend Framework 2. Pour autant, il est extrêmement puissant et offre une très grande flexibilité lorsqu’il est correctement utilisé. Cet article a pour vocation de vous initier à son utilisation.

The English version of this article can be found here.

Pour quoi faire ?

Le gestionnaire d’évènements est utilisé de manière abondante au sein du framework (on dit que ZF 2 est event-driven). Ainsi, les différents éléments de la boucle MVC (préparation, routing, dispatch, vue…) ne sont plus appelés les uns après les autres, comme c’était le cas sur ZF 1, mais à la place, le framework lance à différents endroits des évènements (“dispatch”, “route”…). Ces évènements sont “écoutés” par d’autres objets qui, en réponse, effectuent des traitements. Nous avons donc extrait la terminologie importante des évènements :

  • Des objets lancent des évènements (en anglais : trigger). Ces évènements ont systématiquement un nom (“route”, “dispatch”, “sendTweet”…) et, souvent, contiennent des paramètres additionnels (par exemple, un évènement “sendTweet” pourrait contenir le contenu du tweet).
  • Des objets écoutent ces évènements pour y réagir (en anglais : listen). En d’autres termes, on attache des objets à des évènements.

Le cas du TweetService

Nous avons vu que le framework utilise abondamment les évènements. Mais il est également possible d’en tirer parti dans notre propre code. Prenons un exemple simple : nous avons écrit un TweetService dont le seul et unique rôle est d’envoyer le contenu d’un tweet via l’API Twitter :

namespace Tweet\Service;

class TweetService
{
    /**
     * @param string $content
     */
    public function sendTweet($content)
    {
        // Envoi du tweet par l'API Tweeter...
    }
}

Puis on se dit que finalement, il serait également utile d’envoyer un e-mail à l’auteur du tweet pour l’avertir :

namespace Tweet\Service;

class TweetService
{
    /**
     * @param string $content
     */
    public function sendTweet($content)
    {
        // Envoi du tweet par l'API Tweeter...

        // Envoi d'un e-mail
        $mailService->send(...);
    }
}

Et puis finalement, il serait peut-être également sympa de lui envoyer un SMS :

namespace Tweet\Service;

class TweetService
{
    /**
     * @param string $content
     */
    public function sendTweet($content)
    {
        // Envoi du tweet par l'API Tweeter...

        // Envoi d'un e-mail
        $mailService->send(...);

        // Envoi d'un SMS
        $smsService->send(...);
    }
}

Bref, vous avez compris le soucis, ou plutôt les soucis :

  • Notre TwitterService est maintenant complètement couplé à notre application. Cette application requiert d’envoyer un mail et un SMS, peut-être qu’une autre se contentera juste d’envoyer un tweet. Il sera ainsi nécessaire d’aller modifier le code pour chaque application, ce qui est évidemment ce que nous souhaitons éviter lorsque l’on écrit un module “générique”.
  • De plus, notre TweeterService est maintenant fortement couplé avec deux autres services : un service d’envoi de mail et un service d’envoi de SMS. Ceci signifie : écrire des fabriques plus complexes pour tout injecter, et installer systématiquement les dépendances vers des modules supplémentaires.

Comment s’en servir ?

Lancer un évènement

Heureusement, il existe une solution extrêmement élégante à ce problème : le gestionnaire d’évènements. Ainsi, plutôt que de coder en dur les actions à réaliser, nous allons lancer un évènement qui va signaler qu’un tweet a été envoyé :

namespace Tweet\Service;

use Zend\EventManager\EventManagerAwareInterface;
use Zend\EventManager\EventManagerInterface;

class TweetService implements EventManagerAwareInterface
{
    /**
     * @var EventManagerInterface
     */
    protected $eventManager;

    /**
     * @param string $content
     */
    public function sendTweet($content)
    {
        // Envoi du tweet par l'API Tweeter...

        // Lancement de l'évènement
        $this->getEventManager()->trigger('sendTweet', null, array('content' => $content));
    }

    /**
     * @param  EventManagerInterface $eventManager
     * @return void
     */
    public function setEventManager(EventManagerInterface $eventManager)
    {
        $this->eventManager = $eventManager;
    }

    /**
     * @return EventManagerInterface
     */
    public function getEventManager()
    {
        if (null === $this->eventManager) {
            $this->setEventManager(new EventManager());
        }

        return $this->eventManager;
    }
}

Comme nous pouvons le voir, nous nous contentons maintenant juste de lever l’évènement “sendTweet”. A charge de notre service de mail et/ou de SMS de s’abonner à cet évènement et d’y envoyer correctement le mail.

Notez également que notre classe implémente l’interface EventManagerAwareInterface. Lorsque nous créons un objet implémentant cette interface, Zend Framework 2 va automatiquement y injecter un gestionnaire d’évènements tout neuf.

Ajouter des listeners

La plupart du temps, vous allez ajouter des listeners à des évènements dans le fichier Module.php, plus précisément dans la méthode onBootstrap. C’est la méthode la plus simple et celle qui est recommandée. Voici, de manière intuitive, ce que la plupart des débutants font :

namespace Tweet;

use Zend\Mvc\MvcEvent;

class Module
{
    public function onBootstrap(MvcEvent $event)
    {
        $eventManager = $event->getApplication()->getEventManager();
        $eventManager->attach('sendTweet', function($e) {
            var_dump($e);
        }, 100);
    }
}

Ici, nous récupérons le gestionnaire d’évènements de l’application, et nous attachons une fonction à réaliser dès que l’évènement “sendTweet” est levé.

Hélas… ceci ne fonctionne pas ! C’est la partie la plus compliquée du gestionnaire d’évènements, et l’astuce consiste à utiliser un gestionnaire d’évènements partagé.

Le gestionnaire d’évènements partagé

Revenons à notre TweetService. J’ai dit que lorsque l’on implémente l’interface EventManagerAwareInterface, ZF injecte automatiquement pour nous un gestionnaire d’évènements. La subtilité, c’est que ZF 2 injecte un nouveau gestionnaire d’évènements à chaque fois.

Conséquence : lorsque nous lançons l’évènement sendTweet dans le TweetService, comme le gestionnaire d’évènements est différent, celui du TweetService n’a aucune connaissance des listeners qui ont pu être ajoutés dans d’autres gestionnaires d’évènements (dans notre cas, celui de l’application).

Pourquoi est-ce que ZF 2 n’injecte tout simplement pas le même gestionnaire d’évènements partout ? Ainsi, le problème serait réglé… Hélas, si nous faisions ceci, nous aurions rapidement des problèmes. Imaginons un évènement tel que “send”. Plusieurs objets pourraient lancer un évènement nommé “send”, mais de nature complètement différentes (“send” peut aussi bien concerné un envoi de mail, que de SMS, ou d’un envoi de requête ou que sais-je !). Ce qui signifie que nos écouteurs (listeners) recevraient des évènements qui ne les concernent absolument pas ! C’est pourquoi chaque objet dispose de son propre gestionnaire d’évènements, avec ses propres évènements indépendants aux autres gestionnaires d’évènements.

Pour résoudre notre problème précédent, il faut utiliser un gestionnaire d’évènements partagé. Un gestionnaire d’évènements partagé est un gestionnaire qui, lui, est unique, et qui est injecté automatiquement dans chaque gestionnaire d’évènements. Modifions notre code de Module.php afin d’enregistrer l’évènement auprès du gestionnaire d’évènements partagé :

namespace Tweet;

use Zend\Mvc\MvcEvent;

class Module
{
    public function onBootstrap(MvcEvent $event)
    {
        $eventManager       = $event->getApplication()->getEventManager();
        $sharedEventManager = $eventManager->getSharedManager();

        $sharedEventManager->attach('Tweet\Service\TweetService', 'sendTweet', function($e) {
            var_dump($e);
        }, 100);
    }
}

En premier lieu, nous avons d’abord récupéré le gestionnaire d’évènements partagé à partir du gestionnaire d’évènements. Puis nous avons attaché un listener. La subtilité, c’est le premier paramètre : Tweet\Service\TweetService. En effet, à l’heure actuelle, sans ce paramètre, le gestionnaire d’évènements, lorsque l’on appel trigger dans notre TweetService, n’a aucun moyen de “récupérer” les listeners de l’évènement qu’il déclenche.

Ainsi, nous allons devoir légèrement modifier notre fonction setEventManager dans notre TweetService :

public function setEventManager(EventManagerInterface $eventManager)
{
    $eventManager->addIdentifiers(array(
        get_called_class()
    ));

    $this->eventManager = $eventManager;
}

Maintenant, le lien va pouvoir être effectué correctement. Nous ajoutons dans notre $eventManager un identifiant qui ici vaut get_called_class() (donc Tweet\Service\TweetService). Lorsque ce service va lancer l’évènement sendTweet,  voici ce qui va se passer :

  1. Le gestionnaire d’évènement de TweetService va vérifier si des objets écoutent l’évènement sendTweet, ce qui n’est pas le cas car les écouteurs ont été ajouté dans le gestionnaire d’évènements partagé.
  2. Le gestionnaire d’évènements de TweetService va ensuite récupérer le gestionnaire d’évènement partagé, unique à tous les gestionnaires d’évènements. Pour chacun de ses identifiants (dans notre cas, il n’y en a qu’un), il va vérifier si quelqu’un a ajouté un évènement sendTweet pour l’identifiant donné. En clair : il va vérifier si il y a un écouteur pour l’évènement sendTweet attaché à l’identifiant Tweet\Service\TweetService. Bingo ! C’est exactement ce que nous avons fait dans la fonction onBootstrap !
  3. Pour chaque écouteur trouvé, la fonction va être effectuée (dans notre cas, un simple var_dump($e) est effectué dans Module.php.

Ce mécanisme d’identifiant est très puissant. Par exemple, imaginons que nous souhaitons envoyer l’évènement sendTweet  dans plusieurs de nos services. Nous pouvons ajouter un identifiant complémentaire et plus générique :

public function setEventManager(EventManagerInterface $eventManager)
{
    $eventManager->addIdentifiers(array(
        'Application\Service\ServiceInterface',
        get_called_class()
    ));

    $this->eventManager = $eventManager;
}

Maintenant, mettons à jour onBootstrap :

namespace Tweet;

use Zend\Mvc\MvcEvent;

class Module
{
    public function onBootstrap(MvcEvent $event)
    {
        $eventManager       = $event->getApplication()->getEventManager();
        $sharedEventManager = $eventManager->getSharedManager();

        // Cet écouteur ne sera appelé QUE si l'évènement sendTweet est
        // lancé par un gestionnaire d'évènements ayant l'identifiant
        // Tweet\Service\TweetService, et aucun autre !
        $sharedEventManager->attach('Tweet\Service\TweetService', 'sendTweet', function($e) {
            var_dump($e);
        }, 100);

        // Cet écouteur sera appelé pour tous les évènements sendTweet de
        // tout gestionnaire d'évènements ayant l'identifiant
        // Application\Service\ServiceInterface, donc potentiellement
        // plusieurs services à la fois
        $sharedEventManager->attach('Application\Service\ServiceInterface', 'sendTweet', function($e) {
            var_dump($e);
        }, 100);
    }
}

Le cas des évènements de la boucle MVC

J’ai dis plus tôt qu’il fallait utiliser le gestionnaire d’évènements partagé. A une exception : le gestionnaire d’évènements que nous recevons dans la méthode onBootstrap est justement le gestionnaire d’évènements de la boucle MVC principale. Ce qui signifie que ce gestionnaire d’évènements connaît les évènements lancés par le framework. A ce titre, si vous souhaitez ajouter un listener aux évènements décrits dans la classe Zend\Mvc\MvcEvent, vous pouvez le faire sans passer par le gestionnaire d’évènements partagés :

namespace Tweet;

use Zend\Mvc\MvcEvent;

class Module
{
    public function onBootstrap(MvcEvent $event)
    {
        $eventManager = $event->getApplication()->getEventManager();
        $eventManager->attach(MvcEvent::EVENT_ROUTE, function($e) {
            var_dump($e);
        }, 100);
    }
}

Nettoyons un peu tout ça…

Dans les exemples précédents, nous avons définit ce que doivent faire nos écouteurs en utilisant une fonction anonyme (le deuxième paramètre dans le cas du gestionnaire d’évènements, ou le troisième dans le gestionnaire d’évènements partagés).

Or, si vous avez beaucoup d’écouteurs différents à enregistrer dans la méthode onBootstrap, le code peut rapidement devenir illisible. De même, il est possible que vous souhaitiez enregistrer le même écouteur pour plusieurs évènements, ce qui entraînera irrémédiablement une duplication de code.

Heureusement, nous pouvons nettoyer ceci en créant des classes implémentant l’interface  Zend\EventManager\ListenerAggregateInterface. Cette interface impose d’implémenter deux méthodes : attach et detach. Voici la classe permettant d’écouter l’évènement sendTweet du gestionnaire d’évènements partagé :

namespace Tweet\Listener;

use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\ListenerAggregateInterface;

class SendListener implements ListenerAggregateInterface
{
    /**
     * @var \Zend\Stdlib\CallbackHandler[]
     */
    protected $listeners = array();

    /**
     * {@inheritDoc}
     */
    public function attach(EventManagerInterface $events)
    {
        $sharedEvents      = $events->getSharedManager();
        $this->listeners[] = $sharedEvents->attach('Tweet\Service\TweetService', 'sendTweet', array($this, 'onSendTweet'), 100);
    }

    public function detach(EventManagerInterface $events)
    {
        foreach ($this->listeners as $index => $listener) {
            if ($events->detach($listener)) {
                unset($this->listeners[$index]);
            }
        }
    }

    public function onSendTweet($e)
    {
        var_dump($e);
    }
}

Du coup, notre onBootstrap est simplifié, et le SendListener peut être réutilisé. Cela permet de déplacer la logique dans des objets spécifiques plutôt que de tout foutre dans Module.php, ce qui est toujours une bonne chose.

public function onBootstrap(EventInterface $e)
{
    $eventManager = $e->getTarget()->getEventManager();
    $eventManager->attach(new SendListener());
}

Conclusion

Les évènements sont extrêmement puissants et s’avèrent parfois la solution idéale à certains problèmes. Toutefois, il est important de ne pas en abuser : il rend la compréhension du code plus difficile car il est nécessaire de voir quels sont les écouteurs enregistrés pour chaque évènement lancé.

De manière générale, dans votre code métier (donc les modules non génériques), préférez réaliser autant d’actions que possibles dans vos services, plutôt que de lancer des évènements partout.

Liens

Zend Framework 2: Getting Closer With EventManager