Paginer ses résultats avec Spring
Il arrive encore trop souvent de tomber sur des projets mal paginés (voire pas du tout). Or la pagination est un élément essentiel pour éviter de surcharger les serveurs et de perdre les utilisateurs.
Dans des articles précédents, je t'ai expliqué comment créer un projet avec Spring Boot et comment faire une API REST avec Spring. Aujourd'hui, je vais te montrer comment paginer et ordonner les résultats de tes requêtes. Pour cela, nous allons encore utiliser Spring. Mais d'abord, je t'expliquerai ce qu'est la pagination et pourquoi il est important de paginer les résultats de tes requêtes.
Ceci est une anecdote 100% authentique qui s'est passée voilà quelques années.
Je travaille sur un plugin qui allait récupérer des données depuis une API SOAP. Je commence par tester comment fonctionne le serveur (bien évidemment, je n'ai pas de documentation). La console du navigateur de l'IHM journalisant les requêtes exécutées, j'analyse les requêtes HTTP.
Je fais donc une requête simple, par défaut, sans paramètres. Et là, j'attends. Longtemps. Très longtemps. Et ma requête plante avec une belle erreur 500 Internal Server Error
. Je réessaie, serveur indisponible. J'attends un peu et réessaie, serveur toujours indisponible. Je me renseigne et j'apprends que le serveur est en train de redémarrer, car il a planté.
Pas de souci, je fais autre chose en attendant que le serveur soit à nouveau disponible et je recommence. J'imagine que tu as deviné la suite : le serveur plante à nouveau.
Je m'agace sur ce serveur en carton, et j'imagine qu'un autre dév doit être en train de tester un truc pas stable dessus.
Je repars à la chasse aux informations et on me dit que le serveur a replanté, car quelqu'un a fait une requête qui a retourné trop de données. Je fouille un peu et... c'est moi !
Et bien oui, il n'y a aucune limitation côté serveur. Ainsi, une requête sans paramètre retournait toutes les données de la base. Et la base était énorme, avec des liens de partout, des calculs à faire côté serveur, et j'en passe. Je râle auprès du mainteneur de l'application :
— Quand même, c'est pas sérieux ! Il faut mettre une valeur maximum par défaut à la pagination.
Et le gars me répond :
— Les résultats ne sont pas paginés. Il faut utiliser des filtres côté client pour éviter de planter le serveur. Mais ne t'inquiètes pas, les utilisateurs ont l'habitude, ça fait des années que c'est comme ça".
— Ah... D'accord... Quand même.
Je m'en vais donc voir le responsable technique de l'application étant donné que je suis peu satisfait de la réponse.
L'intéressé me dit alors :
— La pagination ? Qu'est-ce que c'est."
Un peu (juste un peu) agacé, j'explique rapidement que ça permet de limiter de la taille des réponses et, en passant, que cela éviterait à son serveur de planter lamentablement. Il prend des notes et me dit qu'il va voir ce qu'il peut faire.
Le lendemain, réunion de crise surprise avec tous les grands responsables techniques disponibles ainsi que le responsable du département. Que des gens en costard cravates. Et moi. Le responsable technique de la veille me demande :
— Peux-tu nous expliquer la pagination ?
La pagination des résultats : avantages et inconvénients
Paginer, c'est découper les résultats d'une requête en plusieurs pages. Comme un livre. Cela permet de limiter la taille des réponses et d'éviter de charger trop d'informations d'un coup. Mais si, tu connais : ton moteur de recherche préféré et tes sites d'e-commerce paginent généralement de façon très factuelle. Tu as 10 résultats et en bas, tu peux changer de page.
Tous les logiciels qui permettent de faire des requêtes à une base de données paginent par défaut pour éviter d'écrouler le serveur. Même la console H2. Mais si, tu connais, je t'en ai parlé dans cet article.
Tu as aussi une pagination invisible et automatique : la page charge les premiers résultats et quand tu les parcoures et que tu arrives vers la fin, la page charge les résultats suivants. C'est fluide et invisible pour l'utilisateur. Sauf les bourrins qui appuient sur la touche "fin" pour aller directement à la fin de la page.
La pagination sauvera ton serveur
La pagination a plusieurs utilités très concrètes côté serveur :
- Elle réduit la quantité de données que le serveur doit traiter. Moins de ressource, mémoire et processeur, sont donc utilisées.
- Si le serveur doit interroger d'autres serveurs, comme une base de données, cela réduit la quantité de données à transférer.
- Elle augmente la réactivité du serveur, car les requêtes sont plus rapides à traiter et à transférer.
- Cela stabilise le serveur qui sera moins sujet à des surcharges qui le feront ralentir.
La pagination sauvera ton client
Côté client, la pagination a aussi des avantages :
- Elle réduit le temps de chargement des pages et la consommation de données, car moins de données sont transférées.
- Elle permet de naviguer plus facilement dans les résultats, car les pages sont plus légères et plus rapides à charger. Et on peut aller directement à la page souhaitée sans passer par les pages intermédiaires.
- Dans le cas d'une pagination automatique, la première page s'affiche rapidement et les suivantes se chargent en arrière-plan. Cela donne l'impression que la page est plus rapide à charger.
- Elle évite à l'utilisateur se de retrouver submergé par un flot d'information qu'il ne traitera pas.
Les inconvénients de la pagination
Parce que rien n'est parfait, la pagination a aussi ses inconvénients :
- Elle peut être frustrante pour l'utilisateur qui doit naviguer entre les pages pour trouver l'information qu'il cherche.
- Elle peut entraîner une perte de contexte si l'utilisateur doit naviguer entre les pages pour trouver l'information qu'il cherche.
- Elle peut être difficile à mettre en place si les données ne sont pas bien structurées ou si les requêtes sont complexes.
- Elle peut être difficile à maintenir si les données changent fréquemment ou si les requêtes sont complexes.
- Si elle est mal implémentée, elle peut entraîner des erreurs ou des incohérences dans les résultats.
Les grands principes de la pagination
Le principe général de la pagination est simple. Il faut juste limiter le nombre de résultats retournés par une requête. Pour cela, on a besoin de deux paramètres : le nombre de résultats par page et le numéro de la page.
Exemple : si tu veux afficher 10 résultats par page, tu demandes la page 1, tu auras les résultats de 1 à 10. Si tu demandes la page 2, tu auras les résultats de 11 à 20. Et ainsi de suite.
En SQL, utilise plutôt LIMIT
et OFFSET
pour paginer les résultats :
SELECT * FROM table
LIMIT 10 OFFSET 10; -- page 2
Cette requête retournera les résultats de 11 à 20.
Selon le SGBD, il peut y avoir des différences dans la syntaxe, mais le principe reste le même.
Pour que tes résultats soient cohérents, il faut aussi les trier. Si tu ne le fais pas, tu auras potentiellement des résultats différents à chaque requête. Va expliquer à tes utilisateurs que les résultats changent à chaque fois qu'ils rafraîchissent la page. Si tu le fais pour de vrai, filme leurs réactions et envoie-les-moi s'il te plaît, que je me marre un peu.
Et quand tu renvoies tes résultats, n'oublie pas de renvoyer le nombre total de résultats et de pages. Cela permettra à l'utilisateur de savoir combien de pages il y a et combien de résultats il y a en tout.
En général, il faut renvoyer toutes les informations de la page. Cela permet au client de savoir où il en est et de naviguer plus facilement entre les pages :
- le numéro de la page,
- le nombre de résultats par page,
- le nombre total de résultats,
- le nombre total de pages
- et bien sûr les résultats de la page.
Pense à limiter la taille maximale de tes pages, car sinon, tu auras toujours un petit malin pour te faire des pages d'un milliard de résultats.
La pagination a besoin de trois informations pour fonctionner :
- Le numéro de la page.
- Le nombre de résultats par page.
- Le tri des résultats.
La pagination dans Spring MVC
Et maintenant, on va Springer© !
Une pagination rapide, mais sans contrôle
Dans mon article précédent sur Spring, nous avions écrit un sac fourre tout. Reprenons le controller et modifions-le avec d'ajouter la pagination au findAll
:
package fr.nvh.begin.spring.bag;
import fr.nvh.begin.spring.bag.use.cases.SomethingFindPageUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/something")
public class SomethingController {
private final SomethingFindPageUseCase somethingFindPageUseCase;
@ResponseStatus(HttpStatus.PARTIAL_CONTENT) // indique que la méthode retourne un code HTTP 206
@GetMapping("/page/flemmard") // généralement, on supprime le findAll et on garde le findPage, mais là, je garde les deux pour l'exemple
public Page<SomethingDto> findPage(Pageable page) {
log.info("GET /api/something/page {}", page);
return somethingFindPageUseCase.findPage(page); // appel du use case
}
//... reste du controller
}
Bien sûr, je ne t'ai mis que ce qu'il y a à ajouter. Le controller initial à compléter se trouve dans cet article.
Spring va se charger de tout pour toi. Tu pourras appeler ton endpoint avec les paramètres page
et size
pour paginer tes résultats.
Tu peux aussi ajouter des paramètres pour trier les résultats. Pour cela, tu peux ajouter un paramètre sort
à ta méthode avec comme valeur le nom du champ à trier. Par exemple, si tu veux trier par name
, tu ajoutes sort=name
à ta requête. Si tu veux choisir le sens du tri, tu ajoutes sort=name,asc
ou sort=name,desc
. Par défaut, le tri est ascendant. Et si tu veux ajouter un autre champ de tri, tu ajoutes un autre paramètre sort
, comme ça : sort=name,asc&sort=age,desc
.
GET http://localhost:8080/api/something/page/flemmard?page=0&size=10&sort=name,asc
Cette requête retournera les 10 premiers résultats triés par nom dans l'ordre croissant.
Avec Pageable
, le nombre de pages commence à 0 ! Si tu n'y fais pas attention et que tu fais commencer ton numéro de page à 1, il te manquera toujours la première page.
Et paf, ça fait des Chocabugs©.
La pagination avec plus de contrôle
C'est la version pour les flemmards où tu laisses Spring faire le boulot pour toi. Voyons un peu une version avec plus de contrôle :
package fr.nvh.begin.spring.bag;
import fr.nvh.begin.spring.bag.use.cases.SomethingFindPageUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/something")
public class SomethingController {
private final SomethingFindPageUseCase somethingFindPageUseCase;
@ResponseStatus(HttpStatus.PARTIAL_CONTENT) // indique que la méthode retourne un code HTTP 206
@GetMapping("/page/control")
// généralement, on supprime le findAll et on garde le findPage, mais là, je garde les deux pour l'exemple
public Page<SomethingDto> findPage(int page, int size, String sort) {
log.info("GET /api/something/page?page={}&size={}&sort={}", page, size, sort);
Pageable pageable = PageRequest.of(page, size, Sort.by(sort));
return somethingFindPageUseCase.findPage(pageable); // appel du use case
}
}
Dans cette version, tu passes les paramètres et tu crées ton Pageable
. Cela te permet de gérer des minimums, des maximums, un tri par défaut, etc. Tu peux aussi ajouter des contrôles sur les paramètres pour éviter les erreurs.
Tu peux faire des choses du genre :
package fr.nvh.begin.spring.bag;
import fr.nvh.begin.spring.bag.use.cases.SomethingFindPageUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
@Slf4j
@RequiredArgsConstructor
//@RestController
@RequestMapping("/api/something")
public class SomethingController {
private final SomethingFindPageUseCase somethingFindPageUseCase;
@ResponseStatus(HttpStatus.PARTIAL_CONTENT) // indique que la méthode retourne un code HTTP 206
@GetMapping("/page/control/plus")
// généralement, on supprime le findAll et on garde le findPage, mais là, je garde les deux pour l'exemple
public Page<SomethingDto> findPagePlus(int page, int size, String sort) {
log.info("GET /api/something/page/control/plus?page={}&size={}&sort={}", page, size, sort);
Pageable pageable = buildPageable(page, size, sort);
return somethingFindPageUseCase.findPage(pageable); // appel du use case
}
/**
* Une petite méthode à part pour que ce soit plus clair
*/
private static Pageable buildPageable(int paramPage, int paramSize, String paramSort) {
int page = Math.min(0, paramPage); // pas de numéro de page négatif
int size = Math.clamp(paramSize, 5, 20); // pas de taille d'un million
String sort; // on vérifie que le paramètre de tri est correct
if (paramSort.equalsIgnoreCase("name")
|| paramSort.equalsIgnoreCase("id")
|| paramSort.equalsIgnoreCase("description")) {
sort = paramSort;
} else {
// si le paramètre de tri n'est pas correct, ou null, on trie par nom
sort = "name";
}
return PageRequest.of(page, size, Sort.by(sort));
}
}
Tu peux du coup gérer ta pagination comme tu l'entends. Par exemple :
- ajouter un tri par défaut, car sans tri, la pagination peut être buguée.
- ajouter un mapping pour que les noms des champs du tri qui correspondent généralement à des champs du DTO correspondent aux noms des champs de la base de données.
- ajouter des contrôles sur les paramètres
page
etsize
pour éviter les erreurs. - changer le nom des paramètres.
- changer l'emplacement des paramètres, par exemple dans le header de la requête.
Bref, tu as le contrôle absolu sur ta pagination.
Le résultat de la pagination
Le résultat de la pagination est un objet Page
qui contient :
content
: les résultats de la page.pageable
: les informations de la pagination qui ont été envoyées.- Et ensuite, en vrac plein d'informations sur la page : le nombre de pages, le nombre de résultats par page, le nombre total de résultats, le nombre total de pages, est-ce que c'est la première ou la dernière page, etc.
Tu peux donc renvoyer cet objet directement à ton client. Il pourra ainsi gérer l'affichage de tes pages.
Voici à quoi cela ressemble en JSON :
{
"content": [
{
"id": 14,
"name": "Thing 12",
"description": "HNNNOQSYBN"
},
{
"id": 15,
"name": "Thing 13",
"description": "MZSGVSDRWR"
},
{
"id": 16,
"name": "Thing 14",
"description": "XMLIPBQJWC"
},
{
"id": 17,
"name": "Thing 15",
"description": "UHOGWJCPQQ"
},
{
"id": 18,
"name": "Thing 16",
"description": "KGBZWEXIZM"
}
],
"pageable": {
"pageNumber": 1,
"pageSize": 5,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"offset": 5,
"paged": true,
"unpaged": false
},
"last": false,
"totalElements": 101,
"totalPages": 21,
"size": 5,
"number": 1,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"first": false,
"numberOfElements": 5,
"empty": false
}
Si tes besoins sont différents, tu peux créer ton propre objet de retour, quelque chose d'équivalent à Page
. Par contre, tu devras tout faire manuellement.
Le Use Case
Nous venons de voir le Controller
, passant au Use Case
. Je t'ai fait utiliser Pageable
pour gérer les paramètres de pagination et Page
pour le retour pour une bonne raison. En effet, Spring utilise par défaut du Controller
au Repository
.
package fr.nvh.begin.spring.bag.use.cases;
import fr.nvh.begin.spring.bag.SomethingDto;
import fr.nvh.begin.spring.bag.SomethingEntity;
import fr.nvh.begin.spring.bag.SomethingMapper;
import fr.nvh.begin.spring.bag.SomethingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;
import java.util.List;
@RequiredArgsConstructor
@Component
public class SomethingFindPageUseCase {
private final SomethingRepository repository;
private final SomethingMapper mapper;
/**
* La méthode findAll(pageable) est fourni par l'interface {@link org.springframework.data.repository.PagingAndSortingRepository}, qui est incluse dans {@link org.springframework.data.jpa.repository.JpaRepository}
*/
public Page<SomethingDto> findPage(Pageable pageable) {
Page<SomethingEntity> paginatedResult = repository.findAll(pageable); // Une belle Page
List<SomethingDto> pageContent = paginatedResult
.stream()
.map(mapper::toDto) // on transforme les entités en DTO
.toList();
return new PageImpl<>(pageContent, pageable, paginatedResult.getTotalElements());// on refait un page, mais avec les DTO
}
}
Voilà. Tu as ta pagination.
Si tu veux avoir ta propre implémentation de Page
, tu peux l'appeler ici.
Et n'oublie pas de vérifier quelles sont les interfaces que tu étends dans ton propre Repository
, sinon tu n'auras pas la méthode findAll(pageable)
.
Voici quelques liens pour approfondir les sujets abordés dans cet article en attendant le prochain article !
- Spring :
- Les sites officiels :
-
mettre des liens
-
- Baeldung :
Conclusion
J'ai commencé par te raconter une belle histoire que j'ai vécue.
Je t'ai expliqué ce qu'était la pagination : le pourquoi, le comment, les avantages et les inconvénients.
Je t'ai posé les bases techniques de la pagination, ce dont tu as absolument besoin pour paginer tes résultats.
Et enfin, je t'ai montré comment paginer tes résultats avec Spring MVC.
J'espère que tu as appris des choses. Et j'espère que tu as envie d'en apprendre encore plus. Parce que ce n'est pas fini. Et je te donne rendez-vous au prochain article pour filtrer tes résultats grâce aux Specification
de Spring.
Si tu as envie que j'explique plus avant certains concepts ou que je parle de quelque chose en particulier, n'hésite pas à me le dire dans les commentaires.
Pour ne pas rater les prochains articles, abonne-toi à un flux (tu as le choix entre RSS, Atom et Json) ou à la newsletter en page d'accueil !
Et bon entrainement !