Lorsqu’une application grandit, il arrive généralement un stade où il faut procéder à certaines optimisations.
Les plus classiques sont la mise en place d’un système de pagination, ce qui permet de récupérer les données petit à petit plutôt qu’en bloc, et la mise en place d’un système de cache, qui permet d’économiser du temps de traitement si plusieurs requêtes identiques sont faites à la suite.
Voyons tout de suite comment mettre ces systèmes en place !
Mettez en place une pagination
La pagination permet d’éviter de retourner toutes les données d’un coup. Si vous avez une base de données avec un million de livres, on peut facilement imaginer que notre getAllBooks
va retourner une quantité considérable de données, et que cela risque de poser des problèmes de performance.
Pour pallier ce problème, au lieu de tout retourner, ne vont être retournés que les X premiers éléments à partir de la page Y.
Récupérez la liste des livres
Pour l’instant, lorsqu’on demande la liste des livres, nous utilisons la méthode toute faite findAll()
fournie par Doctrine. Ici, l’idée va être non pas de tout récupérer, mais de récupérer un certain nombre d’éléments à partir d’un début donné.
Pour cela, la première étape va être de nous créer une nouvelle méthode findAllWithPagination
dans le BookRepository, qui attend 2 paramètres, à savoir le numéro de page et le nombre de livres à récupérer :
<?php
// src\Repository\BookRepository.php
// ...
public function findAllWithPagination($page, $limit) {
$qb = $this->createQueryBuilder('b')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
return $qb->getQuery()->getResult();
}
Ici nous utilisons le queryBuilder
de Doctrine pour créer une nouvelle requête qui va retourner l’ensemble des livres, en spécifiant :
le
firstResult
, à partir de quand nous allons récupérer les livres ;et le
maxResult
, à savoir le nombre de livres que nous voulons retourner.
Ensuite il nous suffit, dans le contrôleur, de remplacer le `findAll` initial par notre findAllWithPagination
:
<?php
// src\Controller\BookController.php
// ...
#[Route('/api/books', name: 'books', methods: ['GET'])]
public function getAllBooks(BookRepository $bookRepository, SerializerInterface $serializer, Request $request): JsonResponse
{
$page = $request->get('page', 1);
$limit = $request->get('limit', 3);
$bookList = $bookRepository->findAllWithPagination($page, $limit);
$jsonBookList = $serializer->serialize($bookList, 'json', ['groups' => 'getBooks']);
return new JsonResponse($jsonBookList, Response::HTTP_OK, [], true);
}
Notez la récupération dans la requête des paramètres page
et limit
avec une valeur par défaut. Cela signifie que si on ne spécifie rien pour le paramètre page
, alors c’est la première page
qui sera retournée. Si on ne spécifie rien pour le paramètre limit
, alors trois éléments seront retournés.
À ce stade, si nous demandons avec Postman, nous pouvons passer un paramètre page
et un paramètre limit
.
Notre URL serait donc :https://127.0.0.1:8000/api/books?page=3&limit=2
.
Et là, nous obtenons bien seulement 2 livres à partir de la page 3 !
Mettez en place un système de cache
Le principe d’un système de cache est d’éviter de recalculer la réponse à chaque fois, lorsque les mêmes requêtes sont réalisées plusieurs fois. Au lieu de cela, on va reprendre directement ce qui a été enregistré dans le cache. Ainsi, la récupération des livres en base de données ne sera faite qu’à la première requête. Pour les requêtes suivantes, les données seront directement récupérées depuis le cache.
Reprenons notre fonction getAllBooks
dans le BookController.php
pour ajouter le cache :
<?php
// src\Controller\BookController.php
// ...
#[Route('/api/books', name: 'books', methods: ['GET'])]
public function getAllBooks(BookRepository $bookRepository, SerializerInterface $serializer, Request $request, TagAwareCacheInterface $cachePool): JsonResponse
{
$page = $request->get('page', 1);
$limit = $request->get('limit', 3);
$idCache = "getAllBooks-" . $page . "-" . $limit;
$bookList = $cachePool->get($idCache, function (ItemInterface $item) use ($bookRepository, $page, $limit) {
$item->tag("booksCache");
return $bookRepository->findAllWithPagination($page, $limit);
});
$jsonBookList = $serializer->serialize($bookList, 'json', ['groups' => 'getBooks']);
return new JsonResponse($jsonBookList, Response::HTTP_OK, [], true);
}
J’ai créé ici un identifiant idCache
. Il est construit ici avec le mot getAllBooks
auquel j’ai ajouté les valeurs depage
et limit
, séparés par des tirets.
Cela permettra de faire une mise en cache différenciée par page et limit. Par exemple, la première fois qu’on demandera la 3ème page, avec 4 éléments par page, le calcul sera fait, et stocké sous l’identifiant “getallBooks-3-4”.
Le prochain appel avec ces mêmes paramètres retournera directement le résultat sans même avoir besoin d’interroger la base de données.
Ensuite, je demande à mon cache, fourni par CacheInterface
de Symfony (n’oubliez pas le use
!), de me retourner l’élément mis en cache, ici la liste de mes livres. Cela se fait avec le $cachePool->get()
.
Si rien n’a été mis en cache, alors la méthode GET ne peut rien retourner. Donc la fonction passée en second argument fait le calcul (ici la récupération de mes données), met le résultat en cache, et le retourne.
Notez la présence de $item
(qui représente l’item mis en cache) et le fait que je lui passe un tag, ”booksCache”
. Ce tag nous sera utile plus tard pour nettoyer le cache.
Gérez le lazy loading de Doctrine
Si vous avez testé la méthode juste au-dessus pour la mise en cache, vous avez probablement remarqué un petit souci. En effet, si les informations sur les livres sont bien en cache, en revanche les informations sur l’auteur sont incomplètes !
Cela est dû à ce qu’on appelle le lazy loading, ou chargement fainéant, en français.
Pour des raisons de performances qui en temps ordinaire sont complètement transparentes pour nous, lorsque Doctrine nous retourne des données, en réalité ce n’est pas l’intégralité des données qui sont retournées. Les sous-entités ne sont pas chargées avant qu’on essaie de les lire, pour les afficher ou, dans notre cas, qu’on essaie de les transformer en JSON.
Ceci permet dans de nombreux cas d’économiser du temps de traitement, mais ici, comme nous mettons en cache la réponse directement sans au préalable accéder aux données, nous ne les avons pas.
Doctrine nous retourne un objet author
avec certes l’id
, mais hélas aussi avec null
pour l’ensemble des autres champs.
L’option la plus simple pour résoudre ce problème est donc de faire la mise en cache après avoir converti les données en JSON.
Regardons la nouvelle version de notre méthode getAllBooks
du fichier BookController.php
:
<?php
// src\Controller\BookController.php
// ...
#[Route('/api/books', name: 'books', methods: ['GET'])]
public function getAllBooks(BookRepository $bookRepository, SerializerInterface $serializer, Request $request, TagAwareCacheInterface $cache): JsonResponse
{
$page = $request->get('page', 1);
$limit = $request->get('limit', 3);
$idCache = "getAllBooks-" . $page . "-" . $limit;
$jsonBookList = $cache->get($idCache, function (ItemInterface $item) use ($bookRepository, $page, $limit, $serializer) {
$item->tag("booksCache");
$bookList = $bookRepository->findAllWithPagination($page, $limit);
return $serializer->serialize($bookList, 'json', ['groups' => 'getBooks']);
});
return new JsonResponse($jsonBookList, Response::HTTP_OK, [], true);
}
Cette solution est plus pertinente à tout point de vue, car le cache va non seulement optimiser la requête à la base de données, mais également le temps de conversion des données en JSON.
Neutralisez le mécanisme de lazy loading de Doctrine
Il existe également une autre solution, sans toucher au contrôleur. Il faut dire à Doctrine, dans la méthode findAllWithPagination
, que nous ne voulons pas un lazy loading, mais bien un chargement complet des données pour que la mise en cache puisse se faire sans souci.
Voici ce que nous aurions pu écrire dans BookRepository.php
pour mettre à jour notre méthode findallWithPagination
:
<?php
// src\Repository\BookRepository.php
// ...
public function findAllWithPagination($page, $limit) {
$qb = $this->createQueryBuilder('b')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
$query = $qb->getQuery();
$query->setFetchMode(Book::class, "author", \Doctrine\ORM\Mapping\ClassMetadata::FETCH_EAGER);
return $query->getResult();
}
Le queryBuilder
ne change pas, mais nous récupérons la requête pour lui préciser son fetchMode
, c'est-à-dire son mode de récupération des données. Puis on lui spécifie FETCH_EAGER
pour lui dire de tout charger directement, en opposition à FETCH_LAZY
qui est présent par défaut.
Les deux méthodes sont tout à fait fonctionnelles, même si j’ai une préférence personnelle pour la première qui est plus optimisée.
Faites attention à la synchronisation du cache
Par exemple, si ici nous supprimons tous les éléments de la base de données, le cache retournera quand même les livres qu’il a en mémoire, ce qui est un problème.
Donc mettre en place un cache signifie également se poser la question du moment où il faut le rafraîchir. Cela peut être après un certain temps, ou alors lorsqu’une opération a eu lieu.
Par exemple, si on utilise la route delete
, comme les données ont changé, supprimer le cache pour forcer une relecture complète des données réelles peut être une bonne idée.
<?php
// src\Controller\BookController.php
// ...
#[Route('/api/books/{id}', name: 'deleteBook', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN', message: 'Vous n\'avez pas les droits suffisants pour supprimer un livre')]
public function deleteBook(Book $book, EntityManagerInterface $em, TagAwareCacheInterface $cachePool): JsonResponse
{
$cachePool->invalidateTags(["booksCache"]);
$em->remove($book);
$em->flush();
return new JsonResponse(null, Response::HTTP_NO_CONTENT);
}
Ici, dans la méthode delete
, nous avons à nouveau utilisé notre cache. Cette fois-ci, ce n’est pas pour récupérer des données, mais pour invalider le tag bookCache
.
De ce fait, tous les éléments que nous aurons créés en utilisant ce bookCache
, à savoir toutes les pages de livres, seront supprimés, et au prochain appel un rechargement sera créé.
Exercez-vous
À vous de jouer ! Mettez en place une pagination et le système de cache sur les auteurs. Surtout, n’oubliez pas de mettre en place des méthodes pour vider le cache, afin de garantir que vos données seront toujours cohérentes !
En résumé
La pagination consiste à ne retourner que certains éléments pour ne pas surcharger le client et le serveur.
Le cache permet d’éviter de recalculer ou de récupérer à nouveau des données, si on est sûr qu’elles n’ont pas changé.
Le lazy loading de Doctrine est un système qui permet de récupérer des données seulement au moment où elles sont lues.
Attention : le cache est un système très efficace pour augmenter les performances, mais doit être bien géré pour éviter de retourner des données obsolètes.
Rendez-vous dans le prochain chapitre, pour aller encore plus loin dans les API REST et finalement atteindre le “Glory of REST” !