Paginação de APIs no Backend

Por Felipe Barbosa, 28/02/2023

APIBackend

Introdução

Paginação no backend é uma solução que resolve o problema de ter uma quantidade substancial de dados a serem retornados em uma requisição.

Um exemplo de situação desse tipo seria a de retornar o feed de um usuário em uma rede social. Esse feed pode ter potencialmente milhares de registros, o que poderia levar uma quantidade impraticável de tempo para carregar, além de ter o potencial de esgotar a memória da aplicação cliente.

Ao invés de retornar esses dados todos de uma vez, a paginação divide-os em partes que podem ser enviadas e processadas de forma adequada.

O objetivo deste artigo é analisar as possíveis abordagens para implementação desse mecanismo, observando em quais situações cada uma se adéqua melhor.

Uma distinção importante: implementação vs. API

O foco da discussão aqui está relacionada à implementação de um sistema paginação. Porém, uma vez implementado o mecanismo, deve-se escolher uma forma de expô-lo às aplicações consumidoras.

Caso estejamos usando REST, a maneira recomendada de expor essa funcionalidade é através de querystring's e meta-informações no corpo da requisição. Vamos imaginar que estamos falando de um endpoint que retorna pedidos. Então, se esse endpoint implementa paginação, teríamos algo como:

https://example.com/orders?page=1&limit=10

O que nos retornaria os 10 primeiros pedidos (em uma ordem pré-definida) na primeira página. No corpo da resposta, teríamos um campo para a lista de pedidos "records" e um campo para informações da paginação "pagination", algo como:

{
  "records": [
    ...
  ],

  "pagination": {
    "current": "https://example.com/orders?page=1&limit=10",

    "next": "https://example.com/orders?page=2&limit=10",

    "previous": null,

    "last": "https://example.com/orders?page=26&limit=10",

    "first": "https://example.com/orders?page=1&limit=10"
  }
}

A abordagem tradicional

A abordagem mais simples para esse problema é usar mecanismos de OFFSET e LIMIT presente na maioria dos sistemas de banco de dados. Em bancos de dados relacionais, essas são exatamente as palavras reservadas usadas para esse propósito. Entretanto, raramente escrevemos SQL puro em nossas aplicações.

O seu ORM de preferência certamente tem uma API para acessar esse mecanismo do banco de dados em algum lugar. No caso do Prisma (Node.js), esse funcionalidade pode ser acessada com:

const results = await prisma.post.findMany({
  skip: 3,
  take: 4,
});

O que significa que estamos buscando 4 registros, "pulando" os três primeiros, tal como na imagem abaixo:

Dessa forma, fica extremamente simples implementar paginação.

Se estivermos lidando com paginação começando com o índice 1 e com 10 itens em cada página, para acessar a n-ésima página, basta especificar um offset de (n - 1) * itens e um limit de 10.

A quantidade de itens e de páginas disponíveis geralmente são retornados no corpo da requisição, como mostrado anteriormente.

Uma outra possibilidade é usar offset ao invés de page para especificar que parte da lista de recurso queremos visualizar.

Com offset, especifica-se exatamente quantos items devemos "pular" até alcançar os items que desejamos visualizar. Por exemplo, o endpoint

https://example.com/orders?offeset=30&limit=10

Retornaria os 10 items depois dos 30 primeiros.

Nesse caso a aplicação cliente fica responsável por ajustar o offset de cada página, o que pode ser mais sujeito a erros.

Por que essa pode não ser a melhor opção

Existem dois problemas cruciais com essa abordagem:

  1. Eficiência
  2. Consistência

Eficiência

Em primeiro lugar, quando usamos OFFSET em um banco de dados relacional, o banco efetivamente tem que percorrer todos os registros presente no banco até chegar no registro especificado.

Se usamos um offset de 20,000, por exemplo, o banco necessariamente precisa percorrer esses 20,000 registros antes de chegar no registro que desejamos.

Como se pode ver, isso não é nada eficiente, caso estejamos lidando com um numero suficientemente grande. Em outras palavras, trata-se uma solução que não escala bem.

Consistência

Outro problema surge quando temos um conjunto de dados dinâmico que muda com o tempo. Com essa abordagem, se dados forem inseridos ou excluídos no momento em que estamos atravessando os registros, poderemos ter omissão ou duplicidade nos resultados.

Por exemplo, imagine que temos 10 itens e estamos percorrendo os mesmo de 5 em 5. Se no momento em que acessarmos a segunda página um novo item for adicionado na primeira, teremos um item da primeira página mostrado novamente na segunda.

Ficou difícil de entender? O diagrama abaixo descreve melhor a situação:

legenda

1,2,3: páginas
X: item da lista
Y: novo item
O: item específico que estamos observando

lista inicial

1   2
X   X
X   X
X   X
X   X
O   X

👇 novo item adicionado (Y)
1   2   3
Y   O   X
X   X
X   X
X   X
X   X

Nesse exemplo, vê-se que o item O aparecerá na página 1 e novamente na página 2, caso acessemos esta última no momento da inserção.

Apesar desses dois pontos, se não estamos lidando com uma serviço que tem uma número enorme de registros e em que consistência não é algo de primeira importância (ou o conjunto de dados muda pouco), então essa abordagem se torna aceitável. Um bom exemplo disso seria um blog pessoal.

Abordagem alternativa

Uma forma alternativa de paginar os dados é usando um parâmetro que sirva para ordenar os dados de forma única e, dessa forma, ser usado como cursor. Geralmente esses parâmetros são indexados no banco de dados. Um bom exemplo é a chave primária de uma tabela ou um carimbo de data/hora de criação/atualização. Vejamos como isso funciona com um exemplo.

Numa primeira requisição, pedimos por itens sem especificar páginas ou offsets, especificando, opcionalmente, um limit (caso não seja especificado, a maioria das API's terá um limite padrão):

https://example.com/orders?limit=10

No corpo da requisição, recebemos nos metadados um campo que pode ser usado para receber mais dados a partir do último item requisitado. Esse parâmetro pode se chamar, por exemplo, after_id. Em geral, recebemos um link para o próximo conjunto de dados e não o parâmetro em si, o que é mais conveniente. Esse link teria um forma como:

https://example.com/orders?after_id=53&limit=10

after_id funciona como cursor, sinalizando que os itens devem ser requisitador a partir do id especificado por ele.

Essa requisição, por sua vez, nos daria acesso a mais dois links para acessar items posteriores e anteriores, governados pelo parâmetro after_id ou algum outro que pudesse cumprir essa função.

A grande desvantagem desse método é que não é possível pular páginas: para acessar a n-ésima página precisamos percorrer todas as anteriores, sempre tomando nosso "cursor" como referência.

Em contra-partida, trata-se de um método muito mais eficiente, porque usa um campo indexado da tabela para fazer as consultas. Portanto não haverá perda de performance mesmo se for necessário atravessar um grande quantidade de items.

Além disso, com essa abordagem temos consistência garantida: nunca teremos duplicação de itens enquanto percorremos os registros.

Esse é um excelente método para ser usado para infinite_scrolling, por exemplo, já que não é necessário acessar páginas específicas, mas simplesmente manter um constante (e performático) fluxo de registros ordenados.

Conclusão

As duas abordagens de paginação apresentadas são suficientes para cobrir a maioria dos casos de uso. É importante observar as características de cada uma para poder decidir qual utilizar em qual situação.

É isto! Espero que tenha gostado do artigo. Se tiver alguma dúvida ou sugestão, não hesite em entrar em contato.

Cheers!

Gostando até aqui? ✍️

Receba atualizações de conteúdo diretamente na sua caixa de entrada!