Créer un système d'annonces simple avec Drupal 8 (seconde partie)

La première partie de cet article décrivait l'analyse et l'implémentation du système d'annonce présent sur notre site. Dans cette partie nous aborderons l'affichage de l'annonce et la gestion du cache.

Dans la première partie de cet article, nous avons créé un formulaire qui nous permet de configurer une annonce, sa date de début, sa date de fin et son contenu. Désormais, il nous faut afficher ces informations en respectant la configuration, notamment les dates.

Pour afficher des informations dans une zone définie d'un site Drupal, en dehors de la zone de contenu, nous avons un mécanisme tout désigné : les blocs. Selon la demande initiale, nous devons donc créer un bloc qui sera présent sur toutes les pages du site, lorsque l'annonce est activée et que nous sommes entre la date de début et la date de fin de l'annonce.

Création du bloc

Nous l'avons déjà couvert dans les billets issus de notre formation, la création d'un bloc passe par la création d'un Plugin, soit la forme d'une classe PHP munie d'une Annotation. Dans notre cas, comme pour le formulaire de paramétrage, nous aurons besoin de pouvoir accéder aux données stockées dans la State API et nous aurons donc une dépendance à injecter. La différence principale est que la classe BlockBase n'implémente pas déjà l'interface nécessaire et qu'il faudra donc le faire nous même. Vous le constaterez, du fait que nous manipulions un Plugin et plus juste un formulaire, les méthodes create() et __construct() auront besoin de quelques paramètres complémentaires. Voyons voir ce que l'on doit mettre dans notre fichier src/Plugin/Block/Announcement.php.

namespace Drupal\hc_announce\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\State\StateInterface;

/**
 * Provides a 'Announcement' block.
 *
 * @Block(
 *  id = "announcement",
 *  admin_label = @Translation("Announcement"),
 * )
 */
class Announcement extends BlockBase implements ContainerFactoryPluginInterface {

  /**
   * @var array
   */
  protected $config;

  /**
   * Constructs a new Announcement object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param string $plugin_definition
   *   The plugin implementation definition.
   * @param \Drupal\Core\State\StateInterface $state
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    StateInterface $state
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->config = $state->get('hc_announcement');
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('state')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function build() {
    return ['#markup' => 'Content of the block'];
  }

}

Bien, maintenant que le bloc est créé et après une petite vidange du cache, vous devriez le voir apparaître dans la liste des blocs disponibles dans l'administration du site. Une fois positionné dans la région de notre choix, on a bien la chaîne "Content of the block" qui apparaît sur le site. Tâchons de faire un petit peu mieux en affichant au moins le contenu stocké dans le State pour que les personnes en charge de l'intégration puissent commencer à travailler sur l'aspect visuel. Nous enrichissons donc la méthode build() de notre bloc comme suit.

  /**
   * {@inheritdoc}
   */
  public function build() {
    $build = [];

    $build['#title'] = $this->config['title'];
    $build['#attributes'] = [
      'class' => ['announcement'],
    ];

    $build['announcement'] = [
      '#type' => 'container',
      '#attributes' => ['class' => ['announcement__content']],
      'content' => [
        '#type' => 'processed_text',
        '#text' => $this->config['announcement']['value'],
        '#format' => $this->config['announcement']['format'],
      ],
    ];

    return $build;
  }

Cela fonctionne bien. Cependant, nous ne respectons pas l'état d'activation de l'annonce ni ses dates de début et de fin. Pour l'état de l'annonce rien de plus simple. Avec un test dans la méthode build(), il est toujours possible de renvoyer un tableau vide si l'annonce est inactive. Pour manipuler les dates, nous avons par contre besoin d'une nouvelle dépendance sur le service datetime.time du cœur. Nous ajoutons donc ce service à la méthode create(), à la méthode __construct() et l'enregistrons dans un attribut $time au niveau de l'objet. Puis, nous ajoutons le code suivant à la méthode build() pour renvoyer un bloc vide si le bloc n'est pas supposé être actif.

    if (empty($this->config['enabled'])) {
      return $build;
    }

    $now = $this->time->getRequestTime();
    $start = (new \DateTime($this->config['start_date'] . ' 00:00:00'))->getTimestamp();
    $end = (new \DateTime($this->config['end_date'] . ' 23:59:59'))->getTimestamp();
    if ($now < $start || $now > $end) {
      return $build;
    }

Trop facile ? Vous n'avez pas tort...

Le problème du cache

Comme indiqué en introduction de cet article, ce bloc va être visible sur toutes les pages du site. Il est donc particulièrement important de bien gérer son cache car sinon nous pouvons être confrontés à deux problématiques majeures :

  1. le bloc est affiché quand il est supposé être inactif ou ne s'affiche pas quand il est supposé être actif,
  2. aucune des pages du site n'est mise en cache.

Nous avions déjà abordé le sujet du cache dans Drupal 8 dans un précédent article sans toutefois aborder le cas un peu spécifique des blocs. En effet, ces derniers utilisent les mêmes métadonnées que les render arrays abordés dans l'article mais définies par des méthodes spécifiques : getCacheContexts(), getCacheMaxAge() et getCacheTags(). Ces dernières vont permettre d'indiquer au service BlockManager, comment mettre en cache le bloc dans son intégralité. Lors du rendu d'une page, si le BlockManager se voit demander un bloc qu'il considère comme ayant un cache valide, il va le renvoyer sans même tenter d'instancier sa classe ou d'appeler sa méthode build() ! Par défaut, tous les blocs sont mis en cache de façon permanente et sans aucune variante. Comme pour le reste des métadonnées de cache, ces dernières se propagent vers le conteneur. Il n'est donc pas souhaitable de se faciliter la vie en désactivant totalement le cache de notre bloc sinon cela signifierait désactiver le cache de toutes les pages du site (en vrai c'est mieux fait que ça, mais on simplifie un peu pour avancer).

Gérer la temporalité du cache

Dans notre cas bien particulier, nous allons devoir prévoir trois cas distincts :

  1. si la date de début n'est pas encore arrivée nous devons mettre en cache jusqu'à cette date,
  2. si la date de début est passée mais la date de fin pas encore, nous devons mettre en cache jusqu'à la date de fin,
  3. si la date de début et de fin sont passées ou si l'annonce est désactivée, nous devons mettre en cache de façon permanente.

Pour atteindre ce but, nous allons simplement implémenter la méthode getCacheMaxAge() de la façon suivante :

  /**
   * {@inheritdoc}
   */
  public function getCacheMaxAge() {
    $max_age = parent::getCacheMaxAge();
    if (!empty($this->config['enabled'])) {
      $now = $this->time->getRequestTime();
      $start = (new \DateTime($this->config['start_date'] . ' 00:00:00'))->getTimestamp();
      $end = (new \DateTime($this->config['end_date'] . ' 23:59:59'))->getTimestamp();

      if ($now < $start) {
        $max_age = Cache::mergeMaxAges($max_age, $start - $now);
      }
      elseif ($now < $end) {
        $max_age = Cache::mergeMaxAges($max_age, $end - $now);
      }
    }

    return $max_age;
  }

Notez l'usage de la méthode \Drupal\Core\Cache\Cache::mergeMaxAges() qui permet de proprement fusionner le max-age par défaut avec la valeur que l'on souhaite configurer. Dans notre cas, cela n'aurait rien changé de renvoyer directement la valeur calculée mais c'est une bonne habitude à prendre d'utiliser les méthodes de fusion des métadonnées de cache pour éviter les mauvaises surprises. La méthode mergeMaxAges() s'assure que l'on conserve toujours le max-age le plus contraignant (et c'est exactement pour cette raison que si on désactive le cache d'un bloc, toute la page est impactée).

Gérer les changements de configuration

Maintenance que la temporalité de notre cache est bien établie, il nous faut prendre en compte les changements qui pourraient venir de l'action d'une personne dans l'interface d'administration. En effet, si le titre ou une date change, par exemple, le cache du bloc devrait immédiatement être invalidé pour prendre en compte les nouvelles données. Dans le cas contraire, les gestionnaires du site n'auraient pas d'autre option que d'invalider la totalité du cache du site. Le mécanisme qui permet cette invalidation, ce sont les cache tags. La plupart des objets gérés par le cœur comme les entités de contenu ou de configuration disposent de cache tags pour les identifier mais ce n'est malheureusement pas le cas de la State API. Fort heureusement, c'est un problème très simple à contourner.

Tout d'abord, définissons un cache tag personnalisé et rattachons le à notre bloc grâce à l'implémentation de la méthode getCacheTags(). Son nom est arbitraire alors, comme souvent, nous allons le préfixer du nom du module pour éviter les collisions. Comme précédemment, nous allons utiliser une méthode pour fusionner ce nouveau cache tag avec d'éventuels autres définis par la classe parente.

  /**
   * {@inheritdoc}
   */
  public function getCacheTags() {
    return Cache::mergeTags(parent::getCacheTags(), ['hc_announcement_settings']);
  }

Ensuite, il nous faut juste modifier légèrement le formulaire d'administration de l'annonce pour lui demander d'invalider ce tag lors de l'enregistrement. Pour cela, nous avons besoin de faire appel au service cache_tags.invalidator que nous allons ajouter à notre injection de dépendances et à notre constructeur :

  /**
   * @var \Drupal\Core\State\StateInterface
   */
  protected $state;

  /**
   * @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface
   */
  protected $invalidator;

  /**
   * Constructs a new AnnouncementSettingsForm object.
   *
   * @param \Drupal\Core\State\StateInterface $state
   * @param \Drupal\Core\Cache\CacheTagsInvalidatorInterface $invalidator
   */
  public function __construct(StateInterface $state, CacheTagsInvalidatorInterface $invalidator) {
    $this->state = $state;
    $this->invalidator = $invalidator;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('state'),
      $container->get('cache_tags.invalidator')
    );
  }

Ceci étant fait, il ne nous reste plus qu'à ajouter une petite ligne dans la méthode submitForm() pour provoquer l'invalidation.

$this->invalidator->invalidateTags(['hc_announcement_settings']);

Un dernier "petit" problème

Désormais, notre bloc est mis en cache suivant une logique qui s'appuie sur le mécanisme d'âge maximum pour définir quand l'expiration a lieu de façon très précise. Nous nous attendons donc à ce que le bloc apparaisse ou disparaisse approximativement au moment indiqué et ça fonctionne particulièrement bien... pour les utilisateurs authentifiés...

Le module Internal Page Cache du cœur permet de mettre en cache les pages entières à destination des anonymes afin d'améliorer drastiquement les performances. Le problème, c'est que ce cache, très agressif, ne tient pas actuellement compte des métadonnées de cache et notamment pas du max-age. En attendant que le cœur change de fonctionnement, vous pouvez contourner ce problème en installant le module Cache Control Override, qui ne nécessite aucune configuration particulière.

Conclusion

Au terme de cette paire d'articles, vous devriez y voir plus clair sur la façon de créer un formulaire et un bloc ainsi que sur la gestion du cache. Par cet exemple concret, j'espère que j'aurai réussi à vous faire visualiser des concepts qui étaient traités de façon bien plus académique dans nos précédents articles. S'il reste des zones d'ombre ou si vous voulez en savoir plus sur un point en particulier, n'hésitez pas à vous manifester dans les commentaires ci-dessous.

 

Crédit photo de couverture : Sue Cro

Votre commentaire

À propos de Edouard

Photo de Edouard habillé d'un Tee Shirt Drupal 8 bleu marine

Expert technique

Après un premier contact douloureux avec Drupal en 2009 en autodidacte, j'ai suivi une formation qui m'a convaincu de mon choix technologique et m'a vraiment mis en selle. Durant plusieurs années suite à cela j'ai accompagné des entreprises locales dans le développement de leurs projets de toutes sortes, de la simple vitrine à l'intranet social en passant par le projet e-commerce.

Nous rejoindre ?

Vous avez envie de rejoindre une équipe éthique ?
Vous avez envie de faire un partenariat ?