Meilleures pratiques de pagination API

288

J'adorerais avoir de l'aide pour gérer un cas de bord étrange avec une API paginée que je construis.

Comme de nombreuses API, celle-ci affiche de grands résultats. Si vous interrogez / foos, vous obtiendrez 100 résultats (c'est-à-dire foo # 1-100), et un lien vers / foos? Page = 2 qui devrait retourner foo # 101-200.

Malheureusement, si foo # 10 est supprimé de l'ensemble de données avant que le consommateur d'API ne fasse la prochaine requête, / foos? Page = 2 sera compensé par 100 et renverra foos # 102-201.

C'est un problème pour les consommateurs d'API qui essaient de tirer tous les foos - ils ne recevront pas foo # 101.

Quelle est la meilleure pratique pour gérer cela? Nous aimerions le rendre aussi léger que possible (c'est-à-dire en évitant de gérer les sessions pour les demandes d'API). Des exemples provenant d'autres API seraient grandement appréciés!

2arrs2ells
la source
1
quel est le problème ici? me semble correct, de toute façon l'utilisateur obtiendra 100 articles.
NARKOZ
2
J'ai été confronté à ce même problème et à la recherche d'une solution. AFAIK, il n'y a vraiment aucun mécanisme solide garanti pour accomplir cela, si chaque page exécute une nouvelle requête. La seule solution à laquelle je peux penser est de garder une session active et de conserver le jeu de résultats côté serveur, et plutôt que d'exécuter de nouvelles requêtes pour chaque page, il suffit de récupérer le prochain jeu d'enregistrements mis en cache.
Jerry Dodge
31
Découvrez
java_geek
1
@java_geek Comment le paramètre since_id est-il mis à jour? Sur la page Web Twitter, il semble qu'ils effectuent les deux demandes avec la même valeur pour Since_id. Je me demande quand sera-t-il mis à jour afin que si de nouveaux tweets sont ajoutés, ils peuvent être pris en compte?
Petar
1
@Petar Le paramètre since_id doit être mis à jour par le consommateur de l'API. Si vous voyez, l'exemple fait référence aux clients qui traitent les tweets
java_geek

Réponses:

175

Je ne suis pas complètement sûr de la façon dont vos données sont traitées, donc cela peut ou non fonctionner, mais avez-vous envisagé de paginer avec un champ d'horodatage?

Lorsque vous interrogez / foos, vous obtenez 100 résultats. Votre API devrait alors retourner quelque chose comme ça (en supposant JSON, mais si elle a besoin de XML, les mêmes principes peuvent être suivis):

{
    "data" : [
        {  data item 1 with all relevant fields    },
        {  data item 2   },
        ...
        {  data item 100 }
    ],
    "paging":  {
        "previous":  "http://api.example.com/foo?since=TIMESTAMP1" 
        "next":  "http://api.example.com/foo?since=TIMESTAMP2"
    }

}

Juste une note, l'utilisation d'un seul horodatage repose sur une «limite» implicite dans vos résultats. Vous pouvez ajouter une limite explicite ou utiliser également ununtil propriété.

L'horodatage peut être déterminé dynamiquement à l'aide du dernier élément de données de la liste. Cela semble être plus ou moins la façon dont Facebook pagine dans son API graphique (faites défiler vers le bas pour voir les liens de pagination dans le format que j'ai donné ci-dessus).

Un problème peut être si vous ajoutez un élément de données, mais d'après votre description, il semble qu'ils seraient ajoutés à la fin (sinon, faites-le moi savoir et je verrai si je peux améliorer cela).

ramblinjan
la source
29
Les horodatages ne sont pas garantis d'être uniques. Autrement dit, plusieurs ressources peuvent être créées avec le même horodatage. Cette approche a donc l'inconvénient que la page suivante, pourrait répéter les dernières (quelques?) Entrées de la page actuelle.
rouble
4
@prmatta En fait, en fonction de la mise en œuvre de la base de données, un horodatage est garanti unique .
ramblinjan
2
@jandjorgensen À partir de votre lien: "Le type de données d'horodatage est simplement un nombre incrémentiel et ne conserve pas de date ou d'heure. ... Dans SQL Server 2008 et versions ultérieures, le type d'horodatage a été renommé en rowversion , sans doute pour mieux refléter son but et valeur. " Il n'y a donc aucune preuve ici que les horodatages (ceux qui contiennent réellement une valeur de temps) sont uniques.
Nolan Amy
3
@jandjorgensen J'aime votre proposition, mais n'auriez-vous pas besoin d'une sorte d'information dans les liens de ressources, donc nous savons si nous allons précédent ou suivant? Sth comme: "précédent": " api.example.com/foo?before=TIMESTAMP " "suivant": " api.example.com/foo?since=TIMESTAMP2 " Nous utiliserions également nos identifiants de séquence au lieu d'un horodatage. Voyez-vous des problèmes avec cela?
longliveenduro
5
Une autre option similaire consiste à utiliser le champ d'en-tête Lien spécifié dans la RFC 5988 (section 5): tools.ietf.org/html/rfc5988#page-6
Anthony F
28

Vous avez plusieurs problèmes.

D'abord, vous avez l'exemple que vous avez cité.

Vous avez également un problème similaire si des lignes sont insérées, mais dans ce cas, l'utilisateur obtient des données en double (sans doute plus facile à gérer que les données manquantes, mais reste un problème).

Si vous ne prenez pas un instantané de l'ensemble de données d'origine, ce n'est qu'une réalité.

Vous pouvez demander à l'utilisateur de créer un instantané explicite:

POST /createquery
filter.firstName=Bob&filter.lastName=Eubanks

Quels résultats:

HTTP/1.1 301 Here's your query
Location: http://www.example.org/query/12345

Ensuite, vous pouvez la page toute la journée, car elle est désormais statique. Cela peut être relativement léger, car vous pouvez simplement capturer les clés de document réelles plutôt que les lignes entières.

Si le cas d'utilisation est simplement que vos utilisateurs veulent (et ont besoin) de toutes les données, vous pouvez simplement leur donner:

GET /query/12345?all=true

et envoyez simplement le kit complet.

Will Hartung
la source
1
(Le type de foos par défaut est par date de création, donc l'insertion de ligne n'est pas un problème.)
2arrs2ells
En fait, capturer uniquement des clés de document ne suffit pas. De cette façon, vous devrez interroger les objets complets par ID lorsque l'utilisateur les demande, mais il se peut qu'ils n'existent plus.
Scadge
27

Si vous avez la pagination, vous triez également les données par clé. Pourquoi ne pas laisser les clients API inclure la clé du dernier élément de la collection retournée précédemment dans l'URL et ajouter une WHEREclause à votre requête SQL (ou quelque chose d'équivalent, si vous n'utilisez pas SQL) afin qu'elle ne renvoie que les éléments pour lesquels la clé est supérieure à cette valeur?

kamilk
la source
4
Ce n'est pas une mauvaise suggestion, mais ce n'est pas parce que vous triez par valeur que c'est une «clé», c'est-à-dire unique.
Chris Peacock
Exactement. Par exemple dans mon cas, le champ de tri se trouve être une date, et il est loin d'être unique.
Sam Thiru
19

Il peut y avoir deux approches en fonction de votre logique côté serveur.

Approche 1: lorsque le serveur n'est pas suffisamment intelligent pour gérer les états des objets.

Vous pouvez envoyer tous les identifiants uniques des enregistrements mis en cache au serveur, par exemple ["id1", "id2", "id3", "id4", "id5", "id6", "id7", "id8", "id9", "id10"] et un paramètre booléen pour savoir si vous demandez de nouveaux enregistrements (tirer pour actualiser) ou d'anciens enregistrements (charger plus).

Votre serveur devrait être responsable de renvoyer les nouveaux enregistrements (charger plus d'enregistrements ou de nouveaux enregistrements via pull pour actualiser) ainsi que les identifiants des enregistrements supprimés de ["id1", "id2", "id3", "id4", "id5", " id6 "," id7 "," id8 "," id9 "," id10 "].

Exemple: - Si vous demandez plus de chargement, votre demande devrait ressembler à ceci: -

{
        "isRefresh" : false,
        "cached" : ["id1","id2","id3","id4","id5","id6","id7","id8","id9","id10"]
}

Supposons maintenant que vous demandez d'anciens enregistrements (chargez plus) et que l'enregistrement "id2" soit mis à jour par quelqu'un et que les enregistrements "id5" et "id8" soient supprimés du serveur, alors la réponse de votre serveur devrait ressembler à ceci: -

{
        "records" : [
{"id" :"id2","more_key":"updated_value"},
{"id" :"id11","more_key":"more_value"},
{"id" :"id12","more_key":"more_value"},
{"id" :"id13","more_key":"more_value"},
{"id" :"id14","more_key":"more_value"},
{"id" :"id15","more_key":"more_value"},
{"id" :"id16","more_key":"more_value"},
{"id" :"id17","more_key":"more_value"},
{"id" :"id18","more_key":"more_value"},
{"id" :"id19","more_key":"more_value"},
{"id" :"id20","more_key":"more_value"}],
        "deleted" : ["id5","id8"]
}

Mais dans ce cas, si vous avez beaucoup d'enregistrements locaux mis en cache, supposez 500, alors votre chaîne de requête sera trop longue comme ceci: -

{
        "isRefresh" : false,
        "cached" : ["id1","id2","id3","id4","id5","id6","id7","id8","id9","id10",………,"id500"]//Too long request
}

Approche 2: lorsque le serveur est suffisamment intelligent pour gérer les états des objets en fonction de la date.

Vous pouvez envoyer l'identifiant du premier enregistrement et le dernier enregistrement et l'heure de l'époque de la demande précédente. De cette façon, votre demande est toujours petite même si vous avez une grande quantité d'enregistrements mis en cache

Exemple:- Si vous demandez plus de chargement, votre demande devrait ressembler à ceci: -

{
        "isRefresh" : false,
        "firstId" : "id1",
        "lastId" : "id10",
        "last_request_time" : 1421748005
}

Votre serveur est responsable de renvoyer les identifiants des enregistrements supprimés qui sont supprimés après le last_request_time ainsi que de renvoyer l'enregistrement mis à jour après last_request_time entre "id1" et "id10".

{
        "records" : [
{"id" :"id2","more_key":"updated_value"},
{"id" :"id11","more_key":"more_value"},
{"id" :"id12","more_key":"more_value"},
{"id" :"id13","more_key":"more_value"},
{"id" :"id14","more_key":"more_value"},
{"id" :"id15","more_key":"more_value"},
{"id" :"id16","more_key":"more_value"},
{"id" :"id17","more_key":"more_value"},
{"id" :"id18","more_key":"more_value"},
{"id" :"id19","more_key":"more_value"},
{"id" :"id20","more_key":"more_value"}],
        "deleted" : ["id5","id8"]
}

Tirer pour rafraîchir:-

entrez la description de l'image ici

Charger plus

entrez la description de l'image ici

Mohd Iftekhar Qurashi
la source
14

Il peut être difficile de trouver les meilleures pratiques, car la plupart des systèmes dotés d'API ne conviennent pas à ce scénario, car il s'agit d'un avantage extrême ou ils ne suppriment généralement pas les enregistrements (Facebook, Twitter). Facebook dit en fait que chaque "page" peut ne pas avoir le nombre de résultats demandé en raison du filtrage effectué après la pagination. https://developers.facebook.com/blog/post/478/

Si vous avez vraiment besoin d'accommoder ce boîtier de bord, vous devez vous "souvenir" de l'endroit où vous vous êtes arrêté. La suggestion de jandjorgensen est à peu près exacte, mais j'utiliserais un champ garanti unique comme la clé primaire. Vous devrez peut-être utiliser plusieurs champs.

En suivant le flux de Facebook, vous pouvez (et devez) mettre en cache les pages déjà demandées et simplement renvoyer celles dont les lignes supprimées sont filtrées si elles demandent une page qu'elles avaient déjà demandée.

Brent Baisley
la source
2
Ce n'est pas une solution acceptable. C'est beaucoup de temps et de mémoire. Toutes les données supprimées ainsi que les données demandées devront être conservées en mémoire qui pourraient ne pas être utilisées du tout si le même utilisateur ne demande plus d'entrées.
Deepak Garg
3
Je ne suis pas d'accord. Le simple fait de conserver les identifiants uniques n'utilise pas beaucoup de mémoire. Vous ne devez pas conserver les données indéfiniment, juste pour la "session". C'est facile avec memcache, il suffit de définir la durée d'expiration (ie 10 minutes).
Brent Baisley
la mémoire est moins chère que la vitesse du réseau / CPU. Donc, si la création d'une page est très coûteuse (en termes de réseau ou de processeur), les résultats de mise en cache sont une approche valide @DeepakGarg
U Avalos
9

La pagination est généralement une opération "utilisateur" et pour éviter une surcharge à la fois sur les ordinateurs et le cerveau humain, vous donnez généralement un sous-ensemble. Cependant, plutôt que de penser que nous n’obtenons pas la liste complète, il vaut peut-être mieux demander est-ce important?

Si une vue de défilement en direct précise est nécessaire, les API REST qui sont de nature demande / réponse ne sont pas bien adaptées à cette fin. Pour cela, vous devez envisager les WebSockets ou les événements envoyés par le serveur HTML5 pour informer votre front-end lors du traitement des modifications.

Maintenant, s'il y a un besoin d'obtenir un instantané des données, je fournirais simplement un appel API qui fournit toutes les données en une seule demande sans pagination. Attention, vous auriez besoin de quelque chose qui ferait le streaming de la sortie sans la charger temporairement en mémoire si vous avez un grand ensemble de données.

Pour mon cas, je désigne implicitement certains appels d'API pour permettre d'obtenir toutes les informations (principalement les données de la table de référence). Vous pouvez également sécuriser ces API afin qu'elles n'endommagent pas votre système.

Archimedes Trajano
la source
8

Option A: pagination du jeu de clés avec horodatage

Afin d'éviter les inconvénients de la pagination offset que vous avez mentionnés, vous pouvez utiliser la pagination basée sur le jeu de clés. Habituellement, les entités ont un horodatage qui indique leur heure de création ou de modification. Cet horodatage peut être utilisé pour la pagination: il suffit de passer l'horodatage du dernier élément comme paramètre de requête pour la prochaine requête. Le serveur, à son tour, utilise l'horodatage comme critère de filtre (par exemple WHERE modificationDate >= receivedTimestampParameter)

{
    "elements": [
        {"data": "data", "modificationDate": 1512757070}
        {"data": "data", "modificationDate": 1512757071}
        {"data": "data", "modificationDate": 1512757072}
    ],
    "pagination": {
        "lastModificationDate": 1512757072,
        "nextPage": "https://domain.de/api/elements?modifiedSince=1512757072"
    }
}

De cette façon, vous ne manquerez aucun élément. Cette approche devrait être suffisante pour de nombreux cas d'utilisation. Cependant, gardez à l'esprit les points suivants:

  • Vous pouvez exécuter des boucles sans fin lorsque tous les éléments d'une même page ont le même horodatage.
  • Vous pouvez remettre plusieurs éléments plusieurs fois au client lorsque des éléments avec le même horodatage chevauchent deux pages.

Vous pouvez réduire ces inconvénients en augmentant la taille de la page et en utilisant des horodatages avec une précision en millisecondes.

Option B: Pagination du jeu de clés étendu avec un jeton de continuation

Pour gérer les inconvénients mentionnés de la pagination normale du jeu de clés, vous pouvez ajouter un décalage à l'horodatage et utiliser un soi-disant "jeton de continuation" ou "curseur". Le décalage est la position de l'élément par rapport au premier élément avec le même horodatage. Habituellement, le jeton a un format comme Timestamp_Offset. Il est transmis au client dans la réponse et peut être renvoyé au serveur afin de récupérer la page suivante.

{
    "elements": [
        {"data": "data", "modificationDate": 1512757070}
        {"data": "data", "modificationDate": 1512757072}
        {"data": "data", "modificationDate": 1512757072}
    ],
    "pagination": {
        "continuationToken": "1512757072_2",
        "nextPage": "https://domain.de/api/elements?continuationToken=1512757072_2"
    }
}

Le jeton "1512757072_2" pointe vers le dernier élément de la page et indique "le client a déjà obtenu le deuxième élément avec l'horodatage 1512757072". De cette façon, le serveur sait où continuer.

Veuillez noter que vous devez gérer les cas où les éléments ont été modifiés entre deux demandes. Cela se fait généralement en ajoutant une somme de contrôle au jeton. Cette somme de contrôle est calculée sur les ID de tous les éléments avec cet horodatage. Donc , nous nous retrouvons avec un format de jeton comme ceci: Timestamp_Offset_Checksum.

Pour plus d'informations sur cette approche, consultez l'article de blog " Pagination de l'API Web avec des jetons de continuation ". Un inconvénient de cette approche est la mise en œuvre délicate car il existe de nombreux cas d'angle qui doivent être pris en compte. C'est pourquoi les bibliothèques comme le jeton de continuation peuvent être pratiques (si vous utilisez Java / un langage JVM). Avertissement: je suis l'auteur de l'article et co-auteur de la bibliothèque.

phauer
la source
4

Je pense que votre API répond actuellement comme il se doit. Les 100 premiers enregistrements de la page dans l'ordre global des objets que vous gérez. Votre explication indique que vous utilisez une sorte d'ID de commande pour définir l'ordre de vos objets pour la pagination.

Maintenant, au cas où vous voudriez que la page 2 commence toujours à partir de 101 et se termine à 200, alors vous devez définir le nombre d'entrées sur la page comme variable, car elles sont sujettes à suppression.

Vous devriez faire quelque chose comme le pseudocode ci-dessous:

page_max = 100
def get_page_results(page_no) :

    start = (page_no - 1) * page_max + 1
    end = page_no * page_max

    return fetch_results_by_id_between(start, end)
mickeymoon
la source
1
Je suis d'accord. plutôt que d'interroger par numéro d'enregistrement (qui n'est pas fiable), vous devez interroger par ID. Modifiez votre requête (x, m) pour signifier «retourner jusqu'à m enregistrements TRIÉS par ID, avec ID> x», puis vous pouvez simplement définir x à l'id maximum du résultat de la requête précédente.
John Henckel
Vrai, soit trier sur les identifiants, soit si vous avez un domaine d'activité concret sur lequel trier comme creation_date etc.
mickeymoon
4

Juste pour ajouter à cette réponse de Kamilk: https://www.stackoverflow.com/a/13905589

Cela dépend beaucoup de la taille du jeu de données sur lequel vous travaillez. Les petits ensembles de données fonctionnent efficacement sur la pagination offset, mais les grands ensembles de données en temps réel nécessitent la pagination du curseur.

J'ai trouvé un article merveilleux sur la façon dont Slack a évolué la pagination de son API à mesure que les jeux de données augmentaient, expliquant les points positifs et négatifs à chaque étape: https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12

Shubham Srivastava
la source
3

J'y ai longuement réfléchi et j'ai finalement trouvé la solution que je décrirai ci-dessous. C'est une assez grande étape dans la complexité, mais si vous effectuez cette étape, vous vous retrouverez avec ce que vous recherchez vraiment, ce qui est des résultats déterministes pour les demandes futures.

Votre exemple d'élément supprimé n'est que la pointe de l'iceberg. Et si vous filtrez parcolor=blue mais que quelqu'un change les couleurs des éléments entre les demandes? La récupération fiable de tous les éléments de manière paginée est impossible ... à moins que ... nous n'implémentions l'historique des révisions .

Je l'ai implémenté et c'est en fait moins difficile que ce à quoi je m'attendais. Voici ce que j'ai fait:

  • J'ai créé une seule table changelogsavec une colonne d'identification à incrémentation automatique
  • Mes entités ont un id champ, mais ce n'est pas la clé primaire
  • Les entités ont un changeId champ qui est à la fois la clé primaire et la clé étrangère des journaux des modifications.
  • Chaque fois qu'un utilisateur crée, met à jour ou supprime un enregistrement, le système insère un nouvel enregistrement dans changelogs, saisit l'ID et l'affecte à un nouvelle version de l'entité, qu'il insère ensuite dans la base de données
  • Mes requêtes sélectionnent le changeId maximum (groupé par id) et se joignent à celui-ci pour obtenir les versions les plus récentes de tous les enregistrements.
  • Des filtres sont appliqués aux enregistrements les plus récents
  • Un champ d'état permet de savoir si un élément est supprimé
  • Le max changeId est renvoyé au client et ajouté en tant que paramètre de requête dans les requêtes suivantes
  • Parce que seules les nouvelles modifications sont créées, chaque changeId représente un instantané unique des données sous-jacentes au moment où la modification a été créée.
  • Cela signifie que vous pouvez mettre en cache les résultats des demandes qui contiennent le paramètre changeIdpour toujours. Les résultats n'expireront jamais car ils ne changeront jamais.
  • Cela ouvre également des fonctionnalités intéressantes telles que la restauration / restauration, la synchronisation du cache client, etc. Toutes les fonctionnalités qui bénéficient de l'historique des modifications.
Stijn de Witt
la source
Je suis confus. Comment cela résout-il le cas d'utilisation que vous avez mentionné? (Un champ aléatoire change dans le cache et vous voulez invalider le cache)
U Avalos
Pour tout changement que vous apportez vous-même, il vous suffit de regarder la réponse. Le serveur fournira un nouveau changeId et vous l'utiliserez dans votre prochaine demande. Pour les autres changements (effectués par d'autres personnes), vous interrogez le dernier changeId de temps en temps et s'il est supérieur au vôtre, vous savez qu'il y a des changements en suspens. Ou vous configurez un système de notification (interrogation longue. Push serveur, websockets) qui alerte le client lorsqu'il y a des changements en suspens.
Stijn de Witt
0

Une autre option de pagination dans les API RESTFul consiste à utiliser l'en-tête Link présenté ici . Par exemple, Github l' utilise comme suit:

Link: <https://api.github.com/user/repos?page=3&per_page=100>; rel="next",
  <https://api.github.com/user/repos?page=50&per_page=100>; rel="last"

Les valeurs possibles pour relsont: premier, dernier, suivant, précédent . Mais en utilisant l'en- Linktête, il peut ne pas être possible de spécifier total_count (nombre total d'éléments).

adnanmuttaleb
la source