Drupal 8 et les événements

Un saut sur une flaque d'eau

Drupal 8 dispose désormais d'une nouvelle corde à son arc pour interagir avec le coeur ou ses modules contribués : les événements, hérités directement de Symfony2. 

Le principe de fonctionnement est très simple :

  • Nous pouvons déclarer et propager un événement, contenant certaines variables, sur une action particulière au moyen de la classe EventDispatcher
  • Nous pouvons souscrire à un événement pour altérer si besoin les variables passées en paramètres, ou encore déclencher une action spécifique, au moyen de la classe EventSubscriber

Découvrons plus en détail comment propager un événement ou y souscrire, ainsi que les différents cas d'utilisation des événements.

Propager un événement

Nous pouvons déclarer et propager un événement pour permettre à d'autres modules contribués d'intervenir et d'altérer le contenu et le comportement de notre propre module. Cette altération peut être réalisé encore, comme sous Drupal 7 et sa fonction drupal_alter(), avec la classe ModuleHandler

\Drupal::moduleHandler()->alter('my_module_data', $data);

Et il suffit à un module contribué d'implémenter hook_TYPE_alter(), pour altérer le contenu de la variable $data. Dans notre exemple :

hook_my_module_data_alter(&$data) {
  $data['key'] = new_key(); //some calculation...
}

L'intérêt de recourir aux événements, en lieu et place de cette méthode procédurale, outre la mise en oeuvre possible de tests unitaires, réside en la possibilité de maitriser plus facilement les ordres de priorités des différentes souscriptions à un événement, et de pouvoir stopper sa propagation le cas échéant, si besoin.

Mais la propagation d'événements peut répondre aussi au besoin de logiques métier, et non pas seulement au besoin de laisser la possibilité à d'autres modules d'intervenir sur un contenu, comme par exemple déclencher des actions sur certains événements.

Pour propager un événement, il nous suffit d'étendre la classe Event pour créer notre propre événement à transmettre, puis de propager proprement dit cet événement au moyen de la classe EventDispatcher.

Nous allons créer notre classe MyEvent dans le fichier src/Event/MyEvent.php de notre module

<?php

namespace Drupal\my_module\Event;

use Drupal\Core\Entity\EntityInterface;
use Symfony\Component\EventDispatcher\Event;

/**
 * Event that is fired when an entity is updated.
 *
 * @see rules_user_login()
 */

class MyEvent extends Event {

  const EVENT_NAME = 'my_module.entity_updated';

 /**
  * The entity object.
  * @var \Drupal\Core\Entity\EntityInterface
  */
  public $entity;

  /**
   * Constructs the object.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity object.
   */
  public function __construct(EntityInterface $entity) {
    $this->entity = $entity;
  }

}

Nous pouvons bien sûr ajouter autant de méthodes que nécessaires dans notre classe, selon nos besoins.

Et dans notre module, il nous suffit alors d'implémenter hook_entity_update().

/**
 * Implements hook_entity_update().
 */
function my_module_entity_update(EntityInterface $entity) {
  // Only handle content entities and ignore config entities.
  if ($entity instanceof ContentEntityInterface) {
    $event = new MyEvent($entity);
    $event_dispatcher = \Drupal::service('event_dispatcher');
    $event_dispatcher->dispatch(MyEvent::EVENT_NAME, $event);
  }
}

Et nous propageons cet événement (une entité a été mise à jour) au moyen de la méthode dispatch(). Nous pourrons alors souscrire à cet événement, et lancer les actions voulues (envoyer un courriel, logguer cette action, etc.).

Souscrire à un événement

Nous pouvons souscrire aux événements que nous avons nous-même propagés bien sûr, mais aussi à ceux propagés par d'autres modules ou encore le coeur de Drupal 8. Celui-ci propage des événements sur les requêtes, à différents moments de leur execution, sur les opérations liées aux entités de configuration et aux types d'entités, ainsi que quelques autres événements encore liés aux routes.

Pour découvrir la souscription aux événements, nous allons souscrire à une requête, notre objectif étant d'intercepter la requête, avant même la construction de la réponse pour des questions de performance, pour interdire l'accès à certaines pages de taxonomy ou encore rediriger les utilisateurs vers la page d'accueil. L'idée ici est d'implémenter un fonctionnel fourni par le module Rabbit hole en cours de migration du Drupal 8.

Dans un premier temps il nous faut créer un service qui va nous permettre de souscrire à un événement. Créons un module intitulé Term page access, et créons le fichier term_page_access.services.yml qui va contenir :

# File term_page_access.services.yml.
services:
  term_page_access.redirect:
    class: Drupal\term_page_access\EventSubscriber\TermPageAccessSubscriber
    arguments: ['@current_user', '@url_generator']
    tags:
      - { name: event_subscriber }

Nous déclarons la classe qui sera utilisée par ce service, nous pouvons lui fournir des arguments qui nous permettent de charger dans notre classe d'autres services par injection de dépendance (ici nous chargeons les services liés à l'utilisateur courant et à la génération d'url), et nous apposons à ce service le tag event_subscriber qui va enregistrer ce service dans la pile de services à l'écoute des événements.

Il ne nous reste plus qu'à créer notre classe TermPageAccessSubscriber, dans le répertoire src/EventSubscriber de notre module. Cette classe devra disposer de deux méthodes principales :

  • getSubscribedEvents() : cette méthode nous permet de déclarer à quels événements nous souhaitons souscrire, et d'associer à cet événement une réaction et une priorité.

  • L'autre méthode sera celle déclarée dans la réaction à l'événement

Passons à l'exemple.

<?php

namespace Drupal\term_page_access\EventSubscriber;

use Drupal\Core\Session\AccountInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Drupal\Core\Routing\UrlGeneratorInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;

/**
 * Class TermPageAccessSubscriber.
 *
 * @package Drupal\term_page_access
 */
class TermPageAccessSubscriber implements EventSubscriberInterface {

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * The url generator.
   *
   * @var \Drupal\Core\Routing\UrlGeneratorInterface
   */
  protected $urlGenerator;

  /**
   * Constructor.
   *
   * @param \Drupal\Core\Session\AccountInterface $current_user
   *   The current user.
   * 
   * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
   *   The url generator.
   *
   */
  public function __construct(AccountInterface $current_user, UrlGeneratorInterface $url_generator) {
    $this->currentUser = $current_user;
    $this->urlGenerator = $url_generator;
  }

  /**
   * {@inheritdoc}
   */
  static function getSubscribedEvents() {
    $events[KernelEvents::REQUEST] = ['onRequestRedirect', 100];
    return $events;
  }

  /**
   * This method is called whenever the kernel.request event is
   * dispatched.
   *
   * @param GetResponseEvent $event
   */
  public function onRequestRedirect(GetResponseEvent $event) {
    $request = $event->getRequest();

    // If we've got an exception, nothing to do here.
    if ($request->get('exception') != NULL) {
      return;
    }

    /** @var \Drupal\taxonomy\Entity\Term $term */
    if ($term = $request->get('taxonomy_term')) {
      if ($term->bundle() == 'tags') {
        $target = $this->urlGenerator->generate('<front>');
        $new_response = new RedirectResponse($target, '301');
        $event->setResponse($new_response);
      }
    }
  }

}

Dans la méthode getSubscribedEvents(), nous souscrivons donc à l'événement kernel.request (depuis la constante KernelEvents::REQUEST), et nous lui associons la méthode onRequestRedirect() et une priorité (les priorités les plus hautes sont exécutées en premier). A noter que nous pouvons souscrire à plusieurs événements au sein d'un même service.

Dans la méthode onRequestRedirect(), nous récupérons la requête associée à l'événement, nous testons si nous sommes sur une page de terme de taxonomy (en récupérant son paramètre taxonomy_term s'il existe), et nous effectuons une simple redirection permanente (301) vers la page d'accueil s'il s'agit d'un terme du vocabulaire Tags.

En quelques lignes, nous venons, grâce aux événements, d'interdire l'accès aux pages des termes de taxonomy Tags, aussi bien aux visiteurs qu'aux robots d'indexation. Nous aurions pu par exemple renvoyer une réponse 403 (Accès refusé) tout aussi simplement. Notre méthode onRequestRedirect() ressemblerait alors à ceci.

/**
 * This method is called whenever the kernel.request event is
 * dispatched.
 *
 * @param GetResponseEvent $event
 */
public function onRequestRedirect(GetResponseEvent $event) {
  $request = $event->getRequest();

  // If we've got an exception, nothing to do here.
  if ($request->get('exception') != NULL) {
    return;
  }

  /** @var \Drupal\taxonomy\Entity\Term $term */
  if ($term = $request->get('taxonomy_term')) {
    if ($term->bundle() == 'tags') {
      throw new AccessDeniedHttpException();
    }
  }
}

Notons ici que si nous souhaitons maîtriser totalement (et donc pas de redirection) le contrôle d'accès aux termes de taxonomy (dont leurs pages de rendu, mais pas seulement), il est préférable alors d'altérer cette entité avec hook_entity_type_alter() pour surcharger la classe responsable du contrôle d'accès et implémenter alors notre propre logique.

Pour conclure cet exemple, nous pouvons essayer de rendre plus modulable notre service, en nous appuyant sur le système de permissions de Drupal pour ne pas paramétrer en dur dans notre méthode le vocabulaire que nous souhaitons protéger.

Déclarons de nouvelles permissions dynamiques.

# File term_page_access.permissions.yml
permission_callbacks:
  - \Drupal\term_page_access\TermPageAccessPermissions::permissions

Nous déclarons ici une fonction de Callback pour déclarer nos permissions.

Créons la classe TermPageAccessPermissions à la racine du répertoire /src de notre module.

<?php

namespace Drupal\term_page_access;

use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\core\Entity\EntityTypeManagerInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;


/**
 * Defines a class for dynamic permissions based on vocabularies.
 */
class TermPageAccessPermissions implements ContainerInjectionInterface {

  use StringTranslationTrait;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;


  /**
   * Constructor.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
    $this->entityTypeManager = $entity_type_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static($container->get('entity_type.manager'));
  }

  /**
   * Returns an array of transition permissions.
   *
   * @return array
   *   The access protected permissions.
   */
  public function permissions() {

    $perms = [];
    $vocabularies = $this->entityTypeManager->getStorage('taxonomy_vocabulary')->loadMultiple();
    /* @var \Drupal\taxonomy\Entity\Vocabulary $vocabulary */
    foreach ($vocabularies as $id => $vocabulary) {
      $perms['access ' . $id . ' vocabulary'] = [
        'title' => $this->t('Access to the %label vocabulary', [
          '%label' => $vocabulary->label(),
        ]),
        'description' => $this->t('Access to the terms page of %label vocabulary', [
          '%label' => $vocabulary->label(),
        ]),
      ];
    }

    return $perms;
  }

}

Et nous déclarons au moyen de la méthode permissions() une permission pour chaque vocabulaire existant sur le site.

Permissions dynamiques générées

Nous pouvons alors mettre à jour la méthode onRequestRedirect(), pour nous appuyer désormais sur les permissions affectées ou pas à l'utilisateur courant (d'où l'injection de dépendance du service @current_user faite initialement)

/**
 * This method is called whenever the kernel.request event is
 * dispatched.
 *
 * @param GetResponseEvent $event
 */
public function onRequestRedirect(GetResponseEvent $event) {
  $request = $event->getRequest();

  // If we've got an exception, nothing to do here.
  if ($request->get('exception') != NULL) {
    return;
  }

  /** @var \Drupal\taxonomy\Entity\Term $term */
  if ($term = $request->get('taxonomy_term')) {
    if (!$this->currentUser->hasPermission('access ' . $term->bundle() . ' vocabulary')) {
      // throw new AccessDeniedHttpException();
      $target = $this->urlGenerator->generate('<front>');
      $new_response = new RedirectResponse($target, '301');
      $event->setResponse($new_response);
    }
  }
}

Les événements à votre service

Outre le fait de pouvoir déclencher des actions en fonction de certains événements, ou encore de pouvoir altérer le comportement d'autres modules, l'utilisation des événements est une très bonne solution pour découpler votre code métier en services unitaires réutilisables dans un autre contexte.

Si nous prenons un exemple de la soumission d'un formulaire d'inscription, nous pourrions réaliser différentes opérations dans la fonction de soumission de ce formulaire : envoyer un courriel de confirmation à l'utilisateur, logguer cette action, inscrire cet utilisateur dans votre CRM, l'inscrire à votre Newsletter (option qu'il aura bien sûr choisi volontairement cela va sans dire) et envoyer un Tweet dans la foulée. Et nous aurions une fonction de soumission relativement lourde, contenant beaucoup de réactions, et difficilement maintenable.


public function submitForm(array &$form, FormStateInterface $form_state) {
  $name = $form_state->getValue('name');
  $email = $form_state->getValue('email');

  $this->mailManager->mail('example', 'signup_form', $email, .... ['name' => $name]);
  $this->logger->log('notice', 'Registration of interest submitted...');
  $this->crmManager->subscribe($name, $email);
  $this->NewsletterSubscriptionManager->add($name, $email);
  $tweet = TweetFactory::create('blah blah ' . $name . ' blah blah');
  $this->tweeter->tweet($tweet);

}

Les événements sont une bonne réponse à cette problématique, en nous permettant de propager un événement "Un utilisateur s'est inscrit", et de déclarer autant de services que nécessaires qui pourront souscrire à cet événement, ou un autre d'ailleurs. Vous obtiendrez alors un code découplé et réutilisable. Et que vous pourrez maintenir et faire évoluer plus facilement.

La fonction de soumission de notre formulaire d'inscription s'en retrouve alors plus allégée et surtout plus logique.


public function submitForm(array &$form, FormStateInterface $form_state) {
  $name = $form_state->getValue('name');
  $email = $form_state->getValue('email');

  $event = new SignupEvent($name, $email);
  $this->eventDispatcher->dispatch(SignUpEvent::SIGNUP_FORM_SUBMIT, $event);

}

Les événements sous Drupal 8 sont très certainement destinés à remplacer à terme tout le système de HOOK (ou une bonne part) qui sont encore présents dans Drupal 8 (pour des raisons de temps, ou encore de performance, pour l'instant). Le module Rules (bientôt disponible sur Drupal 8) s'appuie d'ailleurs entièrement sur les événements pour déclencher ses actions.

N'hésitez pas à les utiliser, à en créer. Une fois qu'on y a gouté on peut difficilement s'en passer.

Vous avez déjà passé le cap ? Vous avez été confronté à des inconvénients ou des limitations sur l'usage des événements ? Si c'est le cas, je serais curieux d'avoir votre retour d'expérience. Et si vous avez besoin d'un développeur Drupal, n'hésitez pas à vous servir du formulaire de contact.

 

Commentaires

Soumis par Kevin (non vérifié) le 04/05/2017 à 18:40 - Permalien

Merci pour tes explications, j'ai une petite question.

Je suis partie sur ton principe de l'envoi d'un formulaire et de tout gérer avec les events.
Pour ma part le formulaire est le processus d'une commande avec un panier.

J'essaye de voir la logique et j'en suis venu à créer une class CommandeEvent et une class CommandeSubscriber.

Toute la logique de création de l'entity commande, des lignes de commande, tu gères dans le Subscriber ?

Mais du coup la class CommandeEvent ne sert pas à grand chose hormis à faire le passage du submitForm vers le subscriber.

Voilou c'est un peu flou car nouveau pour moi à ce niveau la.

Commerce 2.x s'appuie énormément sur les events, notamment via les events de state_machine. Je ne suis pas sûr qu'ils soient allés jusqu'à créer la commande par ce biais (à vérifier) mais pourquoi pas pour une solution custom. Ta classe CommandeEvent peut servir effectivement à passer les data, mais aussi à créer ton entité commande dans son constructeur, et tu peux y créer aussi toute méthode utile pour effectuer les traitements nécessaires.

Merci pour ta réponse,

Si je fais mes traitements dans la class CommandeEvent, mes actions dans EventSubscriber non plus d'intéret ( si je comprends bien ).

L'idée c'est d'exécuter une multitudes d'actions dans un ordre précis dans l'EventSubsciber :

- créer l'entité commande
- Envoi du mail de confirmation
- log des informations

Et du coup si j'ai une app mobile qui communique via REST, j'ai juste à passer les infos de commande à l'event CommandeEvent qui elle va se charger de CommandeSubsciber.

Est ce une bonne approche pour utiliser les évènements ?

Ajouter un commentaire