Aller au contenu principal

Gestion via EventSubscriber

Image
Par Thierry V le

Bon bon bon... ça c'est un joli morceau. 
EventSubscriber, dispatchEvent, custom events... ça vous parle ?

J'ai pas mal travaillé avec Drupal 11 ces derniers temps, et toute une série de choses ont changé. J'aime particulièrement les annotations #[Hook('...')], qui permettent de déclarer certains (j'ai bien dit certains) hook dans un fichier de classe plutôt que de passer par les méthodes dans le fichier .module. C'est une belle avancée car on peut enfin déclarer des hooks dans des classes proprement, tout en utiliser l'injection de dépendances pour utiliser les services adéquats. Mais là, je m'étends sur un autre sujet :-D

Pour aujourd'hui, nous allons parler de la gestion des évènements via Symfony\Component\EventDispatcher\EventDispatcherInterface. Comme vous l'aurez compris, ce n'est pas un système propre à Drupal, mais un système bien implanté dans le framework symfony. 

Il est certain que beaucoup d'entre-vous qui lirons cet article auront déjà soit une vague idée, soit une bonne compréhension du système de propagation des évènements. C'est un système qui est utilisé dans beaucoup de langages / frameworks. Pour donner un simple exemple, la fonction trigger qu'on peut utiliser en JQuery est une sorte de dispatcher d'évènement.

Bref... On va tout de même faire un petit rappel, et expliquer pourquoi c'est super intéressant dans Drupal. 

On va commencer par un petit historique... Dans Drupal, aussi loin que je me souvienne, on avait une façon un peu spéciale (et très "procédurale") de s'abonner à un évènement, à savoir les "hooks". En gros, le noyau Drupal ou les modules de contribution proposaient des "hooks" pour intercepter un processus, modifier ou exécuter quelque chose durant cette interception, puis laisser le processus continuer à se dérouler. hook_entity_save, hook_entity_delete, hook_theme_suggestions_alter, hook_theme, etc...

Prenons un exemple simple : hook_entity_save est déclenché lorsqu'on va sauvegarder une entité. Tous les modules qui implémentent ce hook vont pouvoir "intercepter" la sauvegarde d'une entité et faire leur petite cuisine interne dessus avant que l'entité soit réellement sauvegardée. On va donc par exemple pouvoir modifier une ou plusieurs valeurs, ou encore en rajouter, avant que la sauvegarde ne soit réellement effective. On implémente donc ce hook pour altérer une entité. hook_entity_save est appelé pour toute entité en passe d'être sauvegardée. On va donc pouvoir tester le type de l'entité en cours de traitement, et ajouter sa sauce selon qu'on le veuille ou pas.

Bref... le système de hook, c'est un truc bien implanté dans Drupal depuis fort fort lointain, mais c'était un peu "bâtard", comme méthode. Quand on part sur du dev un peu balèze, ça devient vite pénible de ne pas pouvoir intégrer les interceptions directement dans nos classes. 

Alors voilà qu'arrive le event dispatcher !! Bon, il n'est pas vraiment nouveau, il y a d'ailleurs un module qui permettait de l'utiliser et d'intercepter plein d'évènements depuis un moment déjà, j'ai nommé hook_event_dispatcher. Néanmoins, c'est un module de contrib, et il n'a pas été intégré dans le noyau. Donc se baser sur lui reste un peu hacky et n'est pas forcément la meilleure façon de faire. Mais là, ce qui est intéressant depuis Drupal 10/11, c'est qu'une partie des hooks sont en train d'être migrés sous forme d'évènements dispatchés, afin de pouvoir s'abonner dessus et ajouter les process qu'on veut par dessus l'existant.

Alors bon, c'est un peu verbeux, ça demande un peu de code (carrément), mais c'est la solution "long terme" à utiliser pour les futures évolutions de Drupal. 

On va donc reprendre et tenter un petit schéma mental pour que ça soit plus clair. 

  • On a un service : \Symfony\Component\EventDispatcher\EventDispatcher(Interface)
  • On a une classe "Event" de base : \Symfony\Contracts\EventDispatcher\Event
  • On a une interface pour enregistrer l'écoute aux évènements : \Symfony\Component\EventDispatcher\EventSubscriberInterface
  • Et enfin, on a un tag de service : event_subscriber

Et c'est à partir de ça qu'on va pouvoir propager des évènements, et les écouter.

On va prendre un exemple pour illustrer tout ça... Admettons un module qui permet de gérer des templates qui vont pouvoir être utilisés dans le système. Ces templates peuvent provenir du module lui-même, mais d'autres modules pourraient également déclarer des templates pour permettre d'étendre le module de base. L'idée ici, c'est donc de créer un gestionnaire de template, qui va propager un évènement pour "récolter" la liste des templates disponibles dans tous les modules, afin de les proposer en utilisation.

On va donc créer une classe d'évènement qui va permettre de propager un évènement de ce type. Pour ce faire, on va, dans notre module de base, créer 2 classes : 

La première va permettre de lister les évènements disponibles.

<?php

namespace Drupal\template_manager\Event;

/**
 * Defines events for the template manager module.
 */
final class TemplateManagerEvents {

  /**
   * Fired when the entity templates list is requested.
   */
  const ENTITY_TEMPLATES = 'template_manager.entity_templates';

}

La seconde va définir l'évènement lui-même : 

<?php

namespace Drupal\template_manager\Event;

use Symfony\Contracts\EventDispatcher\Event;

/**
 * Fired to retrieve entity templates.
 */
class EntityTemplatesEvent extends Event {

  /**
   * The event name.
   *
   * @var string
   */
  public const NAME = 'entity_templates';

  /**
   * The current theme.
   *
   * @var string
   */
  protected string $theme;

  /**
   * The templates store.
   *
   * @var array
   */
  protected array $templates = [];

  /**
   * Constructor.
   *
   * @param string $theme
   *   The current theme.
   */
  public function __construct(string $theme) {
    $this->theme = $theme;
  }

  /**
   * Get the current theme.
   *
   * @return string
   *   The current theme.
   */
  public function getTheme(): string {
    return $this->theme;
  }

  /**
   * Get the configurations.
   *
   * @return array
   *   Retrieve the configurations.
   */
  public function getTemplates(): array {
    return $this->templates;
  }

  /**
   * Add a new template.
   *
   * @param string $key
   *   The template key.
   * @param array $template
   *   The template array.
   */
  public function addTemplate(string $key, array $template): void {
    $this->templates[$key] = $template;
  }

}

Assez basique, mais tout de même. Comme on peut le voir, cet évènement stocke plusieurs informations. Premièrement, le thème utilisé, et deuxièmement, un tableau qui va permettre de stocker les templates. Vous noterez que la classe se trouve dans une hiérarchie bien particulière : src/Event

Là, on a déjà une bonne partie du système. 

L'étape suivante, c'est d'utiliser l'eventDispatcher pour "propager l'évènement". En gros, on va propager une instance de notre classe d'évènement, à laquelle on aura passé l'information concernant le thème. C'est la partie facile : 

  /**
   * {@inheritDoc}
   */
  public function getTemplates(): array {

    if ($this->templates === NULL) {
      $event = new EntityTemplatesEvent($this->theme);
      $this->eventDispatcher->dispatch($event, TemplateManagerEvents::ENTITY_TEMPLATES);
      $this->templates = $event->getTemplates();
    }
    return $this->templates;
  }

Dans mon cas, mon "template manager" possède une méthode "getTemplates" qui permet de récupérer la liste des templates. Vu qu'il n'est que rarement appelé dans le système, je peux assez facilement le faire de cette manière là. Si il était appelé souvent, il serait surement plus adéquat de stocker les templates dans la base de données (mise en cache) pour éviter d'appeler trop souvent le dispatcher. 

Qu'est-ce qui se passe ici ? 

Tout d'abord, j'instancie une nouvelle instance de ma classe EntityTemplatesEvent en lui passant le thème en cours.

Ensuite, je propage l'évènement TemplateManagerEvents::ENTITY_TEMPLATES. $this->eventDispatcher est une instance du service @event_dispatcher qui a été passée au constructeur de mon service TemplateManager. Et je passe mon instance de classe EntityTemplatesEvent pour la propagation.

Enfin, je récupère la liste des templates qui ont été ajoutés dans mon évènement.

Rien de sorcier. L'avantage ici, c'est de pouvoir stocker les infos dans l'évènements pendant toutes les interceptions, et de pouvoir les récupérer ensuite. On peut passer des paramètres à notre évènement (ici : $this->theme) et on peut "alimenter" notre évènements pour récupérer des données quand tous les écouteurs ont fait leur travail.

Tiens... un écouteur... Qu'est-ce donc que cette bizarrerie ? C'est l'élément central, voyons. On a un évènement qui peut stocker des données. On a un eventDispatcher qui peut "propager" ledit évènement. Mais qui s'y "abonne" ? Comment fait-on pour réellement ajouter nos templates ? C'est là que les écouteurs interviennent.

Un écouteur, c'est une classe de type EventSubscriber. Cette dernière permet de s'abonner à un ou plusieurs évènements et d'implémenter une méthode qui sera exécutée pour chaque dispatch d'évènement en écoute. Pour créer un subscriber, il y a 2 choses à faire : 

  1. Créer une classe qui implémente EventSubscriberInterface ;
  2. Créer une nouvelle entrée dans le fichier services.yml du module qui veut se mettre en écoute ;

Commençons par notre classe : 

<?php

namespace Drupal\template_manager\EventSubscriber;

use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\template_manager\Event\EntityTemplatesEvent;
use Drupal\template_manager\Event\TemplateManagerEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Subscribes to configuration save events to track shared config usage.
 */
class EntityTemplatesSubscriber implements EventSubscriberInterface {

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents(): array {
    return [
      TemplateManagerEvents::ENTITY_TEMPLATES => 'onEntityTemplates',
    ];
  }

  /**
   * Handles entity templates event.
   *
   * @param Drupal\template_manager\Event\EntityTemplatesEvent $event
   *   The entity templates event.
   */
  public function onEntityTemplates(EntityTemplatesEvent $event): void {
    if ($event->getTheme() === 'my_theme') {
      $event->addTemplate(
        'my_template',
        [
          'default' => [
            'teaser' => [
              'standard' => [
                'image' => 'image/standard.png',
                'template' => 'mytemplate__default__teaser__standard',
                'label' => new TranslatableMarkup('Standard'),
              ],
            ],
          ],
        ],
      );
    }
  }
}

Ce qu'on peut constater ici : 

  • Le namespace utilisé est important. La classe se trouve dans src/EventSubscriber.
  • implémentation de la méthode getSubscribedEvents(). Cette dernière permet de s'abonner aux évènements. Ici je m'abonne à mon évènement custom, mais on pourrait tout aussi bien s'abonner à des évènements du noyau ou de modules de contribution.
  • Implémentation d'une méthode onEntityTemplates(EntityTemplatesEvent $event). C'est la méthode qu'on a défini dans getSubscribedEvents et qui va être exécutée à chaque fois que le dispatcher aura propagé mon évènement TemplateManagerEvents::ENTITY_TEMPLATES.
  • Dans la méthode onEntityTemplates, on peut récupérer la variable $theme de notre évènement pour la tester et exécuter le nécessaire, à savoir ici ajouter un nouveau template dans la liste des templates. Cette liste de templates sera récupérée quand tous les subscribers auront fait leur travail.

Il ne reste plus qu'à déclarer le service d'écoute dans le fichier services.yml : 

  template_manager.entity_templates:
    class: Drupal\template_manager\EventSubscriber\EntityTemplatesSubscriber
    tags:
      - { name: event_subscriber }

Puis vider les caches, et le subscriber sera actif.

À partir de là, n'importe quel module peut s'abonner à cet évènement, créer une classe qui implémente eventSubscriberInterface et l'ajouter dans son fichier services.yml pour se mettre à l'écoute et ajouter un ou plusieurs nouveaux templates dans le système.

C'était un simple exemple, mais vous pouvez imaginer le potentiel de ce système. À terme, Drupal devrait s'appuyer plus fortement sur EventDispatcher et éliminer petit à petit les hook standard car EventDispatcher est un système éprouvé dans de nombreux framework. Néanmoins, le système de hook va certainement perdurer encore un bon moment, le temps que tout le noyau et les modules de contributions fassent la bascule. 

Bon dispatch !! :-)