Najlepsze praktyki podziału na strony w interfejsie API

288

Chciałbym trochę pomóc w rozwiązaniu dziwnego przypadku przy tworzeniu stronicowanego API.

Podobnie jak wiele interfejsów API, ten paginuje duże wyniki. Jeśli zapytasz / foos, otrzymasz 100 wyników (tj. Foo # 1-100) i link do strony / foos? = 2, które powinny zwrócić foo # 101-200.

Niestety, jeśli foo # 10 zostanie usunięte z zestawu danych, zanim konsument API wykona następne zapytanie, / foos? Page = 2 spowoduje przesunięcie o 100 i zwróci foos # 102-201.

Jest to problem dla klientów API, którzy próbują wyciągnąć wszystkie foos - nie otrzymają foo # 101.

Jaka jest najlepsza praktyka, aby sobie z tym poradzić? Chcielibyśmy, aby był tak lekki, jak to możliwe (tj. Unikanie sesji obsługi żądań API). Przykłady z innych interfejsów API byłyby bardzo mile widziane!

2arrs2ells
źródło
1
w czym jest problem? wydaje mi się ok, tak czy inaczej, użytkownik dostanie 100 przedmiotów.
NARKOZ 14.12.12
2
Napotkałem ten sam problem i szukam rozwiązania. AFAIK, naprawdę nie ma solidnego mechanizmu gwarantującego to, jeśli każda strona wykona nowe zapytanie. Jedyne rozwiązanie, jakie mogę wymyślić, to utrzymać aktywną sesję i zachować zestaw wyników po stronie serwera, a zamiast wykonywać nowe zapytania dla każdej strony, wystarczy pobrać następny zestaw rekordów w pamięci podręcznej.
Jerry Dodge
31
Spójrz, jak twitter osiągnął to dev.twitter.com/rest/public/timelines
java_geek
1
@java_geek Jak aktualizowany jest parametr Since_id? Na stronie Twittera wygląda na to, że wysyłają oba żądania o tej samej wartości dla Since_id. Zastanawiam się, kiedy zostanie zaktualizowany, aby po dodaniu nowszych tweetów można było je uwzględnić?
Petar
1
@Petar Parametr Since_id musi zostać zaktualizowany przez konsumenta interfejsu API. Jeśli widzisz, przykład dotyczy klientów przetwarzających tweety
java_geek,

Odpowiedzi:

176

Nie jestem całkowicie pewien, w jaki sposób przetwarzane są twoje dane, więc to może, ale nie musi działać, ale czy zastanawiałeś się nad paginowaniem za pomocą pola sygnatury czasowej?

Kiedy pytasz / foos dostajesz 100 wyników. Twój interfejs API powinien następnie zwrócić coś takiego (przy założeniu JSON, ale jeśli potrzebuje XML, należy przestrzegać tych samych zasad):

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

}

Tylko uwaga, użycie tylko jednego znacznika czasu zależy od domyślnego „limitu” wyników. Możesz dodać wyraźny limit lub też użyć untilwłaściwości.

Znacznik czasu może być dynamicznie określany przy użyciu ostatniego elementu danych na liście. To wydaje się mniej więcej tak, jak Facebook paginuje w swoim API Graph (przewiń w dół, aby zobaczyć linki do stronicowania w formacie, który podałem powyżej).

Jednym z problemów może być dodanie elementu danych, ale na podstawie opisu wygląda na to, że zostaną dodane na końcu (jeśli nie, daj mi znać, a zobaczę, czy mogę to poprawić).

ramblinjan
źródło
30
Nie można zagwarantować, że znaczniki czasu są unikalne. Oznacza to, że można utworzyć wiele zasobów z tym samym znacznikiem czasu. Więc to podejście ma tę wadę, że następna strona może powtórzyć ostatnie (kilka?) Wpisy z bieżącej strony.
rubel
4
@prmatta Właściwie, w zależności od implementacji bazy danych znacznik czasu jest unikalny .
ramblinjan
2
@jandjorgensen Z twojego linku: „Typ danych znacznika czasu jest tylko wartością rosnącą i nie zachowuje daty ani godziny. ... W SQL Server 2008 i późniejszych nazwach znaczników czasu zmieniono nazwę na wierszversion , prawdopodobnie w celu lepszego odzwierciedlenia jego cel i wartość ”. Nie ma więc dowodów na to, że znaczniki czasu (te, które faktycznie zawierają wartość czasu) są unikalne.
Nolan Amy
3
@ jandjorgensen Podoba mi się twoja propozycja, ale czy nie potrzebujesz jakichś informacji w linkach do zasobów, więc wiemy, czy przejdziemy do poprzedniego, czy następnego? Sth like: „previous”: „ api.example.com/foo?before=TIMESTAMP ” „next”: „ api.example.com/foo?since=TIMESTAMP2Użylibyśmy również naszych identyfikatorów sekwencji zamiast znacznika czasu. Czy widzisz z tym jakieś problemy?
longliveenduro
5
Inną podobną opcją jest użycie pola nagłówka Link określonego w RFC 5988 (sekcja 5): tools.ietf.org/html/rfc5988#page-6
Anthony F
28

Masz kilka problemów.

Po pierwsze, masz przytoczony przykład.

Podobny problem występuje również w przypadku wstawienia wierszy, ale w tym przypadku użytkownik otrzymuje zduplikowane dane (prawdopodobnie łatwiejsze do zarządzania niż brakujące dane, ale nadal problem).

Jeśli nie tworzysz migawki oryginalnego zestawu danych, to tylko fakt.

Możesz poprosić użytkownika o wykonanie wyraźnej migawki:

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

Co powoduje:

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

Następnie możesz przeglądać strony przez cały dzień, ponieważ są one teraz statyczne. Może to być stosunkowo niewielka waga, ponieważ można po prostu uchwycić rzeczywiste klucze dokumentu, a nie całe wiersze.

Jeśli przypadek użycia polega na tym, że użytkownicy chcą (i potrzebują) wszystkich danych, możesz im je po prostu przekazać:

GET /query/12345?all=true

i po prostu wyślij cały zestaw.

Will Hartung
źródło
1
(Domyślny rodzaj foos jest według daty utworzenia, więc wstawienie wiersza nie stanowi problemu.)
2arrs2ells,
W rzeczywistości przechwytywanie tylko kluczy dokumentów nie wystarczy. W ten sposób będziesz musiał zapytać o pełne obiekty według identyfikatora, gdy użytkownik ich zażąda, ale być może już ich nie ma.
Scadge
27

Jeśli masz paginację, sortujesz dane według klucza. Dlaczego nie pozwolić klientom API dołączyć klucz ostatniego elementu poprzednio zwróconej kolekcji do adresu URL i dodać WHEREklauzulę do zapytania SQL (lub coś równoważnego, jeśli nie używasz SQL), aby zwracał tylko te elementy, dla których klucz jest większy niż ta wartość?

kamilk
źródło
4
Nie jest to zła sugestia, jednak tylko dlatego, że sortowanie według wartości nie oznacza, że ​​jest to „klucz”, czyli unikatowy.
Chris Peacock
Dokładnie. Na przykład w moim przypadku pole sortowania okazuje się datą i jest dalekie od unikalnego.
Sat Thiru,
19

Mogą istnieć dwa podejścia w zależności od logiki serwera.

Podejście 1: Gdy serwer nie jest wystarczająco inteligentny, aby obsłużyć stany obiektów.

Możesz wysłać wszystkie unikalne identyfikatory zapisywane w pamięci podręcznej na serwer, na przykład [„id1”, „id2”, „id3”, „id4”, „id5”, „id6”, „id7”, „id8”, „id9”, „id10”] i parametr boolowski, aby dowiedzieć się, czy żądasz nowych rekordów (ściągnij, aby odświeżyć), czy starych rekordów (załaduj więcej).

Twój serwer powinien być odpowiedzialny za zwracanie nowych rekordów (ładowanie większej liczby rekordów lub nowych rekordów poprzez ściąganie w celu odświeżenia), a także identyfikatorów usuniętych rekordów z [„id1”, „id2”, „id3”, „id4”, „id5”, „ id6 ”,„ id7 ”,„ id8 ”,„ id9 ”,„ id10 ”].

Przykład: - Jeśli chcesz załadować więcej, Twoje żądanie powinno wyglądać mniej więcej tak:

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

Załóżmy teraz, że żądasz starych rekordów (załaduj więcej) i załóżmy, że rekord „id2” został przez kogoś zaktualizowany, a rekordy „id5” i „id8” zostały usunięte z serwera, a następnie odpowiedź serwera powinna wyglądać mniej więcej tak:

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

Ale w tym przypadku, jeśli masz wiele lokalnych zapisanych w pamięci podręcznej danych, przypuśćmy, że 500, to łańcuch żądania będzie zbyt długi:

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

Podejście 2: Gdy serwer jest wystarczająco inteligentny, aby obsłużyć stany obiektów zgodnie z datą.

Możesz wysłać identyfikator pierwszego rekordu oraz ostatniego rekordu i czasu epoki poprzedniego żądania. W ten sposób Twoje żądanie jest zawsze małe, nawet jeśli masz dużą liczbę zapisanych w pamięci podręcznej rekordów

Przykład: - Jeśli chcesz załadować więcej, Twoje żądanie powinno wyglądać mniej więcej tak:

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

Twój serwer jest odpowiedzialny za zwrócenie identyfikatora usuniętych rekordów, które są usuwane po ostatnim czasie żądania, a także zwrócenie zaktualizowanego rekordu po czasie ostatniego żądania między „id1” a „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"]
}

Pociągnij by odświeżyć:-

wprowadź opis zdjęcia tutaj

Załaduj więcej

wprowadź opis zdjęcia tutaj

Mohd Iftekhar Qurashi
źródło
14

Znalezienie najlepszych praktyk może być trudne, ponieważ większość systemów z interfejsami API nie obsługuje tego scenariusza, ponieważ jest to skrajna przewaga lub zazwyczaj nie usuwają rekordów (Facebook, Twitter). Facebook faktycznie twierdzi, że każda „strona” może nie mieć żądanej liczby wyników z powodu filtrowania wykonanego po paginacji. https://developers.facebook.com/blog/post/478/

Jeśli naprawdę potrzebujesz zmieścić tę skrzynkę, musisz „pamiętać”, gdzie ją przerwałeś. Sugestia jandjorgensen jest na miejscu, ale użyłbym pola, które gwarantowałoby unikalność, jak klucz podstawowy. Może być konieczne użycie więcej niż jednego pola.

Po przepływie Facebooka możesz (i powinieneś) buforować strony, które już zamówiłeś, i po prostu zwróć te z filtrowanymi usuniętymi wierszami, jeśli zażądają strony, o którą już poprosiły.

Brent Baisley
źródło
2
To nie jest akceptowalne rozwiązanie. To zajmuje dużo czasu i pamięci. Wszystkie usunięte dane wraz z żądanymi danymi będą musiały być przechowywane w pamięci, która w ogóle może nie zostać użyta, jeśli ten sam użytkownik nie zażąda więcej wpisów.
Deepak Garg
3
Nie zgadzam się. Samo zachowanie unikalnych identyfikatorów w ogóle nie zajmuje dużo pamięci. Nie przechowuj danych w nieskończoność, tylko na „sesję”. Jest to łatwe dzięki memcache, wystarczy ustawić czas wygaśnięcia (tj. 10 minut).
Brent Baisley,
pamięć jest tańsza niż prędkość sieci / procesora. Więc jeśli utworzenie strony jest bardzo kosztowne (pod względem sieci lub wymaga dużej mocy obliczeniowej), to wyniki buforowania są poprawnym podejściem @DeepakGarg
U Avalos
9

Podział na strony jest na ogół operacją „użytkownika” i aby zapobiec przeciążeniu zarówno na komputerach, jak i ludzkim mózgu, zwykle podaje się podzbiór. Jednak zamiast myśleć, że nie otrzymamy całej listy, lepiej zapytać, czy to ma znaczenie?

Jeśli potrzebny jest dokładny widok przewijania na żywo, interfejsy API REST, które mają charakter żądania / odpowiedzi, nie są odpowiednie do tego celu. W tym celu należy rozważyć zdarzenia wysyłane przez serwer WebSockets lub HTML5, aby poinformować interfejs użytkownika o zmianach.

Teraz, jeśli istnieje potrzeba uzyskania migawki danych, po prostu zapewniłbym wywołanie API, które zapewnia wszystkie dane w jednym żądaniu bez podziału na strony. Pamiętaj, że potrzebujesz dużego strumienia danych wyjściowych bez tymczasowego ładowania go do pamięci.

W moim przypadku domyślnie wyznaczam niektóre wywołania API, aby umożliwić uzyskanie całej informacji (przede wszystkim danych tabeli referencyjnej). Możesz także zabezpieczyć te interfejsy API, aby nie zaszkodziły Twojemu systemowi.

Archimedes Trajano
źródło
8

Opcja A: Paginacja zestawu kluczy ze znacznikiem czasu

Aby uniknąć wspomnianych wad stronicowania offsetowego, można zastosować paginację opartą na zestawie kluczy. Zwykle jednostki mają znacznik czasu, który określa czas ich utworzenia lub modyfikacji. Tego znacznika czasu można użyć do podziału na strony: wystarczy przekazać znacznik czasu ostatniego elementu jako parametr zapytania dla następnego żądania. Serwer z kolei używa znacznika czasu jako kryterium filtru (np. 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"
    }
}

W ten sposób nie przegapisz żadnego elementu. To podejście powinno być wystarczające dla wielu przypadków użycia. Pamiętaj jednak, że:

  • Możesz napotkać niekończące się pętle, gdy wszystkie elementy na jednej stronie mają ten sam znacznik czasu.
  • Możesz dostarczyć wiele elementów do klienta wiele razy, gdy elementy z tym samym znacznikiem czasu nakładają się na dwie strony.

Możesz zmniejszyć te wady, zwiększając rozmiar strony i stosując znaczniki czasu z milisekundową precyzją.

Opcja B: Rozszerzone paginowanie zestawu kluczy z tokenem kontynuacji

Aby poradzić sobie ze wspomnianymi wadami zwykłego podziału na strony zestawu kluczy, możesz dodać przesunięcie do znacznika czasu i użyć tak zwanego „tokena kontynuacji” lub „kursora”. Przesunięcie jest pozycją elementu względem pierwszego elementu z tym samym znacznikiem czasu. Zwykle token ma format podobny do Timestamp_Offset. W odpowiedzi jest przekazywany klientowi i może zostać przesłany z powrotem na serwer w celu pobrania następnej strony.

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

Token „1512757072_2” wskazuje na ostatni element strony i stwierdza „klient już dostał drugi element ze znacznikiem czasu 1512757072”. W ten sposób serwer wie, gdzie kontynuować.

Pamiętaj, że musisz obsługiwać przypadki, w których elementy zostały zmienione między dwoma żądaniami. Zazwyczaj odbywa się to poprzez dodanie sumy kontrolnej do tokena. Ta suma kontrolna jest obliczana na podstawie identyfikatorów wszystkich elementów z tym znacznikiem czasu. Więc skończyć z tokena formacie jak poniżej: Timestamp_Offset_Checksum.

Aby uzyskać więcej informacji o tym podejściu, sprawdź post na blogu „ Paginacja interfejsu API sieci Web za pomocą tokenów kontynuacji ”. Wadą tego podejścia jest trudna implementacja, ponieważ istnieje wiele przypadków narożnych, które należy wziąć pod uwagę. Właśnie dlatego biblioteki takie jak token kontynuacji mogą być przydatne (jeśli używasz języka Java / JVM). Uwaga: Jestem autorem postu i współautorem biblioteki.

fauer
źródło
4

Myślę, że obecnie Twój interfejs API działa tak, jak powinien. Pierwsze 100 rekordów na stronie w ogólnej kolejności obsługiwanych obiektów. Twoje wyjaśnienie mówi, że używasz jakiegoś identyfikatora zamówienia, aby zdefiniować kolejność obiektów do stronicowania.

Teraz, jeśli chcesz, aby strona 2 zawsze zaczynała się od 101, a kończyła na 200, musisz wprowadzić liczbę wpisów na stronie jako zmienną, ponieważ podlegają one usunięciu.

Powinieneś zrobić coś takiego jak poniższy pseudokod:

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
źródło
1
Zgadzam się. zamiast zapytań według numeru rekordu (co nie jest wiarygodne), powinieneś zapytać według ID. Zmień zapytanie (x, m) na „powrót do m rekordów SORTOWANYCH według identyfikatora, z ID> x”, a następnie możesz po prostu ustawić x na maksymalny identyfikator z poprzedniego wyniku zapytania.
John Henckel
To prawda, posortuj według identyfikatorów lub jeśli masz jakieś konkretne pola biznesowe, na przykład
sort_date
4

Aby dodać do tej odpowiedzi Kamilk: https://www.stackoverflow.com/a/13905589

Wiele zależy od tego, jak duży zestaw danych pracujesz. Małe zestawy danych działają skutecznie w przypadku stronicowania offsetowego, ale duże zestawy danych w czasie rzeczywistym wymagają stronicowania kursora.

Znalazłem wspaniały artykuł o tym, jak Slack ewoluował paginację swojego interfejsu API, gdy zbiory danych wzrosły, wyjaśniając pozytywne i negatywne aspekty na każdym etapie: https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12

Shubham Srivastava
źródło
3

Długo się nad tym zastanawiałem i ostatecznie znalazłem rozwiązanie, które opiszę poniżej. To dość duży wzrost złożoności, ale jeśli to zrobisz, skończysz z tym, czego naprawdę szukasz, co jest deterministycznymi wynikami dla przyszłych żądań.

Twój przykład usuwania elementu to tylko wierzchołek góry lodowej. Co jeśli filtrujesz według, color=blueale ktoś zmienia kolory elementów między żądaniami? Rzetelne pobranie wszystkich elementów w sposób stronicowany jest niemożliwe ... chyba że ... wprowadzimy historię zmian .

Wdrożyłem to i jest to właściwie mniej trudne niż się spodziewałem. Oto co zrobiłem:

  • Utworzyłem pojedynczą tabelę changelogsz kolumną z identyfikatorem automatycznego przyrostu
  • Moje podmioty mają idpole, ale nie jest to klucz podstawowy
  • Jednostki mają changeIdpole, które jest zarówno kluczem podstawowym, jak i kluczem obcym do dzienników zmian.
  • Za każdym razem, gdy użytkownik tworzy, aktualizuje lub usuwa rekord, system wstawia nowy rekord changelogs, pobiera identyfikator i przypisuje go do nowej wersji encji, którą następnie wstawia do bazy danych
  • Moje zapytania wybierają maksymalny identyfikator zmiany (pogrupowane według identyfikatora) i łączą się, aby uzyskać najnowsze wersje wszystkich rekordów.
  • Filtry są stosowane do najnowszych rekordów
  • Pole stanu śledzi, czy element został usunięty
  • Maksymalny identyfikator zmiany jest zwracany do klienta i dodawany jako parametr zapytania w kolejnych żądaniach
  • Ponieważ tworzone są tylko nowe zmiany, każda z nich changeIdstanowi unikatową migawkę danych bazowych w momencie, gdy zmiana została utworzona.
  • Oznacza to, że możesz buforować wyniki żądań, które mają parametr changeIdw nich na zawsze. Wyniki nigdy nie wygasną, ponieważ nigdy się nie zmienią.
  • Otwiera to również ekscytujące funkcje, takie jak wycofywanie / przywracanie, synchronizowanie pamięci podręcznej klienta itp. Wszelkie funkcje korzystające z historii zmian.
Stijn de Witt
źródło
Jestem zmieszany. Jak to rozwiązać wspomniany przypadek użycia? (Losowe pole zmienia się w pamięci podręcznej i chcesz unieważnić pamięć podręczną)
U Avalos
W przypadku wszelkich zmian, które sam wprowadzisz, wystarczy spojrzeć na odpowiedź. Serwer dostarczy nową zmianę i użyjesz jej przy następnym żądaniu. W przypadku innych zmian (wprowadzonych przez innych ludzi) albo sondujesz najnowszą zmianę Raz na jakiś czas, a jeśli jest ona wyższa niż Twoja, wiesz, że są wyjątkowe zmiany. Lub skonfigurujesz system powiadomień (długie odpytywanie. Push serwera, websockets), który ostrzega klienta, gdy są zaległe zmiany.
Stijn de Witt
0

Inną opcją paginacji w interfejsach API RESTFul jest użycie wprowadzonego tutaj nagłówka łącza . Na przykład Github używa go w następujący sposób:

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"

Możliwe wartości relto: first, last, next, previous . Ale używając Linknagłówka, może nie być możliwe określenie total_count (całkowita liczba elementów).

adnanmuttaleb
źródło