Mettre en cache la réponse d'un callback JSON avec Drupal

Dans cet article

Pour les plus frileux de Drupal, rien que le titre de cet article peut donner mal à la tête ! Pourtant l'idée est simple : dans Drupal, nous définissons parfois des routes dont la réponse sera une JsonResponse, c'est à dire du contenu JSON. Ces routes sont généralement utilisées en callback, c'est à dire appelées en AJAX, parfois pour récupérer des données en asynchrones (chargement de points sur une carte) ou dans le cadre d'autocomplétion par exemple (barre de recherche).

Parfois également, le résultat de cette page (c'est à dire les données qu'elle renvoie) sont longues à récupérer, créer, mettre en forme, mais une fois fait ne varient pas ou (très peu). Dans ce cas, renvoyer une simple JsonResponse c'est à dire sans activation de cache, va forcer Drupal à recalculer encore et encore ces mêmes données à chaque appel.

Ce que nous allons voir, c'est donc comment mettre en place le retour de cette route, et donc de ces données.

Définition classique d'un callback JSON

Voici la définition la plus simple que nous pourrions faire d'un callback JSON, à savoir la méthode autocompleteNoCache() que nous considérons disponible depuis l'URL /api/nocache.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
 
namespace Drupal\demo\Controller;
 
use Symfony\Component\HttpFoundation\JsonResponse;
 
class AutocompleteController {
 
  /**
   * Returns some JSON content, with no caching.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   A JSON response.
   */
  public function autocompleteNoCache() {
    $results = [];
 
    // Build here the $results data.
    // ...
 
    return new JsonResponse($results, 200);
  }
}

Définition d'un callback JSON avec mise en place du cache

Reprenons la définition présentée au paragraphe précédent, et étendons là avec la gestion du cache. 

Dans Drupal, le cache se gère grâce aux métadonnées de cache, c'est à dire le trio cache tags, cache contexts et max-age. Le but n'étant pas ici de présenter le fonctionnement du cache en soit, je ne reviendrais pas dessus mais ce sera prochainement l'objet d'un article spécifique. 

Quoi qu'il en soit, nous allons substituer la JsonResponse de notre callback par une CacheableJsonResponse. Celle-ci va alors être capable de gérer les informations de cache que nous allons appliquer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php
 
namespace Drupal\demo\Controller;
 
use Drupal\Core\Cache\CacheableJsonResponse;
use Drupal\Core\Cache\CacheableMetadata;
 
class AutocompleteController {
 
  /**
   * Returns some JSON content, with caching enabled.
   *
   * @return \Drupal\Core\Cache\CacheableJsonResponse
   *   A cached JSON response.
   */
  public function autocompleteCache() {
    $results = [];
 
    // Build here the $results data.
    // ...
 
    // Build the appropriate cache metadatas.
    // You can use cache tags, contexts and max-age as needed
    $cacheMetadatas = [
      '#cache' => [
        'tags' => ['node_list'], // given for example only
      ]
    ];
 
    // Substitute the JsonResponse with a cache handled CacheableJsonResponse.
    $response = new CacheableJsonResponse($results, 200);
    $response->addCacheableDependency(CacheableMetadata::createFromRenderArray($cacheMetadatas));
    return $response;
  }
}
Par analogie aux contrôleurs classiques où $results pourrait être un render array il est courant de voir les propriétés de #cache insérées dans $results plutôt que dans un tableau séparé. Ce n'est pas gênant du tout, simplement ces données se retrouveront dans la réponse JSON également ce qui peut empêcher son traitement par une librairie jQuery ou JS qui ne s'y attendrait pas.

Analyse de performance, invalidation et erreurs

Résultat et invalidation

Supposons que les deux callbacks présentés dans cet article soient retournés via deux URLs : /api/nocache et /api/cache. Grâce à la mise en place du debug des caches, nous pouvons constater les headers suivants.

  • Pour la page /api/nocache
cache-control: must-revalidate, no-cache, private
x-drupal-cache: UNCACHEABLE (no cacheability)
x-drupal-dynamic-cache: UNCACHEABLE (no cacheability)

Les headers de debug du cache montrent une page qui ne peut être mise en cache.
 

Les headers retournés par la page montrent que celle-ci ne peut être mise en cache. C'est logique, dans ce cas, notre JsonResponse ne contient aucune information de cache de Drupal. Le header Cache-Control indique lui aussi que la page est incachable, permettant à un mécanisme externe (CDN, Varnish, etc...) de ne pas la prendre en compte.

  • Pour la page /api/cache
cache-control: max-age=86400, public
x-drupal-cache: HIT
x-drupal-dynamic-cache: MISS
x-drupal-cache-contexts
x-drupal-cache-max-age: -1 (Permanent)
x-drupal-cache-tags: http_response node_list

Les headers de debug du cache montrent une page requêtée en anonyme et servie depuis le cache Drupal.
 

Ici, les headers montrent que cette page peut être mise en cache une fois pour toute (max-age à -1 permanent), sauf si le cache tags node_list est invalidé (c'est à dire qu'un node peu importe lequel a été ajouté, modifié ou supprimé) ou que l'ensemble des réponses de Drupal sont invalidées (c'est le sens du cache tags http_response ajouté par Drupal à toutes ses réponses).

Dans notre cas, la page a été retournée par Drupal depuis son cache, en une centaine de millisecondes à peine. Nous devinons que cette requête a été faite anonymement, c'est à dire par un visiteur non connecté au site. C'est en effet Internal Cache Page qui a répondu et non Internal Dynamic Page Cache.

Le header Cache-Control renvoyé correspond bien à une page qu'un mécanisme externe (CDN, Varnish, etc...) sera autorisé à mettre en cache pour une période de 24h tel que configuré sur mon site via /admin/config/development/performance

Mettre en cache la page, c'est bien joli, encore faut-il l'invalider correctement pour avoir des infos à jour quand nécessaire. C'est à cela que sert le trio "max-age", "cache-tags" et "cache-contexts" appelés cacheability metadatas ou métadonnées de cache.
Plus d'infos à ce sujet.

Performance

La grande question est : le jeu en vaut-il la chandelle ? Et la réponse est OUI !

Pour un but de test, la construction du tableau $results va prendre au moins 1 seconde, process garanti par l'introduction d'un sleep(1). Nous avons donc deux URLs disponibles: /api/cache et /api/nocache.

Grâce à ApacheBench, nous pouvons tester la performance des deux URLs grâce aux commandes suivantes, permettant une moyenne des temps sur 100 essais pour 1 seul visiteur concurrent :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> ab -n 100 -c 1 drupal.org/api/nocache
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       88  129  70.3    111     561
Processing:  1402 1494 131.7   1437    2020
Waiting:     1399 1489 131.3   1434    2020
Total:       1500 1623 155.4   1558    2234
 
> ab -n 100 -c 1 drupal.org/api/cache
Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:       80  113  48.2    105     493
Processing:    74  109 152.9     83    1525
Waiting:       74  108 152.9     82    1524
Total:        163  222 161.4    189    1638

La différence est assez nette. Dans le premier cas, nous aurons un process qui va encore et encore reconstruire nos données et donc se prendre notre délai d'une seconde. Dans le second, la réponse est renvoyée très tôt et servie directement du cache. Notons que la colonne max de la version cache est sensiblement similaire à la médiane de la version normale. J'ai fait le test en cache à froid et je suppose donc qu'il s'agit de la première occurrence : à savoir quand le résultat n'est pas encore disponible en cache.

Mettre en cache la réponse améliore les performances dès la seconde requête. La requête initiale toutefois, celle du premier utilisateur à utiliser le callback aura le même temps que dans la version sans cache. Cette primo-requête de chauffe du cache n'est pas affectée bénéfiquement en performance par cette stratégie. Cela signifie que si votre page varie en permanence, la pertinence du cache est peut-être à ré-évaluer.

Erreur potentielle

Dans de rares cas, vous pourriez être amené à avoir un callback fonctionnel en JsonReponse, qui se met à renvoyer une erreur en CacheableJsonResponse. Cette erreur va vous effrayer : 

LogicException: The controller result claims to be providing relevant cache metadata, but leaked metadata was detected

D'une certaine façon, la raison est assez technique et provient du fonctionnement même de Drupal, du rendu des pages et de la gestion du cache. Pour approfondir le sujet et bien en comprendre la cause (et donc sa correction), je vous proposerais un article dédié à ce sujet.

D'ici là, pour ne pas vous laisser en plan, sachez que tout code qui passera par le service renderer avant d'être renvoyé dans une CacheableJsonResponse génèrera cette réponse si les métadonnées associées au rendu ne sont pas récupérées d'une manière ou d'une autre et ajoutées manuellement aux métadonnées de cache de la réponse. En somme, si vous n'avez pas fait vous-même le travail de l'algorithme de bubbling qui est fait lors du rendu dans un render context.

Comme je le disais, ces dernières phrases sont complexes bien que claires si l'on en maîtrise les concepts et je développerais tout cela spécifiquement dans un prochain article.

Ajouter un commentaire

Votre nom sera affiché publiquement avec votre commentaire.
Votre email restera privé et n'est utilisé que pour vous notifier de l'approbation de ce commentaire.
Sur internet, vous pouvez être qui vous voulez. Soyez quelqu'un de bien :)