リソースのリストを返すREST APIを構築する際、大規模なデータセットをどのように扱うかを検討することが重要です。単一のAPIレスポンスで数千、あるいは数百万ものレコードを返すことは非現実的であり、サーバーとクライアントの両方で重大なパフォーマンス問題、高いメモリ消費、そして貧弱なユーザーエクスペリエンスにつながる可能性があります。この問題に対する標準的な解決策がページネーションです。これは、大規模なデータセットを「ページ」と呼ばれるより小さく管理しやすい塊に分割し、それらを順番に提供することを含みます。このチュートリアルでは、REST APIで様々なページネーション戦略を実装するための技術的な手順を案内します。
開発チームが最大限の生産性で共同作業できる、統合されたオールインワンプラットフォームをお探しですか?
Apidogはあなたの全ての要求を満たし、Postmanをはるかに手頃な価格で置き換えます!
なぜページネーションが不可欠なのか?
実装の詳細に入る前に、リソースのコレクションを扱うAPIにとってページネーションがなぜ不可欠な機能なのかを簡単に触れておきましょう。
- パフォーマンス: 大量のデータを要求し転送することは遅くなる可能性があります。ページネーションは各リクエストのペイロードサイズを削減し、応答時間の短縮とサーバー負荷の軽減につながります。
- リソース消費: レスポンスが小さいほど、それを生成するサーバーとそれを解析するクライアントの両方で消費されるメモリが少なくなります。これは、モバイルクライアントやリソースが限られた環境では特に重要です。
- レート制限とクォータ: 多くのAPIはレート制限を課しています。ページネーションは、一度にすべてを取得しようとするのではなく、データを小さな塊で時間的に取得することで、クライアントがこれらの制限内に留まるのを助けます。
- ユーザーエクスペリエンス: APIを消費するUIにとって、データをページで提示することは、膨大なリストや非常に長いスクロールでユーザーを圧倒するよりもはるかにユーザーフレンドリーです。
- データベース効率: データセット全体を取得するのに比べて、データのサブセットを取得することは、特に適切なインデックスが配置されている場合、データベースへの負担が一般的に少なくなります。
一般的なページネーション戦略
ページネーションを実装するための一般的な戦略はいくつかあり、それぞれにトレードオフがあります。最も人気のあるもの、すなわちオフセット/リミット(しばしばページベースと呼ばれる)とカーソルベース(キーセットまたはシークページネーションとも呼ばれる)を探求します。
1. オフセット/リミット(またはページベース)ページネーション
これは間違いなく最もシンプルで広く採用されているページネーション方法です。クライアントが2つの主要なパラメータを指定できるようにすることで機能します。
offset
: データセットの先頭からスキップするレコード数。limit
: 単一ページで返すレコードの最大数。
あるいは、クライアントは以下を指定することもあります。
page
: 取得したいページ番号。pageSize
(またはper_page
,limit
): 1ページあたりのレコード数。
offset
は、offset = (page - 1) * pageSize
の式を使用して page
と pageSize
から計算できます。
技術的な実装手順:
アイテムのリストを返すAPIエンドポイント /items
があると仮定します。
a. APIリクエストパラメータ:
クライアントは次のようなリクエストを行います。GET /items?offset=20&limit=10
(最初の20件をスキップして10件のアイテムを取得)
またはGET /items?page=3&pageSize=10
(1ページあたり10件で3ページ目を取得、これはoffset=20, limit=10と同等です)。
クライアントがこれらのパラメータを提供しない場合のために、デフォルト値(例: limit=20
, offset=0
または page=1
, pageSize=20
)を設定するのが良い習慣です。また、クライアントが過度に大きな数のレコードを要求してサーバーに負担をかけるのを防ぐために、最大 limit
または pageSize
を強制します。
b. バックエンドロジック(概念):
サーバーがこのリクエストを受け取ると、これらのパラメータをデータベースクエリに変換する必要があります。
// Example in Java with Spring Boot
@GetMapping("/items")
public ResponseEntity<PaginatedResponse<Item>> getItems(
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "20") int limit
) {
// Validate limit to prevent abuse
if (limit > 100) {
limit = 100; // Enforce a max limit
}
List<Item> items = itemRepository.findItemsWithOffsetLimit(offset, limit);
long totalItems = itemRepository.countTotalItems(); // For metadata
// Construct and return paginated response
// ...
}
c. データベースクエリ(SQL例):
ほとんどのリレーショナルデータベースは、オフセットとリミット句を直接サポートしています。
PostgreSQLまたはMySQLの場合:
SELECT *
FROM items
ORDER BY created_at DESC -- Consistent ordering is crucial for stable pagination
LIMIT 10 -- This is the 'limit' parameter
OFFSET 20; -- This is the 'offset' parameter
SQL Serverの場合(古いバージョンでは ROW_NUMBER()
を使用する場合があります):
SELECT *
FROM items
ORDER BY created_at DESC
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;
Oracleの場合:
SELECT *
FROM (
SELECT i.*, ROWNUM rnum
FROM (
SELECT *
FROM items
ORDER BY created_at DESC
) i
WHERE ROWNUM <= 20 + 10 -- offset + limit
)
WHERE rnum > 20; -- offset
順序付けに関する重要な注意: オフセット/リミットページネーションを信頼性のあるものにするためには、基になるデータセットは一貫性がありユニーク(またはほぼユニーク)なキー、あるいはキーの組み合わせでソートされている必要があります。リクエスト間でアイテムの順序が変わる可能性がある場合(例: 新しいアイテムが挿入されたり、ソート順に影響する形でアイテムが更新されたりする場合)、ユーザーはページを移動する際に重複したアイテムを見たり、アイテムを見逃したりする可能性があります。一般的な選択肢は、作成タイムスタンプまたはプライマリIDでソートすることです。
d. APIレスポンス構造:
良いページネーションレスポンスは、現在のページのデータだけでなく、クライアントがナビゲートするのに役立つメタデータも含むべきです。
{
"data": [
// array of items for the current page
{ "id": "item_21", "name": "Item 21", ... },
{ "id": "item_22", "name": "Item 22", ... },
// ... up to 'limit' items
{ "id": "item_30", "name": "Item 30", ... }
],
"pagination": {
"offset": 20,
"limit": 10,
"totalItems": 5000, // Total number of items available
"totalPages": 500, // Calculated as ceil(totalItems / limit)
"currentPage": 3 // Calculated as (offset / limit) + 1
},
"links": { // HATEOAS links for navigation
"self": "/items?offset=20&limit=10",
"first": "/items?offset=0&limit=10",
"prev": "/items?offset=10&limit=10", // Null if on the first page
"next": "/items?offset=30&limit=10", // Null if on the last page
"last": "/items?offset=4990&limit=10"
}
}
HATEOAS(Hypermedia as the Engine of Application State)リンク(self
, first
, prev
, next
, last
)を提供することは、RESTのベストプラクティスです。これにより、クライアントは自分でURLを構築することなくページ間をナビゲートできます。
オフセット/リミットページネーションの利点:
- シンプルさ: 理解しやすく、実装が容易です。
- ステートフルなナビゲーション: 任意の特定のページに直接ナビゲートできます(例: 「50ページにジャンプ」)。
- 広くサポートされている:
OFFSET
およびLIMIT
に対するデータベースサポートは一般的です。
オフセット/リミットページネーションの欠点:
- 大きなオフセットでのパフォーマンス劣化:
offset
の値が増加するにつれて、データベースは遅くなる可能性があります。データベースはしばしば、offset
行を破棄する前に、offset + limit
のすべての行をスキャンする必要があります。これは深いページでは非効率になる可能性があります。 - データスキュー/見逃しアイテム: ユーザーがページネーションを行っている間にデータセットに新しいアイテムが追加されたり、既存のアイテムが削除されたりすると、データの「ウィンドウ」がずれる可能性があります。これにより、ユーザーは異なる2つのページで同じアイテムを見たり、アイテムを完全に見逃したりする可能性があります。これは、頻繁に更新されるデータセットで特に問題となります。たとえば、2ページ目(アイテム11-20)にいるときに、リストの先頭に新しいアイテムが追加されると、3ページ目を要求したときに、以前はアイテム21だったものがアイテム22になります。正確なタイミングと削除パターンによっては、新しいアイテム21を見逃したり、重複を見たりする可能性があります。
2. カーソルベース(キーセット/シーク)ページネーション
カーソルベースのページネーションは、オフセット/リミットのいくつかの欠点、特に大規模なデータセットでのパフォーマンスとデータの一貫性の問題に対処します。絶対的なオフセットに依存する代わりに、データセット内の特定のアイテムを指す「カーソル」を使用します。クライアントは、このカーソルの「後」または「前」のアイテムを要求します。
カーソルは通常、前のページで取得された最後のアイテムのソートキーの値(複数可)をエンコードした不透明な文字列です。
技術的な実装手順:
a. APIリクエストパラメータ:
クライアントは次のようなリクエストを行います。GET /items?limit=10
(最初のページの場合)
そして、後続のページの場合:GET /items?limit=10&after_cursor=opaquestringrepresentinglastitemid
または、後方ページネーションの場合(あまり一般的ではありませんが可能):GET /items?limit=10&before_cursor=opaquestringrepresentingfirstitemid
limit
パラメータは引き続きページサイズを定義します。
b. カーソルとは?
カーソルは以下であるべきです。
- クライアントにとって不透明: クライアントは内部構造を理解する必要はありません。単に1つのレスポンスから受け取り、次のリクエストでそれを送り返すだけです。
- ユニークで順序付けられた列(複数可)に基づいている: 通常、これはプライマリID(UUIDv1やデータベースシーケンスのように順次である場合)またはタイムスタンプ列です。単一の列が十分にユニークでない場合(例: 複数のアイテムが同じタイムスタンプを持つ場合)、列の組み合わせが使用されます(例:
timestamp
+id
)。 - エンコードおよびデコード可能: URLセーフであることを保証するためにBase64エンコードされることが多いです。それは単にID自体であったり、
{ "last_id": 123, "last_timestamp": "2023-10-27T10:00:00Z" }
のようなJSONオブジェクトをBase64エンコードしたものであったりします。
c. バックエンドロジック(概念):
// Example in Java with Spring Boot
@GetMapping("/items")
public ResponseEntity<CursorPaginatedResponse<Item>> getItems(
@RequestParam(defaultValue = "20") int limit,
@RequestParam(required = false) String afterCursor
) {
// Validate limit
if (limit > 100) {
limit = 100;
}
// Decode cursor to get the last seen item's properties
// e.g., LastSeenItemDetails lastSeen = decodeCursor(afterCursor);
// If afterCursor is null, it's the first page.
List<Item> items;
if (afterCursor != null) {
DecodedCursor decoded = decodeCursor(afterCursor); // e.g., { lastId: "some_uuid", lastCreatedAt: "timestamp" }
items = itemRepository.findItemsAfter(decoded.getLastCreatedAt(), decoded.getLastId(), limit);
} else {
items = itemRepository.findFirstPage(limit);
}
String nextCursor = null;
if (!items.isEmpty() && items.size() == limit) {
// Assuming items are sorted, the last item in the list is used to generate the next cursor
Item lastItemOnPage = items.get(items.size() - 1);
nextCursor = encodeCursor(lastItemOnPage.getCreatedAt(), lastItemOnPage.getId());
}
// Construct and return cursor paginated response
// ...
}
// Helper methods for encoding/decoding cursors
// private DecodedCursor decodeCursor(String cursor) { ... }
// private String encodeCursor(Timestamp createdAt, String id) { ... }
d. データベースクエリ(SQL例):
重要なのは、カーソルからのソートキーの値に基づいてレコードをフィルタリングする WHERE
句を使用することです。ORDER BY
句はカーソルの構成と一致している必要があります。
created_at
(降順)でソートし、次に id
(降順)でソートして、created_at
がユニークでない場合の安定した順序付けのタイブレーカーと仮定します。
最初のページの場合:
SELECT *
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 10;
後続のページの場合、カーソルが last_created_at_from_cursor
および last_id_from_cursor
にデコードされた場合:
SELECT *
FROM items
WHERE (created_at, id) < (CAST('last_created_at_from_cursor' AS TIMESTAMP), CAST('last_id_from_cursor' AS UUID)) -- Or appropriate types
-- For ascending order, it would be >
-- The tuple comparison (created_at, id) < (val1, val2) is a concise way to write:
-- WHERE created_at < 'last_created_at_from_cursor'
-- OR (created_at = 'last_created_at_from_cursor' AND id < 'last_id_from_cursor')
ORDER BY created_at DESC, id DESC
LIMIT 10;
このタイプのクエリは、特に (created_at, id)
にインデックスがある場合に非常に効率的です。データベースは無関係な行をスキャンすることなく、開始位置に直接「シーク」できます。
e. APIレスポンス構造:
{
"data": [
// array of items for the current page
{ "id": "item_N", "createdAt": "2023-10-27T10:05:00Z", ... },
// ... up to 'limit' items
{ "id": "item_M", "createdAt": "2023-10-27T10:00:00Z", ... }
],
"pagination": {
"limit": 10,
"hasNextPage": true, // boolean indicating if there's more data
"nextCursor": "base64encodedcursorstringforitem_M" // opaque string
// Potentially a "prevCursor" if bi-directional cursors are supported
},
"links": {
"self": "/items?limit=10&after_cursor=current_request_cursor_if_any",
"next": "/items?limit=10&after_cursor=base64encodedcursorstringforitem_M" // Null if no next page
}
}
カーソルベースのページネーションでは、totalPages
または totalItems
は通常提供されないことに注意してください。これらを計算するにはテーブル全体のスキャンが必要であり、パフォーマンス上の利点の一部が失われるためです。これらが厳密に必要な場合は、別のエンドポイントや推定値が提供される場合があります。
カーソルベースページネーションの利点:
- 大規模データセットでのパフォーマンス: 一般的に、オフセット/リミットよりも深いページネーションで優れたパフォーマンスを発揮します。データベースはインデックスを使用してカーソルの位置に効率的にシークできるためです。
- 動的なデータセットでの安定性: データが頻繁に追加または削除される場合でも、カーソルが特定のアイテムに固定されるため、見逃しや重複の発生が少なくなります。カーソルの前のアイテムが削除されても、後続のアイテムには影響しません。
- 無限スクロールに適している: 「次のページ」モデルは、無限スクロールUIと自然に適合します。
カーソルベースページネーションの欠点:
- 「ページにジャンプ」機能がない: ユーザーは任意のページ番号に直接ナビゲートできません(例: 「5ページ」)。ナビゲーションは厳密に順次(次/前)です。
- 実装がより複雑: カーソルの定義と管理、特に複数のソート列や複雑なソート順の場合、より複雑になる可能性があります。
- ソートの制限: ソート順は固定され、カーソルに使用される列に基づいている必要があります。カーソルでソート順を動的に変更することは複雑です。
適切な戦略の選択
オフセット/リミットとカーソルベースのページネーションのどちらを選択するかは、特定の要件によって異なります。
- オフセット/リミットは、次のような場合に十分です。
- データセットが比較的小さいか、頻繁に変更されない場合。
- 任意のページにジャンプできる機能が重要な機能である場合。
- 実装のシンプルさが高い優先度である場合。
- 非常に深いページでのパフォーマンスが主要な懸念事項ではない場合。
- カーソルベースは、次のような場合に一般的に推奨されます。
- 非常に大規模で頻繁に変更されるデータセットを扱っている場合。
- 大規模なパフォーマンスとページネーション中のデータの一貫性が最優先である場合。
- 順次ナビゲーション(無限スクロールなど)が主要なユースケースである場合。
- 合計ページ数を表示したり、特定のページへのジャンプを許可したりする必要がない場合。
一部のシステムでは、ハイブリッドアプローチが使用されたり、異なるユースケースやエンドポイントに対して異なる戦略が提供されたりすることもあります。
ページネーション実装のベストプラクティス
選択した戦略に関係なく、これらのベストプラクティスに従ってください。
- 一貫したパラメータ命名: ページネーションパラメータには明確で一貫した名前を使用してください(例:
limit
,offset
,page
,pageSize
,after_cursor
,before_cursor
)。API全体で1つの命名規則(例:camelCase
またはsnake_case
)に固執してください。 - ナビゲーションリンクの提供(HATEOAS): レスポンス例で示したように、
self
,next
,prev
,first
,last
(該当する場合)のリンクを含めてください。これにより、APIの発見性が向上し、クライアントがURL構築ロジックから分離されます。 - デフォルト値と最大制限:
limit
には常に妥当なデフォルト値(例: 10または25)を設定してください。- クライアントが多すぎるデータを要求してサーバーを圧倒するのを防ぐために、最大
limit
を強制してください(例: 1ページあたり最大100レコード)。無効な値が要求された場合は、エラーを返すか、制限をキャップしてください。
- 明確なAPIドキュメント: ページネーション戦略を徹底的に文書化してください。
- 使用されるパラメータを説明してください。
- リクエストとレスポンスの例を提供してください。
- デフォルト値と最大制限を明確にしてください。
- カーソルがどのように使用されるか(該当する場合)を説明してください。不透明であるべき内部構造は開示しないでください。
- 一貫したソート: すべてのページネーションリクエストで、基になるデータが一貫してソートされていることを確認してください。オフセット/リミットの場合、これはデータスキューを避けるために不可欠です。カーソルベースの場合、ソート順はカーソルがどのように構築され解釈されるかを決定します。プライマリソート列に重複する値がある場合は、ユニークなタイブレーカー列(プライマリIDなど)を使用してください。
- エッジケースの処理:
- 空の結果: 空のデータ配列と適切なページネーションメタデータ(例:
totalItems: 0
またはhasNextPage: false
)を返してください。 - 無効なパラメータ: クライアントが無効なページネーションパラメータ(例: 負の制限、非整数のページ番号)を提供した場合、
400 Bad Request
エラーを返してください。 - カーソルが見つからない(カーソルベースの場合): 提供されたカーソルが無効であるか、削除されたアイテムを指している場合、動作を決定してください:
404 Not Found
または400 Bad Request
を返すか、最初のページに gracefully fall back してください。
- 合計件数の考慮事項:
- オフセット/リミットの場合、
totalItems
およびtotalPages
を提供するのが一般的です。COUNT(*)
が非常に大きなテーブルで遅くなる可能性があることに注意してください。これがボトルネックになる場合は、データベース固有の最適化や推定値を検討してください。 - カーソルベースの場合、
totalItems
はパフォーマンスのために省略されることが多いです。必要な場合は、推定件数や、それを計算する別のエンドポイント(非同期で)の提供を検討してください。
- エラー処理: エラーに対して適切なHTTPステータスコードを返してください(例: 不正な入力の場合は
400
、データ取得中のサーバーエラーの場合は500
)。 - セキュリティ: ページネーションメカニズム自体ではありませんが、ページネーションされるデータが認証ルールを尊重していることを確認してください。ユーザーは、表示を許可されているデータのみをページネーションできるべきです。
- キャッシング: ページネーションされたレスポンスはしばしばキャッシュ可能です。オフセットベースのページネーションの場合、
GET /items?page=2&pageSize=10
は非常にキャッシュ可能です。カーソルベースの場合、GET /items?limit=10&after_cursor=XYZ
もキャッシュ可能です。ページネーションリンクがどのように生成され消費されるかとうまく連携するキャッシング戦略を確保してください。基になるデータが頻繁に変更される場合は、無効化戦略を考慮する必要があります。
高度なトピック(簡単な言及)
- 無限スクロール: カーソルベースのページネーションは、無限スクロールUIに自然に適合します。クライアントは最初のページを取得し、ユーザーが下部に近づくにつれて、
nextCursor
を使用して後続のアイテムセットを取得します。 - 複雑なフィルタリングとソートを伴うページネーション: ページネーションを動的なフィルタリングおよびソートパラメータと組み合わせる場合、以下を確認してください。
- オフセット/リミットの場合:
totalItems
の件数がフィルタリングされたデータセットを正確に反映していること。 - カーソルベースの場合: カーソルがソートとフィルタリングの両方の状態をエンコードしていること。これらは「次」の意味に影響を与える場合です。これはカーソル設計を大幅に複雑にする可能性があります。多くの場合、フィルタやソート順が変更された場合、ページネーションは新しいビューの「最初のページ」にリセットされます。
- GraphQLページネーション: GraphQLには、ページネーションを処理するための独自の標準化された方法があり、しばしば「Connections」と呼ばれます。これは通常、カーソルベースのページネーションを使用し、エッジ(カーソル付きのアイテム)とページ情報を返すための定義された構造を持っています。GraphQLを使用している場合は、その規約(例: Relay Cursor Connections specification)に従ってください。
結論
ページネーションを正しく実装することは、スケーラブルでユーザーフレンドリーなREST APIを構築するための基本です。オフセット/リミットページネーションは始めるのがよりシンプルですが、カーソルベースのページネーションは、大規模で動的なデータセットに対して優れたパフォーマンスと一貫性を提供します。各戦略の技術的な詳細を理解し、アプリケーションのニーズに最適なものを選択し、実装とAPI設計のベストプラクティスに従うことで、規模に関係なく、APIがデータをクライアントに効率的に配信することを保証できます。APIコンシューマーにスムーズなエクスペリエンスを提供するために、常に明確なドキュメントと堅牢なエラー処理を優先することを忘れないでください。