• 8 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_video

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 27/11/2023

Optimisez votre API avec une pagination et un système de cache

Optimisez votre API avec une pagination et un système de cache

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.

En renseignant l’URL dans le GET de Postman, nous arrivons bien sur 2 livres à partir de la page 3 dans le Body.
Test avec Postman : exemple de pagination

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 !

Les données du livre avec l’id 88 en cache n’ont pas de lastName ou firstName dans le Body de Postman.
Test avec Postman : le nom et le prénom de l’auteur sont null lorsqu’on récupère les données mises en cache

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” ! 

Exemple de certificat de réussite
Exemple de certificat de réussite