Mejores prácticas de paginación de API

288

Me encantaría obtener ayuda para manejar un caso marginal extraño con una API paginada que estoy construyendo.

Al igual que muchas API, esta página ofrece grandes resultados. Si consulta / foos, obtendrá 100 resultados (es decir, foo # 1-100), y un enlace a / foos? Page = 2 que debería devolver foo # 101-200.

Desafortunadamente, si foo # 10 se elimina del conjunto de datos antes de que el consumidor de API realice la siguiente consulta, / foos? Page = 2 se compensará en 100 y devolverá foos # 102-201.

Este es un problema para los consumidores de API que intentan atraer a todos los foos: no recibirán el foo # 101.

¿Cuál es la mejor práctica para manejar esto? Nos gustaría que sea lo más ligero posible (es decir, evitar el manejo de sesiones para solicitudes de API). ¡Los ejemplos de otras API serían muy apreciados!

2arrs2ells
fuente
1
¿Cuál es el problema aquí? me parece bien, de cualquier manera el usuario obtendrá 100 artículos.
NARKOZ
2
He estado enfrentando este mismo problema y buscando una solución. AFAIK, realmente no hay un mecanismo sólido garantizado para lograr esto, si cada página ejecuta una nueva consulta. La única solución que se me ocurre es mantener una sesión activa y mantener el conjunto de resultados en el lado del servidor, y en lugar de ejecutar nuevas consultas para cada página, simplemente tome el siguiente conjunto de registros en caché.
Jerry Dodge
31
Eche un vistazo a cómo Twitter logra esto dev.twitter.com/rest/public/timelines
java_geek
1
@java_geek ¿Cómo se actualiza el parámetro since_id? En la página web de Twitter parece que están haciendo ambas solicitudes con el mismo valor para since_id. Me pregunto cuándo se actualizará para que, si se agregan nuevos tweets, se puedan tener en cuenta.
Petar
1
@Petar El consumidor de la API debe actualizar el parámetro since_id. Si ve, el ejemplo allí se refiere a clientes que procesan tweets
java_geek

Respuestas:

175

No estoy completamente seguro de cómo se manejan sus datos, por lo que esto puede o no funcionar, pero ¿ha considerado paginar con un campo de marca de tiempo?

Cuando consulta / foos obtiene 100 resultados. Su API debería devolver algo como esto (suponiendo JSON, pero si necesita XML, se pueden seguir los mismos principios):

{
    "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, solo usar una marca de tiempo se basa en un "límite" implícito en sus resultados. Es posible que desee agregar un límite explícito o también usar una untilpropiedad.

La marca de tiempo se puede determinar dinámicamente utilizando el último elemento de datos de la lista. Esto parece ser más o menos cómo Facebook pagina en su API Graph (desplácese hacia abajo para ver los enlaces de paginación en el formato que di anteriormente).

Un problema puede ser si agrega un elemento de datos, pero según su descripción, parece que se agregarán al final (si no, hágamelo saber y veré si puedo mejorar en esto).

ramblinjan
fuente
29
No se garantiza que las marcas de tiempo sean únicas. Es decir, se pueden crear múltiples recursos con la misma marca de tiempo. Entonces, este enfoque tiene el inconveniente de que la página siguiente puede repetir las últimas (¿pocas?) Entradas de la página actual.
rublo
44
@prmatta En realidad, dependiendo de la implementación de la base de datos, se garantiza que la marca de tiempo sea única .
ramblinjan
2
@jandjorgensen Desde su enlace:. "El tipo de datos de marca de tiempo es sólo un número incremental y no conserva una fecha o una hora ... En SQL Server 2008 y más tarde, el tipo de marca de tiempo se ha renombrado a rowversion , presumiblemente para mejor reflejar su propósito y valor ". Así que no hay evidencia aquí de que las marcas de tiempo (aquellas que realmente contienen un valor de tiempo) sean únicas.
Nolan Amy
3
@jandjorgensen Me gusta tu propuesta, pero ¿no necesitarías algún tipo de información en los enlaces de recursos, para que sepamos si vamos a la anterior o la siguiente? Algo así como: "anterior": " api.example.com/foo?before=TIMESTAMP " "next": " api.example.com/foo?since=TIMESTAMP2 " También usaríamos nuestros identificadores de secuencia en lugar de una marca de tiempo. ¿Ves algún problema con eso?
longliveenduro
55
Otra opción similar es usar el campo de encabezado de enlace especificado en RFC 5988 (sección 5): tools.ietf.org/html/rfc5988#page-6
Anthony F
28

Tienes varios problemas

Primero, tiene el ejemplo que citó.

También tiene un problema similar si se insertan filas, pero en este caso el usuario obtiene datos duplicados (posiblemente más fácil de administrar que los datos faltantes, pero sigue siendo un problema).

Si no está capturando instantáneamente el conjunto de datos original, entonces esto es solo una realidad.

Puede hacer que el usuario realice una instantánea explícita:

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

Cuales resultados:

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

Luego puede paginar eso todo el día, ya que ahora es estático. Esto puede ser razonablemente ligero, ya que puede capturar las claves de documento reales en lugar de las filas completas.

Si el caso de uso es simplemente que sus usuarios desean (y necesitan) todos los datos, simplemente puede dárselos:

GET /query/12345?all=true

y solo envíe el kit completo.

Will Hartung
fuente
1
(Clasificación predeterminado de Foos es por fecha de creación, por lo que la inserción fila no es un problema.)
2arrs2ells
En realidad, capturar solo claves de documento no es suficiente. De esta manera, tendrá que consultar los objetos completos por ID cuando el usuario los solicite, pero es posible que ya no existan.
Scadge
27

Si tiene paginación, también ordena los datos por alguna clave. ¿Por qué no permitir que los clientes API incluyan la clave del último elemento de la colección devuelta anteriormente en la URL y agreguen una WHEREcláusula a su consulta SQL (o algo equivalente, si no está utilizando SQL) para que solo devuelva aquellos elementos para los cuales la clave es mayor que este valor?

kamilk
fuente
44
Esta no es una mala sugerencia, sin embargo, solo porque ordene por un valor no significa que sea una 'clave', es decir, única.
Chris Peacock
Exactamente. Por ejemplo, en mi caso, el campo de clasificación es una fecha, y está lejos de ser único.
Sáb Thiru
19

Puede haber dos enfoques dependiendo de la lógica del lado del servidor.

Enfoque 1: cuando el servidor no es lo suficientemente inteligente como para manejar estados de objetos.

Puede enviar todos los ID únicos de registro en caché al servidor, por ejemplo ["id1", "id2", "id3", "id4", "id5", "id6", "id7", "id8", "id9", "id10"] y un parámetro booleano para saber si está solicitando nuevos registros (extraer para actualizar) o registros antiguos (cargar más).

Su servidor debe ser responsable de devolver nuevos registros (cargar más registros o registros nuevos mediante extracción para actualizar), así como los ID de los registros eliminados de ["id1", "id2", "id3", "id4", "id5", " id6 "," id7 "," id8 "," id9 "," id10 "].

Ejemplo: - Si está solicitando cargar más, su solicitud debería verse así: -

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

Ahora suponga que está solicitando registros antiguos (cargue más) y suponga que alguien actualiza el registro "id2" y los registros "id5" e "id8" se eliminan del servidor, entonces su respuesta del servidor debería verse así:

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

Pero en este caso, si tiene muchos registros en caché locales, supongamos 500, entonces su cadena de solicitud será demasiado larga como esta:

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

Enfoque 2: cuando el servidor es lo suficientemente inteligente como para manejar estados de objetos según la fecha.

Puede enviar la identificación del primer registro y el último registro y el tiempo de solicitud anterior. De esta manera, su solicitud siempre es pequeña, incluso si tiene una gran cantidad de registros en caché

Ejemplo: - Si está solicitando cargar más, su solicitud debería verse así: -

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

Su servidor es responsable de devolver los id de los registros eliminados que se eliminan después de last_request_time, así como devolver el registro actualizado después de last_request_time entre "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"]
}

Tire para actualizar: -

ingrese la descripción de la imagen aquí

Carga más

ingrese la descripción de la imagen aquí

Mohd Iftekhar Qurashi
fuente
14

Puede ser difícil encontrar las mejores prácticas, ya que la mayoría de los sistemas con API no se adaptan a este escenario, porque es una ventaja extrema, o por lo general no eliminan registros (Facebook, Twitter). Facebook realmente dice que cada "página" puede no tener la cantidad de resultados solicitados debido al filtrado realizado después de la paginación. https://developers.facebook.com/blog/post/478/

Si realmente necesita acomodar este caso extremo, debe "recordar" dónde lo dejó. La sugerencia de jandjorgensen es casi acertada, pero usaría un campo garantizado para ser único como la clave principal. Es posible que deba usar más de un campo.

Siguiendo el flujo de Facebook, puede (y debería) almacenar en caché las páginas ya solicitadas y simplemente devolver aquellas con filas eliminadas filtradas si solicitan una página que ya habían solicitado.

Brent Baisley
fuente
2
Esta no es una solución aceptable. Consume mucho tiempo y memoria. Todos los datos eliminados junto con los datos solicitados deberán mantenerse en la memoria, lo que podría no utilizarse en absoluto si el mismo usuario no solicita más entradas.
Deepak Garg
3
Estoy en desacuerdo. Solo mantener las ID únicas no usa mucha memoria en absoluto. No debe retener los datos indefinidamente, solo para la "sesión". Esto es fácil con Memcache, solo establezca la duración de caducidad (es decir, 10 minutos).
Brent Baisley
la memoria es más barata que la velocidad de la red / CPU. Entonces, si crear una página es muy costoso (en términos de red o es intensivo en CPU), entonces los resultados de almacenamiento en caché es un enfoque válido @DeepakGarg
U Avalos
9

La paginación es generalmente una operación de "usuario" y para evitar la sobrecarga tanto en las computadoras como en el cerebro humano, generalmente se le asigna un subconjunto. Sin embargo, en lugar de pensar que no obtenemos la lista completa, puede ser mejor preguntar ¿importa?

Si se necesita una vista de desplazamiento en vivo precisa, las API REST que son de naturaleza solicitud / respuesta no son adecuadas para este propósito. Para esto, debe considerar WebSockets o Eventos enviados por el servidor HTML5 para informar a su front end cuando se trata de cambios.

Ahora, si es necesario obtener una instantánea de los datos, solo proporcionaría una llamada API que proporciona todos los datos en una solicitud sin paginación. Eso sí, necesitaría algo que hiciera la transmisión de la salida sin cargarla temporalmente en la memoria si tiene un conjunto de datos grande.

Para mi caso, implícitamente designo algunas llamadas API para permitir obtener toda la información (principalmente datos de la tabla de referencia). También puede proteger estas API para que no dañe su sistema.

Arquímedes Trajano
fuente
8

Opción A: paginación de conjunto de claves con una marca de tiempo

Para evitar los inconvenientes de la paginación de desplazamiento que ha mencionado, puede usar la paginación basada en el conjunto de claves. Por lo general, las entidades tienen una marca de tiempo que indica su hora de creación o modificación. Esta marca de tiempo se puede usar para paginación: simplemente pase la marca de tiempo del último elemento como parámetro de consulta para la próxima solicitud. El servidor, a su vez, usa la marca de tiempo como criterio de filtro (por ejemplo 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 esta manera, no te perderás ningún elemento. Este enfoque debería ser lo suficientemente bueno para muchos casos de uso. Sin embargo, tenga en cuenta lo siguiente:

  • Puede encontrarse con bucles sin fin cuando todos los elementos de una sola página tienen la misma marca de tiempo.
  • Puede entregar muchos elementos varias veces al cliente cuando los elementos con la misma marca de tiempo se superponen a dos páginas.

Puede hacer que esos inconvenientes sean menos probables aumentando el tamaño de la página y utilizando marcas de tiempo con una precisión de milisegundos.

Opción B: paginación de conjunto de claves extendida con un token de continuación

Para manejar los inconvenientes mencionados de la paginación normal del conjunto de claves, puede agregar un desplazamiento a la marca de tiempo y usar el llamado "Token de Continuación" o "Cursor". El desplazamiento es la posición del elemento con respecto al primer elemento con la misma marca de tiempo. Por lo general, el token tiene un formato como Timestamp_Offset. Se pasa al cliente en la respuesta y se puede volver a enviar al servidor para recuperar la página siguiente.

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

El token "1512757072_2" apunta al último elemento de la página y dice "el cliente ya obtuvo el segundo elemento con la marca de tiempo 1512757072". De esta manera, el servidor sabe dónde continuar.

Tenga en cuenta que debe manejar los casos en que los elementos se cambiaron entre dos solicitudes. Esto generalmente se hace agregando una suma de verificación al token. Esta suma de comprobación se calcula sobre los ID de todos los elementos con esta marca de tiempo. Así que terminamos con un formato de token de la siguiente manera: Timestamp_Offset_Checksum.

Para obtener más información sobre este enfoque, consulte la publicación del blog " Paginación de API web con tokens de continuación ". Un inconveniente de este enfoque es la implementación complicada ya que hay muchos casos de esquina que deben tenerse en cuenta. Es por eso que las bibliotecas como el token de continuación pueden ser útiles (si está utilizando lenguaje Java / a JVM). Descargo de responsabilidad: soy el autor de la publicación y coautor de la biblioteca.

Phauer
fuente
4

Creo que actualmente tu API está respondiendo realmente como debería. Los primeros 100 registros de la página en el orden general de los objetos que está manteniendo. Su explicación le dice que está utilizando algún tipo de identificación de pedido para definir el orden de sus objetos para la paginación.

Ahora, en caso de que desee que la página 2 siempre comience desde 101 y termine en 200, debe hacer que el número de entradas en la página sea variable, ya que están sujetas a eliminación.

Debe hacer algo como el pseudocódigo siguiente:

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
fuente
1
Estoy de acuerdo. en lugar de consultar por número de registro (que no es confiable) debe consultar por ID. Cambie su consulta (x, m) para que signifique "regresar a m registros ORDENADOS por ID, con ID> x", luego simplemente puede establecer x en la identificación máxima del resultado de la consulta anterior.
John Henckel
Es cierto, ya sea en especie o identificadores de si tiene algún campo de negocio concreta para ordenar como creation_date etc.
mickeymoon
4

Solo para agregar a esta respuesta por Kamilk: https://www.stackoverflow.com/a/13905589

Depende mucho del tamaño del conjunto de datos en el que esté trabajando. Los conjuntos de datos pequeños funcionan eficazmente en la paginación de desplazamiento, pero los conjuntos de datos en tiempo real de gran tamaño requieren paginación del cursor.

Encontré un artículo maravilloso sobre cómo Slack evolucionó la paginación de su API a medida que aumentaron los conjuntos de datos explicando los aspectos positivos y negativos en cada etapa: https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12

Shubham Srivastava
fuente
3

He pensado mucho sobre esto y finalmente terminé con la solución que describiré a continuación. Es un gran avance en la complejidad, pero si haces este paso, terminarás con lo que realmente buscas, que son resultados deterministas para futuras solicitudes.

Su ejemplo de un elemento que se está eliminando es solo la punta del iceberg. ¿Qué sucede si está filtrando color=bluepero alguien cambia los colores de los elementos entre las solicitudes? Es imposible recuperar todos los elementos de forma confiable y paginada ... a menos que ... implementemos el historial de revisiones .

Lo he implementado y en realidad es menos difícil de lo que esperaba. Esto es lo que hice:

  • Creé una sola tabla changelogscon una columna de ID de incremento automático
  • Mis entidades tienen un idcampo, pero esta no es la clave principal
  • Las entidades tienen un changeIdcampo que es tanto la clave principal como una clave externa para los registros de cambios.
  • Cada vez que un usuario crea, actualiza o elimina un registro, el sistema inserta un nuevo registro changelogs, toma el id y lo asigna a una nueva versión de la entidad, que luego inserta en el DB
  • Mis consultas seleccionan el changeId máximo (agrupado por id) y se unen para obtener las versiones más recientes de todos los registros.
  • Los filtros se aplican a los registros más recientes.
  • Un campo de estado realiza un seguimiento de si un elemento se elimina
  • Max changeId se devuelve al cliente y se agrega como parámetro de consulta en solicitudes posteriores
  • Debido a que solo se crean nuevos cambios, cada uno changeIdrepresenta una instantánea única de los datos subyacentes en el momento en que se creó el cambio.
  • Esto significa que puede almacenar en caché los resultados de las solicitudes que tienen el parámetro changeIden ellos para siempre. Los resultados nunca caducarán porque nunca cambiarán.
  • Esto también abre funciones interesantes como deshacer / revertir, sincronizar el caché del cliente, etc. Cualquier función que se beneficie del historial de cambios.
Stijn de Witt
fuente
estoy confundido. ¿Cómo resuelve esto el caso de uso que mencionaste? (Un campo aleatorio cambia en el caché y desea invalidar el caché)
U Avalos
Para cualquier cambio que haga usted mismo, solo mire la respuesta. El servidor proporcionará un nuevo changeId y lo usará en su próxima solicitud. Para otros cambios (realizados por otras personas), puede sondear el último changeId de vez en cuando y si es más alto que el suyo, sabe que hay cambios pendientes. O puede configurar un sistema de notificación (sondeo largo, envío de servidor, sockets web) que alerta al cliente cuando hay cambios pendientes.
Stijn de Witt
0

Otra opción para la paginación en las API RESTFul es utilizar el encabezado de enlace que se presenta aquí . Por ejemplo, Github lo usa de la siguiente manera:

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"

Los valores posibles para relson: primero, último, siguiente, anterior . Pero al usar el Linkencabezado, puede que no sea posible especificar total_count (número total de elementos).

adnanmuttaleb
fuente