Un exemple de cache en action avec Drupal 8

Athlètes dans un bobsley

Comme on dit en matière de programmation informatique, seules deux choses sont extrêmement complexes : le nommage des variables et l'invalidation du cache. Drupal 8 dispose d'un système de cache automatique activée par défaut proprement révolutionnaire qui permet de proposer un cache pour les visiteurs anonymes et aussi surtout pour des utilisateurs authentifiés et ceci sans aucune configuration. Ce système de cache est basé sur trois notions fondamentales :

  • Les cache tags
  • Les cache context
  • La durée du cache (max-age)

Les caches tags permettent de taguer des contenus, des pages, des éléments de page avec des tags très précis permettant de facilement et précisément invalider toutes les pages ou éléments de page disposant de ces caches tags. Les caches context permettent d'indiquer selon quels critères le cache d'une page peut varier (par utilisateur, par chemin, par language, etc.) tandis que la propriété max-age peut permettre de définir une durée maximum de validité du cache.

Mais le propos de ce billet n'est pas de rentrer dans le détail de ce système de cache, mais plutôt d'illustrer l'utilisation de la cache API permettant de mettre en place son propre cache pour un cas d'usage bien précis. Imaginons le besoin de construire un arbre hiérarchique pour un utilisateur, arbre basé sur une taxonomie très prolifique. Une fois l'arbre hiérarchique construit, avec toutes les données métier incluses, satisfait, vous pouvez enfin consulter le fruit de votre travail...après un temps d'attente de 17s...Ouch.

temps d'attente 16s

Afin de remédier à ce petit désagrément nous allons mettre en place un cache spécifique pour notre besoin métier et utiliser pour cela la cache API.

Mettre en place un cache se fait relativement aisément. Nous pouvons tout aussi bien utiliser le cache par défaut de Drupal 8 (le service cache.default), ou encore déclarer et utiliser notre propre cache, qui disposera alors d'une table dédiée mais qui pourra aussi être géré de façon distincte, notamment si nous souhaitons par la suite placer ce cache sur un service tiers tel que Redis ou encore Memcache.

Pour déclarer notre cache, créons le fichier my.module.services.yml, dans le répertoire de notre module my_module.

services:

  my_module.my_cache:
    class: Drupal\Core\Cache\CacheBackendInterface
    tags:
      - { name: cache.bin }
    factory: cache_factory:get
    arguments: [my_cache]

Et nous déclarons notre cache dont l'identifiant sera my_cache.

Puis au sein de notre Controller, il nous reste à conditionner le calcul intensif de construction de notre arbre hiérarchique à l'existence ou non de notre cache. Ce qui peut se traduire par ces quelques lignes supplémentaires :

$cid = 'my_hierarchical_tree:' . $user->id();
$data_cached = $this->cacheBackend->get($cid);
if (!$data_cached) {
  // Extensive calcul...

  // Store the tree into the cache.
  $this->cacheBackend->set($cid, $data, CacheBackendInterface::CACHE_PERMANENT, $tags);
}
else {
  $data = $data_cached->data;
  $tags = $data_cached->tags;
}

Pour avoir un Controller complet qui pourrait ressembler à cet exemple.

<?php

namespace Drupal\my_module\Controller;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Controller\ControllerBase;

/**
 * Class HomeController.
 */
class MyModuleController extends ControllerBase {

  /**
   * The cache backend service.
   *
   * @var \Drupal\Core\Cache\CacheBackendInterface
   */
  protected $cacheBackend;


  /**
   * Constructs a new HomeController object.
   */
  public function __construct(CacheBackendInterface $cache_backend) {
    $this->cacheBackend = $cache_backend;
  }

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

 /**
   * Build the hierarchical tree.
   *
   * @return array
   *   Return the render array of the hierarchical tree.
   */
  public function buildTree() {
    $user = $this->userStorage->load($this->currentUser->id());
    $argument_id = $this->getDefaultArgument($user);
 
    $cid = 'my_hierarchical_tree:' . $user->id();
    $data_cached = $this->cacheBackend->get($cid);

    if (!$data_cached) {
      $data = $this->getTree('VOCABULARY', 0, 10, $argument_id);
      $this->addUserStatusToTree($data['response'], $user);
      $this->addUserLevels($data['response']);

      $tags = isset($data['#cache']['tags']) ? $data['#cache']['tags'] : [];
      $tags = Cache::mergeTags($tags, [$cid]);

      // Store the tree into the cache.
      $this->cacheBackend->set($cid, $data, CacheBackendInterface::CACHE_PERMANENT, $tags);
    }
    else {
      $data = $data_cached->data;
      $tags = $data_cached->tags;
    }

    $build = [
      '#theme' => 'user--tree',
      '#user' => $user,
      '#data' => $data,
      '#cache' => [
        'tags' => $tags,
        'context' => ['user'],
      ],
    ];

    return $build;
  }

}

Et bien entendu il reste invalider le cache uniquement sur certaines actions très précises, actions qui de part leur nature vont modifier l'arbre mis en cache.

Cache::invalidateTags(['my_hierarchical_tree:' . $user->id()]);

Et nous pouvons obtenir alors un résultat plus que correct par rapport au délai initial d'attente divisé par 8 environ (les temps sont ici extraits depuis une instance de développement)

Temps d'attente de 2s

L'amélioration des performances est très nette ici, mais lors de l'invalidation du cache alors l'utilisateur devra attendre un certain temps afin les données soient reconstruites puis remises en cache.

Nous pouvons encore améliorer ce comportement, en fonction des contraintes métier du projet : est-il acceptable qu'un utilisateur puisse consulter des données que l'on sait ne plus être valides, et donc utiliser un cache invalide, et selon quelques conditions et durée. Si la réponse est positive, alors nous pouvons encore améliorer notre système de cache, en utilisant le cache que l'on sait invalide.

L'idée ici est assez simple. Si le cache de données métier est invalide, alors nous pouvons décider de délivrer malgré tout ces données tout en déclenchant, en tache de fond et donc de façon non perceptible par l'utilisateur, une requête qui va recalculer les données.

$cid = 'my_hierarchical_tree:' . $user->id();
$data_cached = $this->cacheBackend->get($cid, TRUE); // Nous nous autorisons de récupérer le cache même invalide.
if (!$data_cached->valid) {
  $this->queueWorker->addItem(['user' => $users->id()]);
}

if (!$data_cached) {
  // Extensive calcul...

  // Store the tree into the cache.
  $this->cacheBackend->set($cid, $data, CacheBackendInterface::CACHE_PERMANENT, $tags);
}
else {
  $data = $data_cached->data;
  $tags = $data_cached->tags;
}

Pour ce faire nous utilisons un paramètre TRUE pour nous autoriser à récupérer un cache même invalide, puis si c'est le cas nous déléguons à un plugin QueueWorker la tâche de recalculer les données au prochain cron.

Nous pourrions aussi très bien traiter cet aspect au moment même où nous invalidons le cache et déléguer à une Queue la reconstruction des données en arrière-plan. Cela nous permettrait même de pouvoir recalculer les données avant même que l'utilisateur vienne consulter son profil.

Et de cette manière la problématique d'un temps de calcul trop long peut être résolue de façon pérenne, moyennant quelques compromis. Il s'agit juste de trouver un juste compromis selon le projet et ses contraintes et un développeur Drupal 8 peut vous aider à distinguer les tenants et aboutissants de chaque solution, voire vous suggérer d'autres pistes d'optimisation possible peut-être en remontant même à la source de la source d'une problématique rencontrée.

 

 

Ajouter un commentaire