Lors de la création d'API REST qui renvoient une liste de ressources, il est crucial de réfléchir à la manière de gérer les grands ensembles de données. Renvoyer des milliers, voire des millions d'enregistrements dans une seule réponse d'API est impraticable et peut entraîner des problèmes de performances importants, une consommation de mémoire élevée pour le serveur et le client, et une mauvaise expérience utilisateur. La pagination est la solution standard à ce problème. Elle consiste à diviser un grand ensemble de données en morceaux plus petits et gérables appelés « pages », qui sont ensuite servis de manière séquentielle. Ce tutoriel vous guidera à travers les étapes techniques de la mise en œuvre de diverses stratégies de pagination dans vos API REST.
Vous voulez une plateforme intégrée, tout-en-un, pour que votre équipe de développeurs travaille ensemble avec une productivité maximale ?
Apidog répond à toutes vos demandes et remplace Postman à un prix beaucoup plus abordable !
Pourquoi la pagination est-elle essentielle ?
Avant de plonger dans les détails de la mise en œuvre, abordons brièvement pourquoi la pagination est une fonctionnalité non négociable pour les API traitant des collections de ressources :
- Performance : la demande et le transfert de grandes quantités de données peuvent être lents. La pagination réduit la taille de la charge utile de chaque requête, ce qui entraîne des temps de réponse plus rapides et une réduction de la charge du serveur.
- Consommation de ressources : des réponses plus petites consomment moins de mémoire sur le serveur qui les génère et sur le client qui les analyse. Ceci est particulièrement important pour les clients mobiles ou les environnements aux ressources limitées.
- Limitation du débit et quotas : de nombreuses API appliquent des limites de débit. La pagination aide les clients à rester dans ces limites en récupérant les données par petits morceaux au fil du temps, plutôt que d'essayer de tout obtenir en une seule fois.
- Expérience utilisateur : pour les interfaces utilisateur qui consomment l'API, la présentation des données par pages est beaucoup plus conviviale que d'accabler les utilisateurs avec une liste énorme ou un défilement très long.
- Efficacité de la base de données : la récupération d'un sous-ensemble de données est généralement moins exigeante pour la base de données que la récupération d'une table entière, en particulier si une indexation appropriée est en place.
Stratégies de pagination courantes
Il existe plusieurs stratégies courantes pour la mise en œuvre de la pagination, chacune ayant ses propres compromis. Nous allons explorer les plus populaires : offset/limit (souvent appelé basé sur les pages) et basé sur le curseur (également connu sous le nom de pagination keyset ou seek).
1. Offset/Limit (ou pagination basée sur les pages)
Il s'agit sans doute de la méthode de pagination la plus simple et la plus largement adoptée. Elle fonctionne en permettant au client de spécifier deux paramètres principaux :
offset
: le nombre d'enregistrements à ignorer à partir du début de l'ensemble de données.limit
: le nombre maximal d'enregistrements à renvoyer dans une seule page.
Alternativement, les clients peuvent spécifier :
page
: le numéro de page qu'ils souhaitent récupérer.pageSize
(ouper_page
,limit
) : le nombre d'enregistrements par page.
Le offset
peut être calculé à partir de page
et pageSize
en utilisant la formule : offset = (page - 1) * pageSize
.
Étapes techniques de la mise en œuvre :
Supposons que nous ayons un point de terminaison d'API /items
qui renvoie une liste d'éléments.
a. Paramètres de la requête API :
Le client effectuerait une requête comme :GET /items?offset=20&limit=10
(récupérer 10 éléments, en ignorant les 20 premiers)
ouGET /items?page=3&pageSize=10
(récupérer la 3e page, avec 10 éléments par page, ce qui équivaut à offset=20, limit=10).
Il est de bonne pratique de définir des valeurs par défaut pour ces paramètres (par exemple, limit=20
, offset=0
ou page=1
, pageSize=20
) si le client ne les fournit pas. De plus, appliquez une limit
ou pageSize
maximale pour empêcher les clients de demander un nombre excessivement important d'enregistrements, ce qui pourrait solliciter le serveur.
b. Logique du backend (conceptuelle) :
Lorsque le serveur reçoit cette requête, il doit traduire ces paramètres en une requête de base de données.
// Exemple en Java avec Spring Boot
@GetMapping("/items")
public ResponseEntity<PaginatedResponse<Item>> getItems(
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "20") int limit
) {
// Valider la limite pour éviter les abus
if (limit > 100) {
limit = 100; // Appliquer une limite maximale
}
List<Item> items = itemRepository.findItemsWithOffsetLimit(offset, limit);
long totalItems = itemRepository.countTotalItems(); // Pour les métadonnées
// Construire et renvoyer une réponse paginée
// ...
}
c. Requête de base de données (exemple SQL) :
La plupart des bases de données relationnelles prennent en charge directement les clauses offset et limit.
Pour PostgreSQL ou MySQL :
SELECT *
FROM items
ORDER BY created_at DESC -- L'ordre cohérent est crucial pour une pagination stable
LIMIT 10 -- Ceci est le paramètre 'limit'
OFFSET 20; -- Ceci est le paramètre 'offset'
Pour SQL Server (les versions antérieures peuvent utiliser ROW_NUMBER()
) :
SELECT *
FROM items
ORDER BY created_at DESC
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;
Pour Oracle :
SELECT *
FROM (
SELECT i.*, ROWNUM rnum
FROM (
SELECT *
FROM items
ORDER BY created_at DESC
) i
WHERE ROWNUM <= 20 + 10 -- offset + limit
)
WHERE rnum > 20; -- offset
Remarque importante sur le tri : pour que la pagination offset/limit soit fiable, l'ensemble de données sous-jacent doit être trié par une clé cohérente et unique (ou presque unique), ou une combinaison de clés. Si l'ordre des éléments peut changer entre les requêtes (par exemple, de nouveaux éléments sont insérés ou des éléments sont mis à jour d'une manière qui affecte leur ordre de tri), les utilisateurs peuvent voir des éléments en double ou manquer des éléments lors de la navigation dans les pages. Un choix courant consiste à trier par horodatage de création ou par ID principal.
d. Structure de la réponse API :
Une bonne réponse paginée doit non seulement inclure les données de la page actuelle, mais également des métadonnées pour aider le client à naviguer.
{
"data": [
// tableau d'éléments pour la page actuelle
{ "id": "item_21", "name": "Item 21", ... },
{ "id": "item_22", "name": "Item 22", ... },
// ... jusqu'à 'limit' éléments
{ "id": "item_30", "name": "Item 30", ... }
],
"pagination": {
"offset": 20,
"limit": 10,
"totalItems": 5000, // Nombre total d'éléments disponibles
"totalPages": 500, // Calculé comme ceil(totalItems / limit)
"currentPage": 3 // Calculé comme (offset / limit) + 1
},
"links": { // Liens HATEOAS pour la navigation
"self": "/items?offset=20&limit=10",
"first": "/items?offset=0&limit=10",
"prev": "/items?offset=10&limit=10", // Null si sur la première page
"next": "/items?offset=30&limit=10", // Null si sur la dernière page
"last": "/items?offset=4990&limit=10"
}
}
Fournir des liens HATEOAS (Hypermedia as the Engine of Application State) (self
, first
, prev
, next
, last
) est une bonne pratique REST. Il permet aux clients de naviguer dans les pages sans avoir à construire eux-mêmes les URL.
Avantages de la pagination offset/limit :
- Simplicité : facile à comprendre et à mettre en œuvre.
- Navigation avec état : permet une navigation directe vers une page spécifique (par exemple, « aller à la page 50 »).
- Largement pris en charge : la prise en charge de la base de données pour
OFFSET
etLIMIT
est courante.
Inconvénients de la pagination offset/limit :
- Dégradation des performances avec des offsets importants : à mesure que la valeur
offset
augmente, les bases de données peuvent devenir plus lentes. La base de données doit souvent encore analyser toutes les lignesoffset + limit
avant de supprimer les lignesoffset
. Cela peut être inefficace pour les pages profondes. - Décalage des données/éléments manqués : si de nouveaux éléments sont ajoutés ou si des éléments existants sont supprimés de l'ensemble de données pendant qu'un utilisateur pagine, la « fenêtre » de données peut se déplacer. Cela pourrait amener un utilisateur à voir le même élément sur deux pages différentes ou à manquer complètement un élément. Ceci est particulièrement problématique avec les ensembles de données fréquemment mis à jour. Par exemple, si vous êtes sur la page 2 (éléments 11-20) et qu'un nouvel élément est ajouté au début de la liste, lorsque vous demandez la page 3, ce qui était auparavant l'élément 21 est maintenant l'élément 22. Vous pourriez manquer le nouvel élément 21 ou voir des doublons en fonction du minutage exact et des modèles de suppression.
2. Pagination basée sur le curseur (Keyset/Seek)
La pagination basée sur le curseur résout certaines des lacunes de offset/limit, en particulier les performances avec les grands ensembles de données et les problèmes de cohérence des données. Au lieu de s'appuyer sur un décalage absolu, elle utilise un « curseur » qui pointe vers un élément spécifique de l'ensemble de données. Le client demande ensuite des éléments « après » ou « avant » ce curseur.
Le curseur est généralement une chaîne opaque qui encode la ou les valeurs de la ou des clés de tri du dernier élément récupéré sur la page précédente.
Étapes techniques de la mise en œuvre :
a. Paramètres de la requête API :
Le client effectuerait une requête comme :GET /items?limit=10
(pour la première page)
Et pour les pages suivantes :GET /items?limit=10&after_cursor=opaquestringrepresentinglastitemid
Ou, pour paginer en arrière (moins courant mais possible) :GET /items?limit=10&before_cursor=opaquestringrepresentingfirstitemid
Le paramètre limit
définit toujours la taille de la page.
b. Qu'est-ce qu'un curseur ?
Un curseur doit être :
- Opaque pour le client : le client n'a pas besoin de comprendre sa structure interne. Il le reçoit simplement d'une réponse et le renvoie dans la requête suivante.
- Basé sur des colonnes uniques et ordonnées de manière séquentielle : généralement, il s'agit de l'ID principal (s'il est séquentiel comme un UUIDv1 ou une séquence de base de données) ou d'une colonne d'horodatage. Si une seule colonne n'est pas suffisamment unique (par exemple, plusieurs éléments peuvent avoir le même horodatage), une combinaison de colonnes est utilisée (par exemple,
timestamp
+id
). - Codable et décodable : souvent codé en Base64 pour garantir qu'il est sûr pour les URL. Il pourrait être aussi simple que l'ID lui-même, ou un objet JSON
{ "last_id": 123, "last_timestamp": "2023-10-27T10:00:00Z" }
qui est ensuite codé en Base64.
c. Logique du backend (conceptuelle) :
// Exemple en Java avec Spring Boot
@GetMapping("/items")
public ResponseEntity<CursorPaginatedResponse<Item>> getItems(
@RequestParam(defaultValue = "20") int limit,
@RequestParam(required = false) String afterCursor
) {
// Valider la limite
if (limit > 100) {
limit = 100;
}
// Décoder le curseur pour obtenir les propriétés du dernier élément vu
// par exemple, LastSeenItemDetails lastSeen = decodeCursor(afterCursor);
// Si afterCursor est nul, c'est la première page.
List<Item> items;
if (afterCursor != null) {
DecodedCursor decoded = decodeCursor(afterCursor); // par exemple, { lastId: "some_uuid", lastCreatedAt: "timestamp" }
items = itemRepository.findItemsAfter(decoded.getLastCreatedAt(), decoded.getLastId(), limit);
} else {
items = itemRepository.findFirstPage(limit);
}
String nextCursor = null;
if (!items.isEmpty() && items.size() == limit) {
// En supposant que les éléments sont triés, le dernier élément de la liste est utilisé pour générer le curseur suivant
Item lastItemOnPage = items.get(items.size() - 1);
nextCursor = encodeCursor(lastItemOnPage.getCreatedAt(), lastItemOnPage.getId());
}
// Construire et renvoyer une réponse paginée par curseur
// ...
}
// Méthodes d'assistance pour l'encodage/le décodage des curseurs
// private DecodedCursor decodeCursor(String cursor) { ... }
// private String encodeCursor(Timestamp createdAt, String id) { ... }
d. Requête de base de données (exemple SQL) :
L'essentiel est d'utiliser une clause WHERE
qui filtre les enregistrements en fonction de la ou des clés de tri du curseur. La clause ORDER BY
doit s'aligner sur la composition du curseur.
En supposant un tri par created_at
(décroissant) puis par id
(décroissant) comme critère de départage pour un ordre stable si created_at
n'est pas unique :
Pour la première page :
SELECT *
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 10;
Pour les pages suivantes, si le curseur est décodé en last_created_at_from_cursor
et last_id_from_cursor
:
SELECT *
FROM items
WHERE (created_at, id) < (CAST('last_created_at_from_cursor' AS TIMESTAMP), CAST('last_id_from_cursor' AS UUID)) -- Ou les types appropriés
-- Pour l'ordre croissant, ce serait >
-- La comparaison de tuple (created_at, id) < (val1, val2) est un moyen concis d'écrire :
-- WHERE created_at < 'last_created_at_from_cursor'
-- OR (created_at = 'last_created_at_from_cursor' AND id < 'last_id_from_cursor')
ORDER BY created_at DESC, id DESC
LIMIT 10;
Ce type de requête est très efficace, surtout s'il existe un index sur (created_at, id)
. La base de données peut directement « rechercher » le point de départ sans analyser les lignes non pertinentes.
e. Structure de la réponse API :
{
"data": [
// tableau d'éléments pour la page actuelle
{ "id": "item_N", "createdAt": "2023-10-27T10:05:00Z", ... },
// ... jusqu'à 'limit' éléments
{ "id": "item_M", "createdAt": "2023-10-27T10:00:00Z", ... }
],
"pagination": {
"limit": 10,
"hasNextPage": true, // booléen indiquant s'il y a plus de données
"nextCursor": "base64encodedcursorstringforitem_M" // chaîne opaque
// Potentiellement un "prevCursor" si les curseurs bidirectionnels sont pris en charge
},
"links": {
"self": "/items?limit=10&after_cursor=current_request_cursor_if_any",
"next": "/items?limit=10&after_cursor=base64encodedcursorstringforitem_M" // Null s'il n'y a pas de page suivante
}
}
Notez que la pagination basée sur le curseur ne fournit généralement pas totalPages
ou totalItems
car le calcul de ceux-ci nécessiterait une analyse complète de la table, ce qui annule certains des avantages en termes de performances. Si ceux-ci sont strictement nécessaires, un point de terminaison distinct ou une estimation peuvent être fournis.
Avantages de la pagination basée sur le curseur :
- Performances sur les grands ensembles de données : fonctionne généralement mieux que offset/limit pour la pagination profonde, car la base de données peut rechercher efficacement la position du curseur à l'aide d'index.
- Stable dans les ensembles de données dynamiques : moins sensible aux éléments manqués ou en double lorsque les données sont fréquemment ajoutées ou supprimées, car le curseur s'ancre à un élément spécifique. Si un élément avant le curseur est supprimé, cela n'affecte pas les éléments suivants.
- Adapté au défilement infini : le modèle « page suivante » s'intègre naturellement aux interfaces utilisateur de défilement infini.
Inconvénients de la pagination basée sur le curseur :
- Pas de « aller à la page » : les utilisateurs ne peuvent pas naviguer directement vers un numéro de page arbitraire (par exemple, « page 5 »). La navigation est strictement séquentielle (suivant/précédent).
- Mise en œuvre plus complexe : la définition et la gestion des curseurs, en particulier avec plusieurs colonnes de tri ou des ordres de tri complexes, peuvent être plus complexes.
- Limitations de tri : l'ordre de tri doit être fixe et basé sur les colonnes utilisées pour le curseur. Modifier l'ordre de tri à la volée avec des curseurs est complexe.
Choisir la bonne stratégie
Le choix entre offset/limit et la pagination basée sur le curseur dépend de vos exigences spécifiques :
- Offset/Limit est souvent suffisant si :
- L'ensemble de données est relativement petit ou ne change pas fréquemment.
- La possibilité d'aller à des pages arbitraires est une fonctionnalité essentielle.
- La simplicité de mise en œuvre est une priorité élevée.
- Les performances pour les pages très profondes ne sont pas une préoccupation majeure.
- Basé sur le curseur est généralement préféré si :
- Vous traitez des ensembles de données très volumineux et fréquemment modifiés.
- Les performances à l'échelle et la cohérence des données pendant la pagination sont primordiales.
- La navigation séquentielle (comme le défilement infini) est le principal cas d'utilisation.
- Vous n'avez pas besoin d'afficher le nombre total de pages ou d'autoriser le saut vers des pages spécifiques.
Dans certains systèmes, une approche hybride est même utilisée, ou différentes stratégies sont proposées pour différents cas d'utilisation ou points de terminaison.
Meilleures pratiques pour la mise en œuvre de la pagination
Quelle que soit la stratégie choisie, respectez ces bonnes pratiques :
- Nommage cohérent des paramètres : utilisez des noms clairs et cohérents pour vos paramètres de pagination (par exemple,
limit
,offset
,page
,pageSize
,after_cursor
,before_cursor
). Tenez-vous-en à une convention (par exemple,camelCase
ousnake_case
) tout au long de votre API. - Fournir des liens de navigation (HATEOAS) : comme indiqué dans les exemples de réponses, incluez des liens pour
self
,next
,prev
,first
etlast
(le cas échéant). Cela rend l'API plus détectable et découple le client de la logique de construction d'URL. - Valeurs par défaut et limites maximales :
- Définissez toujours des valeurs par défaut raisonnables pour
limit
(par exemple, 10 ou 25). - Appliquez une
limit
maximale pour empêcher les clients de demander trop de données et de submerger le serveur (par exemple, maximum 100 enregistrements par page). Renvoyez une erreur ou plafonnez la limite si une valeur non valide est demandée.
- Documentation API claire : documentez votre stratégie de pagination en détail :
- Expliquez les paramètres utilisés.
- Fournissez des exemples de requêtes et de réponses.
- Clarifiez les limites par défaut et maximales.
- Expliquez comment les curseurs sont utilisés (le cas échéant), sans révéler leur structure interne s'ils sont censés être opaques.
- Tri cohérent : assurez-vous que les données sous-jacentes sont triées de manière cohérente pour chaque requête paginée. Pour offset/limit, cela est essentiel pour éviter le décalage des données. Pour la pagination basée sur le curseur, l'ordre de tri dicte la façon dont les curseurs sont construits et interprétés. Utilisez une colonne de départage unique (comme un ID principal) si la colonne de tri principale peut avoir des valeurs en double.
- Gérer les cas limites :
- Résultats vides : renvoyez un tableau de données vide et des métadonnées de pagination appropriées (par exemple,
totalItems: 0
ouhasNextPage: false
). - Paramètres non valides : renvoyez une erreur
400 Bad Request
si les clients fournissent des paramètres de pagination non valides (par exemple, limite négative, numéro de page non entier). - Curseur introuvable (pour la pagination basée sur le curseur) : si un curseur fourni est invalide ou pointe vers un élément supprimé, décidez d'un comportement : renvoyez un
404 Not Found
ou400 Bad Request
, ou revenez en douceur à la première page.
- Considérations relatives au nombre total :
- Pour offset/limit, fournir
totalItems
ettotalPages
est courant. Gardez à l'esprit queCOUNT(*)
peut être lent sur de très grandes tables. Explorez les optimisations ou estimations spécifiques à la base de données si cela devient un goulot d'étranglement. - Pour la pagination basée sur le curseur,
totalItems
est souvent omis pour des raisons de performances. Si nécessaire, envisagez de fournir un nombre estimé ou un point de terminaison distinct qui le calcule (potentiellement de manière asynchrone).
- Gestion des erreurs : renvoyez les codes d'état HTTP appropriés pour les erreurs (par exemple,
400
pour une mauvaise entrée,500
pour les erreurs du serveur lors de la récupération des données). - Sécurité : bien que ce ne soit pas directement un mécanisme de pagination, assurez-vous que les données paginées respectent les règles d'autorisation. Un utilisateur ne doit pouvoir paginer que les données qu'il est autorisé à voir.
- Mise en cache : les réponses paginées peuvent souvent être mises en cache. Pour la pagination basée sur le décalage,
GET /items?page=2&pageSize=10
est hautement cachable. Pour la pagination basée sur le curseur,GET /items?limit=10&after_cursor=XYZ
est également cachable. Assurez-vous que votre stratégie de mise en cache fonctionne bien avec la façon dont les liens de pagination sont générés et consommés. Les stratégies d'invalidation doivent être prises en compte si les données sous-jacentes changent fréquemment.
Rubriques avancées (brèves mentions)
- Défilement infini : la pagination basée sur le curseur est une solution naturelle pour les interfaces utilisateur de défilement infini. Le client récupère la première page, et lorsque l'utilisateur fait défiler près du bas, il utilise le
nextCursor
pour récupérer l'ensemble d'éléments suivant. - Pagination avec filtrage et tri complexes : lors de la combinaison de la pagination avec des paramètres de filtrage et de tri dynamiques, assurez-vous que :
- Pour offset/limit : le nombre
totalItems
reflète avec précision l'ensemble de données filtré. - Pour la pagination basée sur le curseur : le curseur encode l'état du tri et du filtrage si ceux-ci affectent ce que « suivant » signifie. Cela peut compliquer considérablement la conception du curseur. Souvent, si les filtres ou l'ordre de tri changent, la pagination est réinitialisée à la « première page » de la nouvelle vue.
- Pagination GraphQL : GraphQL a sa propre façon standardisée de gérer la pagination, souvent appelée « Connexions ». Elle utilise généralement la pagination basée sur le curseur et a une structure définie pour renvoyer des arêtes (éléments avec des curseurs) et des informations de page. Si vous utilisez GraphQL, respectez ses conventions (par exemple, la spécification des connexions de curseur Relay).
Conclusion
La mise en œuvre correcte de la pagination est essentielle pour la création d'API REST évolutives et conviviales. Bien que la pagination offset/limit soit plus simple pour commencer, la pagination basée sur le curseur offre des performances et une cohérence supérieures pour les grands ensembles de données dynamiques. En comprenant les détails techniques de chaque stratégie, en choisissant celle qui correspond le mieux aux besoins de votre application et en suivant les meilleures pratiques pour la mise en œuvre et la conception d'API, vous pouvez vous assurer que votre API fournit efficacement des données à vos clients, quelle que soit l'échelle. N'oubliez pas de toujours donner la priorité à une documentation claire et à une gestion robuste des erreurs pour offrir une expérience fluide aux consommateurs d'API.