APIページネーションのベストプラクティス


288

私が作成しているページ分割されたAPIを使用して、奇妙なエッジケースを処理するのに役立ついくつかのものが欲しいです。

多くのAPIと同様に、これは大きな結果をページ分割します。/ foosに対してクエリを実行すると、100の結果(つまり、foo#1-100)と、foo#101-200を返す/ foos?page = 2へのリンクが表示されます。

残念ながら、APIコンシューマが次のクエリを実行する前にfoo#10がデータセットから削除された場合、/ foos?page = 2は100だけオフセットされ、foos#102-201を返します。

これは、すべてのfooをプルしようとしているAPIコンシューマーの問題です-foo#101を受信しません。

これを処理するためのベストプラクティスは何ですか?できる限り軽量化したい(つまり、APIリクエストのセッションの処理を避けたい)。他のAPIの例をいただければ幸いです。


1
ここで問題は何ですか?どちらの方法でもユーザーは100アイテムを取得できます。
NARKOZ

2
私はこれと同じ問題に直面しており、解決策を探していました。私の知る限り、各ページが新しいクエリを実行する場合、これを達成するための確かな保証されたメカニズムは本当にありません。私が考えることができる唯一の解決策は、アクティブなセッションを維持し、サーバー側で結果セットを維持することです。ページごとに新しいクエリを実行するのではなく、次のキャッシュされたレコードのセットを取得します。
ジェリーダッジ

31
Twitterがどのようにしてこのdev.twitter.com/rest/public/timelinesを
java_geek

1
@java_geek since_idパラメータはどのように更新されますか?TwitterのWebページでは、since_idに同じ値を使用して両方のリクエストを行っているようです。いつ更新されて、新しいツイートが追加された場合でも、それを説明できるようになるのでしょうか。
Petar

1
@Petar since_idパラメータは、APIのコンシューマが更新する必要があります。ご覧の
とおり

回答:


175

データの処理方法が完全にわからないので、これが機能する場合と機能しない場合がありますが、タイムスタンプフィールドによるページ番号付けを検討しましたか?

/ foosをクエリすると、100の結果が得られます。APIは次のようなものを返す必要があります(JSONを想定していますが、XMLが必要な場合も同じ原則に従うことができます)。

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

}

ただ注意してください。1つのタイムスタンプのみを使用すると、結果の暗黙の「制限」に依存します。明示的な制限を追加するか、untilプロパティを使用することもできます。

タイムスタンプは、リストの最後のデータ項目を使用して動的に決定できます。これは、多かれ少なかれFacebookがGraph APIでページ分割する方法です(下にスクロールして、上記で指定した形式のページ分割リンクを表示します)。

1つの問題は、データ項目を追加する場合ですが、説明に基づいて、それらが最後に追加されるように思われます(そうでない場合は、お知らせください。これを改善できるかどうか確認します)。


29
タイムスタンプは一意であることが保証されていません。つまり、同じタイムスタンプで複数のリソースを作成できます。したがって、このアプローチには、次のページが現在のページの最後の(数少ない)エントリを繰り返す可能性があるという欠点があります。
2013


2
@jandjorgensenリンクから:「タイムスタンプデータタイプはインクリメントする数値であり、日付または時間を保持しません。...SQL Server 2008以降では、タイムスタンプタイプはおそらくおそらくより適切に反映するためにrowversion名前が変更されました。目的と価値。」したがって、タイムスタンプ(実際には時間値を含むもの)が一意であることを示す証拠はありません。
Nolan Amy

3
@jandjorgensen私はあなたの提案を気に入っていますが、リソースリンクになんらかの情報が必要ではないので、前に進むか次に進むかわかりますか?Sth like: "previous": " api.example.com/foo?before=TIMESTAMP " "next": " api.example.com/foo?since=TIMESTAMP2 "また、タイムスタンプの代わりにシーケンスIDを使用します。何か問題はありますか?
longliveenduro 2014年

5
別の同様のオプションは、RFC 5988(セクション5)で指定されているリンクヘッダーフィールドを使用することです:tools.ietf.org/html/rfc5988#page-6
Anthony F

28

あなたにはいくつかの問題があります。

まず、引用した例があります。

行が挿入された場合にも同様の問題が発生しますが、この場合、ユーザーは重複したデータを取得します(データの欠落よりも間違いなく管理は簡単ですが、それでも問題です)。

元のデータセットのスナップショットを作成していない場合、これは現実の問題です。

ユーザーに明示的なスナップショットを作成させることができます。

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

どの結果:

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

次に、静的であるため、1日中ページングできます。行全体ではなく実際のドキュメントキーをキャプチャできるため、これはかなり軽量になる可能性があります。

ユーザーがすべてのデータを必要とする(そして必要とする)ユースケースである場合は、単純にそれをユーザーに与えることができます。

GET /query/12345?all=true

キット全体を送るだけです。


1
foosの

実際には、ドキュメントキーだけをキャプチャするだけでは不十分です。この方法では、ユーザーがリクエストしたときに、IDで完全なオブジェクトをクエリする必要がありますが、オブジェクトが存在しない可能性があります。
Scadge

27

ページネーションがある場合は、データを何らかのキーでソートすることもできます。APIクライアントに、以前に返されたコレクションの最後の要素のキーをURLに含めさせWHERE、SQLクエリ(またはSQLを使用していない場合は同等のもの)に句を追加して、その要素のみが返されるようにしないでください。キーはこの値より大きいですか?


4
これは悪い提案ではありませんが、値で並べ替えたからといって、それが「キー」、つまり一意であるとは限りません。
Chris Peacock

丁度。たとえば私の場合、並べ替えフィールドはたまたま日付であり、一意ではありません。
2018

19

サーバー側のロジックに応じて2つの方法があります。

アプローチ1:サーバーがオブジェクトの状態を処理するのに十分スマートでない場合。

キャッシュされたすべてのレコードの一意のIDをサーバーに送信できます(例:["id1"、 "id2"、 "id3"、 "id4"、 "id5"、 "id6"、 "id7"、 "id8"、 "id9"、 "id10"]と、新しいレコード(プルして更新)または古いレコード(さらに読み込む)をリクエストしているかどうかを確認するブールパラメータ。

サーバーは、新しいレコード(プルして更新するか、プル経由で新しいレコードをロードする)と、["id1"、 "id2"、 "id3"、 "id4"、 "id5"、 "から削除されたレコードのIDを返す必要があります。 id6 "、" id7 "、" id8 "、" id9 "、" id10 "]。

例:- ロードをリクエストする場合、リクエストは次のようになります:-

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

ここで、古いレコード(さらに読み込む)をリクエストし、「id2」レコードが誰かによって更新され、「id5」および「id8」レコードがサーバーから削除されたとすると、サーバーの応答は次のようになります。

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

しかし、この場合、ローカルにキャッシュされたレコードがたくさんある場合、500を想定すると、リクエスト文字列は次のように長くなります。

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

アプローチ2:サーバーが日付に従ってオブジェクトの状態を処理するのに十分スマートである場合。

最初のレコードのIDと最後のレコードのID、および前の要求エポック時間を送信できます。このようにして、キャッシュされたレコードが大量にある場合でも、リクエストは常に小さくなります。

例:- ロードをリクエストする場合、リクエストは次のようになります:-

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

サーバーは、last_request_timeの後に削除された削除済みレコードのIDを返し、last_request_timeの後に "id1"と "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"]
}

プルして更新:-

ここに画像の説明を入力してください

もっと読み込む

ここに画像の説明を入力してください


14

APIを備えたほとんどのシステムはこのシナリオに対応していません。極端なエッジであるか、通常はレコードを削除しないため(Facebook、Twitter)、ベストプラクティスを見つけるのは難しいかもしれません。Facebookは実際には、ページ付け後に行われるフィルタリングのために、各「ページ」には要求された結果の数がない可能性があると述べています。 https://developers.facebook.com/blog/post/478/

このエッジケースに本当に対応する必要がある場合は、中断したところを「記憶」する必要があります。jandjorgensenの提案はほぼ適切ですが、主キーのように一意であることが保証されているフィールドを使用します。複数のフィールドを使用する必要がある場合があります。

Facebookのフローに従って、すでにリクエストされたページをキャッシュし、すでにリクエストされたページをリクエストした場合に、削除された行がフィルタリングされたページを返すことができます。


2
これは許容できる解決策ではありません。これはかなりの時間とメモリを消費します。削除されたすべてのデータと要求されたデータはメモリに保持する必要があり、同じユーザーがそれ以上のエントリを要求しない場合は、まったく使用されない可能性があります。
Deepak Garg 2013

3
同意しません。一意のIDを保持するだけでは、メモリをあまり使用しません。「セッション」のためだけに、データを無期限に保持する必要はありません。これはmemcacheで簡単です。有効期限(つまり10分)を設定するだけです。
ブレントベイズリー2013

メモリはネットワーク/ CPU速度よりも安価です。したがって、ページの作成が非常に高価な場合(ネットワークの観点から、またはCPUに負荷がかかる場合)、結果のキャッシュは@DeepakGargの有効なアプローチです
U Avalos

9

ページネーションは一般に「ユーザー」操作であり、コンピューターと人間の脳の両方で過負荷を防ぐために、一般にサブセットを与えます。ただし、リスト全体を取得できないと考えるよりも、問題があるのかでしょう。

正確なライブスクロールビューが必要な場合、本来リクエスト/レスポンスであるREST APIはこの目的にはあまり適していません。このため、変更を処理するときにフロントエンドに知らせるために、WebSocketまたはHTML5サーバー送信イベントを検討する必要があります。

必要があればデータのスナップショットを取得は、ページネーションなしで1つのリクエストですべてのデータを提供するAPI呼び出しを提供します。ちなみに、大きなデータセットがある場合は、出力をメモリに一時的に読み込まずにストリーミングできるものが必要です。

私の場合、情報全体(主に参照テーブルデータ)を取得できるように、いくつかのAPI呼び出しを暗黙的に指定しています。これらのAPIを保護して、システムに害を及ぼさないようにすることもできます。


8

オプションA:タイムスタンプを使用したキーセットのページ分割

前述のオフセットページネーションの欠点を回避するために、キーセットベースのページネーションを使用できます。通常、エンティティには、作成または変更時刻を示すタイムスタンプがあります。このタイムスタンプはページ分割に使用できます。次のリクエストのクエリパラメータとして最後の要素のタイムスタンプを渡すだけです。サーバは、順番に、フィルタ基準としてタイムスタンプを使用しています(例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"
    }
}

このようにして、要素を見逃すことはありません。このアプローチは、多くのユースケースに十分対応できます。ただし、次の点に注意してください。

  • 1つのページのすべての要素のタイムスタンプが同じ場合、無限ループに陥る可能性があります。
  • 同じタイムスタンプを持つ要素が2つのページに重なっている場合、クライアントに多くの要素を何度も配信できます。

ページサイズを増やし、ミリ秒の精度のタイムスタンプを使用することで、これらの欠点を少なくすることができます。

オプションB:継続トークンを使用した拡張キーセットのページ分割

通常のキーセットページネーションの前述の欠点を処理するには、タイムスタンプにオフセットを追加し、いわゆる「継続トークン」または「カーソル」を使用できます。オフセットは、同じタイムスタンプを持つ最初の要素に対する要素の位置です。通常、トークンはのような形式ですTimestamp_Offset。応答でクライアントに渡され、次のページを取得するためにサーバーに送信できます。

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

トークン「1512757072_2」はページの最後の要素を指し、「クライアントはすでにタイムスタンプ1512757072の2番目の要素を取得している」と述べています。このようにして、サーバーは続行する場所を認識します。

2つのリクエスト間で要素が変更されたケースを処理する必要があることに注意してください。これは通常、チェックサムをトークンに追加することによって行われます。このチェックサムは、このタイムスタンプを持つすべての要素のIDに対して計算されます。したがって、次のようなトークン形式になりますTimestamp_Offset_Checksum

このアプローチの詳細については、ブログ投稿をチェックしてください "継続トークンを使用したWeb APIページネーション。このアプローチの欠点は、考慮しなければならない多くのコーナーケースがあるため、トリッキーな実装です。これが、継続トークンのようなライブラリが便利な理由です(Java / a JVM言語を使用している場合)。免責事項:私は投稿の著者であり、図書館の共著者です。


4

私は現在、あなたのapiが実際にそれがすべき方法で応答していると思います。維持しているオブジェクトの全体的な順序でのページの最初の100レコード。あなたの説明は、ページ付けのためのオブジェクトの順序を定義するために、ある種の順序付けIDを使用していることを示しています。

ここで、ページ2が常に101から始まり200で終わるようにしたい場合は、ページのエントリ数を変数として作成する必要があります。これらのエントリは削除される可能性があるためです。

あなたは以下の疑似コードのようなことをするべきです:

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
同意する。レコード番号でクエリするのではなく(これは信頼できません)、IDでクエリする必要があります。query(x、m)を「IDでソートされたIDでxまでソートされた最大m個のレコードを返す」を意味するように変更すると、単純にxを前のクエリ結果からの最大IDに設定できます。
ジョンヘンケル

True、IDで並べ替えるか、creation_dateなどの並べ替えを行う具体的なビジネスフィールドがある場合
mickeymoon

4

Kamilkによるこの回答に追加するには:https ://www.stackoverflow.com/a/13905589

作業しているデータセットのサイズに大きく依存します。小さなデータセットはオフセットページネーションで効果的に機能しますが、大きなリアルタイムデータセットは必要ですカーソルページネーション。

データセットが増加し、あらゆる段階でポジティブとネガティブを説明するように、SlackがどのようにAPIのページネーションを進化させたかについての素晴らしい記事を見つけました:https : //slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12


3

私はこれについて長く懸命に考え、最終的には以下で説明する解決策を見つけました。これはかなり複雑なステップですが、このステップを実行すると、最終的にはあなたが本当に求めているものになってしまいます。これは、将来のリクエストの確定的な結果です。

削除されたアイテムの例は、氷山の一角にすぎません。フィルタリングしているcolor=blueが、リクエスト間で誰かがアイテムの色を変更した場合はどうなりますか?ページングされた方法ですべてのアイテムを確実にフェッチすることは不可能です... 変更履歴を実装しない限り...

私はそれを実装しましたが、実際には思ったより難しくありません。これが私がしたことです:

  • 単一のテーブルを作成しました changelogs自動インクリメントID列を持つ
  • 私のエンティティにはidフィールドがありますが、これは主キーではありません
  • エンティティには、変更changeIdログの主キーと外部キーの両方であるフィールドがあります。
  • ユーザーがレコードを作成、更新、または削除するたびに、システムはに新しいレコードを挿入しchangelogs、IDを取得してエンティティの新しいバージョンに割り当て、それをDBに挿入します
  • 私のクエリでは、最大のchangeId(IDでグループ化)を選択し、それを自己結合して、すべてのレコードの最新バージョンを取得します。
  • フィルターは最新のレコードに適用されます
  • 状態フィールドは、アイテムが削除されたかどうかを追跡します
  • max changeIdがクライアントに返され、後続のリクエストでクエリパラメータとして追加されます
  • 新しい変更のみが作成されるため、すべての changeIdの変更は、変更が作成された時点での基になるデータの一意のスナップショットを表します。
  • これは、パラメータを含むリクエストの結果をchangeId永久にキャッシュできることを意味します。結果は変更されないため、結果が期限切れになることはありません。
  • これにより、ロールバック/リバート、クライアントキャッシュの同期などのエキサイティングな機能も利用できるようになります。変更履歴を活用できる機能はすべてあります。

よくわかりません。これはあなたが述べたユースケースをどのように解決しますか?(キャッシュ内のランダムなフィールドが変更され、キャッシュを無効にしたい)
U Avalos '26年

自分で行った変更については、応答を確認するだけです。サーバーは新しいchangeIdを提供し、次のリクエストでそれを使用します。他の変更(他の人が行った変更)の場合、最新のchangeIdを時々ポーリングし、それが自分の変更IDより大きい場合は、未処理の変更があることを確認します。または、未処理の変更がある場合にクライアントに警告する通知システム(ロングポーリング、サーバープッシュ、Webソケット)を設定します。
Stijn de Witt

0

RESTFul APIのページネーションの別のオプションは、ここで紹介されているリンクヘッダーを使用することです。たとえば、Github 次のように使用します。

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"

可能な値はrelfirst、last、next、previousです。ただし、Linkヘッダーを使用すると、total_count(要素の総数)を指定できない場合があります。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.