A cache example in action with Drupal 8

un bobsleigh

As we say in terms of computer programming, only two things are extremely complex: naming variables and invalidating the cache. Drupal 8 has an automatic caching system activated by default that is truly revolutionary, which makes it possible to offer a cache for anonymous visitors and especially for authenticated users without any configuration. This cache system is based on three basic concepts:

  • The cache tags
  • The context cache
  • Cache duration (max-age)

Tag caches allow you to tag content, pages, page elements with very precise tags allowing to easily and accurately invalidate all pages or page elements that have these caches tags. Context caches allow you to specify the criteria by which the cache of a page can vary (by user, by path, by language, etc.) while the max-age property can be used to define a maximum duration of cache validity.

But the purpose of this post is not to go into the details of this cache system, but rather to illustrate the use of the Cache API to set up its own cache for a specific use case. Imagine the need to build a hierarchical tree for a user, a tree based on a very prolific taxonomy. Once the hierarchical tree built, with all the included business data, satisfied, you can finally consult the fruit of your work ... after a waiting time of 17s ... Ouch.

temps d'attente 16s

In order to remedy this small inconvenience, we will set up a specific cache for our business needs and use the Cache API.

Setting up a cache is relatively easy. We can also use the default cache provided by Drupal 8 (the cache.default service), or declare and use our own cache, which will then have a dedicated table but which can also be managed separately, especially if we want to put this cache on a third party service such as Redis or Memcache.

To declare our cache, let's create the my.module.services.yml file in the directory of our my_module module.

services:

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

And we declare our cache whose identifier will be my_cache.

Then within our Controller, we have to condition the computation intensive construction of our hierarchical tree to the existence or not of our cache. Which can be translated by these additional lines:

$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;
}

To have a complete Controller that could look like this example.

<?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;
  }

}

And of course it remains to invalidate the cache only on some very specific actions, actions that by their nature will modify the tree cached.

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

And then we can get a more than correct result compared to the initial waiting time divided by about 8 (the times are here extracted from a development instance)

Temps d'attente de 2s

The performance improvement is very clear here, but when invalidating the cache then the user will have to wait a while so the data is rebuilt and then cached.

We can still improve this behavior, according to the project's business constraints: is it acceptable for a user to consult data that we know to no longer be valid, and therefore to use an invalid cache, and according to some conditions and duration? . If the answer is positive, then we can still improve our cache system, using the cache that we know is invalid.

The idea here is pretty simple. If the business data cache is invalid, then we can decide to deliver despite this data while triggering, in the background and therefore not perceptible by the user, a query that will recalculate the data.

$cid = 'my_hierarchical_tree:' . $user->id();
$data_cached = $this->cacheBackend->get($cid, TRUE); // Get the cached data even if invalid.
if (!$data_cached->valid) {
  $this->queueWorker->addItem(['user' => $user->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;
}

To do this we use a TRUE parameter to allow us to recover an invalid cache, and if so, we delegate to a QueueWorker plugin the task of recalculating the data during the next cron.

We could also handle this very well just as we invalidate the cache and delegate to a Queue the reconstruction of the data in the background. This would even allow us to recalculate the data even before the user comes to consult his profile.

And in this way the problem of a calculation time too long can be solved in a sustainable way, with some compromises. It's just a matter of finding a fair compromise depending on the project and its constraints, and a Drupal 8 developer can help you to distinguish the ins and outs of each solution, or even suggest other possible optimization paths, perhaps by going back even at the root cause of the root cause of an issue encountered.

 

Ajouter un commentaire