Best practice per l'impaginazione dell'API


288

Mi piacerebbe un po 'di aiuto per gestire uno strano caso limite con un'API impaginata che sto costruendo.

Come molte API, questa impagina grandi risultati. Se esegui una query / foos, otterrai 100 risultati (ovvero foo # 1-100) e un link a / foos? Page = 2 che dovrebbe restituire foo # 101-200.

Sfortunatamente, se foo # 10 viene eliminato dal set di dati prima che il consumatore API esegua la query successiva, / foos? Page = 2 verrà compensato di 100 e restituirà foos # 102-201.

Questo è un problema per i consumatori di API che stanno provando a tirare fuori tutti i foos - non riceveranno foo # 101.

Qual è la migliore pratica per gestirlo? Vorremmo renderlo il più leggero possibile (ovvero evitare sessioni di gestione per richieste API). Esempi di altre API sarebbero molto apprezzati!


1
qual è il problema qui? mi sembra ok, in entrambi i casi l'utente riceverà 100 articoli.
NARKOZ,

2
Ho affrontato lo stesso problema e ho cercato una soluzione. AFAIK, non esiste davvero un solido meccanismo garantito per raggiungere questo obiettivo, se ogni pagina esegue una nuova query. L'unica soluzione a cui riesco a pensare è mantenere una sessione attiva e mantenere il set di risultati sul lato server e, anziché eseguire nuove query per ogni pagina, è sufficiente acquisire il successivo set di record memorizzati nella cache.
Jerry Dodge,

31
Dai un'occhiata a come Twitter raggiunge questo obiettivo dev.twitter.com/rest/public/timelines
java_geek,

1
@java_geek Come viene aggiornato il parametro since_id? Nella pagina web di Twitter sembra che stiano facendo entrambe le richieste con lo stesso valore per since_id. Mi chiedo quando verrà aggiornato in modo che se vengono aggiunti tweet più recenti, possono essere contabilizzati?
Petar,

1
@Petar Il parametro since_id deve essere aggiornato dal consumatore dell'API. Se vedi, l'esempio qui si riferisce ai client che elaborano i tweet
java_geek il

Risposte:


175

Non sono del tutto sicuro di come vengano gestiti i tuoi dati, quindi potrebbe funzionare o meno, ma hai preso in considerazione la paginazione con un campo timestamp?

Quando si esegue una query / foos si ottengono 100 risultati. L'API dovrebbe quindi restituire qualcosa del genere (supponendo JSON, ma se ha bisogno di XML è possibile seguire gli stessi principi):

{
    "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"
    }

}

Solo una nota, l'utilizzo di un solo timestamp si basa su un "limite" implicito nei risultati. È possibile che si desideri aggiungere un limite esplicito o utilizzare anche una untilproprietà.

Il timestamp può essere determinato dinamicamente utilizzando l'ultimo elemento di dati nell'elenco. Questo sembra essere più o meno il modo in cui Facebook impagina nella sua API Graph (scorrere verso il basso per vedere i link di impaginazione nel formato che ho dato sopra).

Un problema potrebbe essere se aggiungi un elemento dati, ma in base alla tua descrizione sembra che verrebbero aggiunti alla fine (in caso contrario, fammi sapere e vedrò se posso migliorare su questo).


29
I timestamp non sono garantiti come unici. Cioè, è possibile creare più risorse con lo stesso timestamp. Quindi questo approccio ha il rovescio della medaglia che la pagina successiva potrebbe ripetere le ultime (poche?) Voci della pagina corrente.
rub

4
@prmatta In realtà, a seconda dell'implementazione del database, un timestamp è garantito come unico .
ramblinjan,

2
@jandjorgensen Dal tuo link: "Il tipo di dati data / ora è solo un numero crescente e non conserva una data o un'ora. In SQL Server 2008 e versioni successive, il tipo di data / ora è stato rinominato in versione a riga , presumibilmente per riflettere meglio il suo scopo e valore ". Quindi non ci sono prove che i timestamp (quelli che contengono effettivamente un valore temporale) siano unici.
Nolan Amy,

3
@jandjorgensen Mi piace la tua proposta, ma non avresti bisogno di qualche tipo di informazione nei collegamenti alle risorse, quindi sappiamo se andiamo avanti o indietro? Come: "precedente": " api.example.com/foo?before=TIMESTAMP " "successivo": " api.example.com/foo?since=TIMESTAMP2 " Useremmo anche i nostri ID sequenza anziché un timestamp. Vedi qualche problema con quello?
longliveenduro,

5
Un'altra opzione simile è utilizzare il campo di intestazione Link specificato in RFC 5988 (sezione 5): tools.ietf.org/html/rfc5988#page-6
Anthony F

28

Hai diversi problemi.

Innanzitutto, hai l'esempio che hai citato.

Hai anche un problema simile se vengono inserite righe, ma in questo caso l'utente ottiene dati duplicati (probabilmente più facile da gestire rispetto ai dati mancanti, ma comunque un problema).

Se non si esegue lo snapshot del set di dati originale, questo è solo un dato di fatto.

Puoi fare in modo che l'utente realizzi un'istantanea esplicita:

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

Quali risultati:

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

Quindi puoi sfogliarlo tutto il giorno, poiché ora è statico. Questo può essere ragionevolmente leggero, dal momento che puoi semplicemente catturare le chiavi del documento effettivo anziché le intere righe.

Se il caso d'uso è semplicemente che i tuoi utenti vogliono (e hanno bisogno) di tutti i dati, allora puoi semplicemente darli a loro:

GET /query/12345?all=true

e basta inviare l'intero kit.


1
(Il tipo predefinito di foos è in base alla data di creazione, quindi l'inserimento delle righe non è un problema.)
2arrs2ells

In realtà, acquisire solo le chiavi del documento non è sufficiente. In questo modo dovrai interrogare gli oggetti completi per ID quando l'utente li richiede, ma potrebbe non esistere più.
Scadge il

27

Se hai l'impaginazione, puoi anche ordinare i dati con una chiave. Perché non consentire ai client API di includere la chiave dell'ultimo elemento della raccolta precedentemente restituita nell'URL e aggiungere una WHEREclausola alla query SQL (o qualcosa di equivalente, se non si utilizza SQL) in modo che restituisca solo quegli elementi per i quali la chiave è maggiore di questo valore?


4
Questo non è un suggerimento negativo, tuttavia solo perché si ordina in base a un valore non significa che sia una "chiave", ovvero unica.
Chris Peacock,

Esattamente. Ad esempio, nel mio caso, il campo di ordinamento sembra essere una data ed è tutt'altro che unico.
Sab Thiru

19

Ci possono essere due approcci a seconda della logica lato server.

Approccio 1: quando il server non è abbastanza intelligente da gestire gli stati degli oggetti.

È possibile inviare al server tutti gli ID univoci dei record memorizzati nella cache, ad esempio ["id1", "id2", "id3", "id4", "id5", "id6", "id7", "id8", "id9", "id10"] e un parametro booleano per sapere se stai richiedendo nuovi record (pull per aggiornare) o vecchi record (carica altro).

Il tuo server dovrebbe essere responsabile della restituzione di nuovi record (carica più record o nuovi record tramite pull per aggiornare) e id di record eliminati da ["id1", "id2", "id3", "id4", "id5", " ID6" , "ID7", "ID8", "ID9", "ID10"].

Esempio: - Se stai richiedendo un caricamento maggiore, la tua richiesta dovrebbe assomigliare a questa: -

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

Supponiamo ora di richiedere vecchi record (carica altro) e supponiamo che il record "id2" sia aggiornato da qualcuno e che i record "id5" e "id8" vengano eliminati dal server, quindi la risposta del server dovrebbe assomigliare a questa: -

{
        "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"]
}

Ma in questo caso se hai molti record memorizzati nella cache locale supponiamo 500, la tua stringa di richiesta sarà troppo lunga in questo modo: -

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

Approccio 2: quando il server è abbastanza intelligente da gestire gli stati degli oggetti in base alla data.

È possibile inviare l'ID del primo record e dell'ultimo record e il periodo di tempo della richiesta precedente. In questo modo la tua richiesta è sempre piccola anche se hai una grande quantità di record memorizzati nella cache

Esempio: - Se stai richiedendo un caricamento maggiore, la tua richiesta dovrebbe assomigliare a questa: -

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

Il tuo server è responsabile di restituire l'id dei record eliminati che viene eliminato dopo last_request_time e di restituire il record aggiornato dopo last_request_time tra "id1" e "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"]
}

Tirare per aggiornare: -

inserisci qui la descrizione dell'immagine

Carica altro

inserisci qui la descrizione dell'immagine


14

Potrebbe essere difficile trovare le migliori pratiche poiché la maggior parte dei sistemi con API non è adatta a questo scenario, perché è un limite estremo o in genere non cancella i record (Facebook, Twitter). Facebook in realtà dice che ogni "pagina" potrebbe non avere il numero di risultati richiesti a causa del filtro fatto dopo l'impaginazione. https://developers.facebook.com/blog/post/478/

Se hai davvero bisogno di accogliere questa custodia per bordi, devi "ricordare" da dove eri rimasto. Il suggerimento di jandjorgensen è quasi perfetto, ma utilizzerei un campo garantito come unico come la chiave primaria. Potrebbe essere necessario utilizzare più di un campo.

Seguendo il flusso di Facebook, puoi (e dovresti) memorizzare nella cache le pagine già richieste e restituire quelle con le righe eliminate filtrate se richiedono una pagina che avevano già richiesto.


2
Questa non è una soluzione accettabile. Richiede molto tempo e memoria. Tutti i dati eliminati insieme ai dati richiesti dovranno essere mantenuti in memoria che potrebbero non essere utilizzati se lo stesso utente non richiede più voci.
Deepak Garg,

3
Non sono d'accordo. Il solo mantenimento degli ID univoci non utilizza molta memoria. Non conservare i dati indefinitamente, solo per la "sessione". Questo è facile con memcache, basta impostare la durata di scadenza (cioè 10 minuti).
Brent Baisley,

la memoria è più economica della velocità di rete / CPU. Quindi, se la creazione di una pagina è molto costosa (in termini di rete o richiede molta CPU), i risultati della memorizzazione nella cache sono un approccio valido @DeepakGarg
U Avalos

9

L'impaginazione è generalmente un'operazione "utente" e per prevenire il sovraccarico sia sui computer che sul cervello umano in genere si fornisce un sottoinsieme. Tuttavia, piuttosto che pensare che non otteniamo l'intero elenco, è meglio chiedere se è importante?

Se è necessaria una visualizzazione a scorrimento in tempo reale accurata, le API REST che sono richieste / risposte in natura non sono adatte a questo scopo. Per questo dovresti prendere in considerazione WebSocket o HTML5 Server-Sent Events per far sapere al tuo front-end quando gestisci le modifiche.

Ora, se è necessario ottenere un'istantanea dei dati, fornirei semplicemente una chiamata API che fornisce tutti i dati in una richiesta senza impaginazione. Intendiamoci, avresti bisogno di qualcosa che farebbe lo streaming dell'output senza caricarlo temporaneamente in memoria se disponi di un set di dati di grandi dimensioni.

Nel mio caso desidero implicitamente alcune chiamate API per consentire di ottenere tutte le informazioni (principalmente i dati della tabella di riferimento). Puoi anche proteggere queste API in modo da non danneggiare il tuo sistema.


8

Opzione A: Impaginazione del set di chiavi con un timestamp

Per evitare gli svantaggi della paginazione offset menzionata, è possibile utilizzare la paginazione basata su keyset. Di solito, le entità hanno un timestamp che indica i tempi di creazione o modifica. Questo timestamp può essere utilizzato per l'impaginazione: basta passare il timestamp dell'ultimo elemento come parametro di query per la richiesta successiva. Il server, a sua volta, utilizza il timestamp come criterio di filtro (ad es. 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"
    }
}

In questo modo, non ti perderai nessun elemento. Questo approccio dovrebbe essere abbastanza buono per molti casi d'uso. Tuttavia, tenere presente quanto segue:

  • È possibile imbattersi in loop infiniti quando tutti gli elementi di una singola pagina hanno lo stesso timestamp.
  • È possibile consegnare più elementi più volte al client quando elementi con lo stesso timestamp si sovrappongono a due pagine.

È possibile rendere tali inconvenienti meno probabili aumentando le dimensioni della pagina e utilizzando i timestamp con precisione in millisecondi.

Opzione B: Impaginazione del set di chiavi estesa con un token di continuazione

Per gestire gli svantaggi menzionati della normale impaginazione del keyset, è possibile aggiungere un offset al timestamp e utilizzare un cosiddetto "token di continuazione" o "Cursore". L'offset è la posizione dell'elemento rispetto al primo elemento con lo stesso timestamp. Di solito, il token ha un formato simile Timestamp_Offset. Viene passato al client nella risposta e può essere inviato nuovamente al server per recuperare la pagina successiva.

{
    "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"
    }
}

Il token "1512757072_2" punta all'ultimo elemento della pagina e indica "il client ha già ottenuto il secondo elemento con il timestamp 1512757072". In questo modo, il server sa dove continuare.

Tieni presente che devi gestire i casi in cui gli elementi sono stati cambiati tra due richieste. Questo di solito viene fatto aggiungendo un checksum al token. Questo checksum viene calcolato sugli ID di tutti gli elementi con questo timestamp. Così si finisce con un formato del token come questo: Timestamp_Offset_Checksum.

Per ulteriori informazioni su questo approccio, consultare il post sul blog " Impaginazione dell'API Web con token di continuazione ". Uno svantaggio di questo approccio è l'implementazione difficile in quanto vi sono molti casi angolari che devono essere presi in considerazione. Ecco perché le librerie come il token di continuazione possono essere utili (se si utilizza Java / un linguaggio JVM). Disclaimer: sono l'autore del post e un coautore della biblioteca.


4

Penso che attualmente la tua API stia effettivamente rispondendo come dovrebbe. I primi 100 record nella pagina nell'ordine generale degli oggetti che stai mantenendo. La tua spiegazione dice che stai usando un qualche tipo di id di ordinamento per definire l'ordine dei tuoi oggetti per l'impaginazione.

Ora, se vuoi che la pagina 2 inizi sempre da 101 e finisca a 200, allora devi rendere il numero di voci nella pagina come variabile, poiché sono soggette a cancellazione.

Dovresti fare qualcosa come il seguente pseudocodice:

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)

1
Sono d'accordo. anziché eseguire una query in base al numero di record (che non è affidabile) è necessario eseguire una query in base all'ID. Modifica la tua query (x, m) per indicare "ritorna a m record ORDINATI da ID, con ID> x", quindi puoi semplicemente impostare x sull'id massimo dal risultato della query precedente.
John Henckel,

È vero, o ordina gli ID o se hai un campo commerciale concreto su cui ordinare come create_date ecc.
mickeymoon

4

Solo per aggiungere a questa risposta Kamilk: https://www.stackoverflow.com/a/13905589

Dipende molto da quanto grande set di dati stai lavorando. I set di dati di piccole dimensioni funzionano efficacemente sull'impaginazione offset, ma i set di dati in tempo reale di grandi dimensioni richiedono l' impaginazione del cursore.

Ho trovato un meraviglioso articolo su come Slack ha evoluto l'impaginazione della sua api man mano che aumentavano le serie di dati che spiegavano i lati positivi e negativi in ​​ogni fase: https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12


3

Ci ho pensato a lungo e alla fine ho trovato la soluzione che descriverò di seguito. È un grande passo avanti nella complessità, ma se fai questo passo, finirai con quello che stai veramente cercando, che è risultati deterministici per richieste future.

Il tuo esempio di un elemento che viene eliminato è solo la punta dell'iceberg. Cosa succede se si filtra color=bluema qualcuno cambia i colori degli elementi tra le richieste? Recuperare tutti gli elementi in modo affidabile è impossibile ... a meno che ... non implementiamo la cronologia delle revisioni .

L'ho implementato ed è in realtà meno difficile di quanto mi aspettassi. Ecco cosa ho fatto:

  • Ho creato una singola tabella changelogscon una colonna ID con incremento automatico
  • Le mie entità hanno un idcampo, ma questa non è la chiave primaria
  • Le entità hanno un changeIdcampo che è sia la chiave primaria che una chiave esterna per i log delle modifiche.
  • Ogni volta che un utente crea, aggiorna o elimina un record, il sistema inserisce un nuovo record changelogs, acquisisce l'id e lo assegna a una nuova versione dell'entità, che quindi inserisce nel DB
  • Le mie query selezionano il massimo changeId (raggruppato per ID) e si uniscono automaticamente per ottenere le versioni più recenti di tutti i record.
  • I filtri vengono applicati ai record più recenti
  • Un campo stato tiene traccia dell'eliminazione di un elemento
  • L'identificativo massimo viene restituito al client e aggiunto come parametro di query nelle richieste successive
  • Poiché vengono create solo nuove modifiche, ogni singola changeIdrappresenta un'istantanea univoca dei dati sottostanti al momento della creazione della modifica.
  • Ciò significa che è possibile memorizzare nella cache i risultati delle richieste che contengono il parametro changeIdper sempre. I risultati non scadranno mai perché non cambieranno mai.
  • Ciò apre anche interessanti funzionalità come rollback / ripristino, sincronizzazione della cache del client, ecc. Tutte le funzionalità che beneficiano della cronologia delle modifiche.

Non ho capito bene. Come questo risolve il caso d'uso che hai citato? (Un campo casuale cambia nella cache e si desidera invalidare la cache)
U Avalos

Per eventuali modifiche apportate, basta guardare la risposta. Il server fornirà un nuovo changeId e lo userete nella vostra prossima richiesta. Per altre modifiche (apportate da altre persone), esegui il polling dell'ultima modifica ogni tanto e se è superiore alla tua, sai che ci sono modifiche in sospeso. Oppure si imposta un sistema di notifica (polling lungo. Push server, websocket) che avvisa il client quando ci sono cambiamenti in sospeso.
Stijn de Witt,

0

Un'altra opzione per l'impaginazione nelle API RESTFul è quella di utilizzare l'intestazione Link qui introdotta . Ad esempio Github lo usa come segue:

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"

I valori possibili per relsono: primo, ultimo, successivo, precedente . Ma usando l' Linkintestazione, potrebbe non essere possibile specificare total_count (numero totale di elementi).

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.