こんにちは。私は株式会社ブックリスタのプロダクト開発部の姚と申します。
現在、コミックROLLY(運営:株式会社ソニー・ミュージックエンタテインメント)のバックエンド開発を担当しています。
この記事では、DynamoDB を使用した API ページネーションの作成方法を紹介します。
はじめに
なぜ DynamoDB を使用するのか
DynamoDB は大量の非構造化データの処理に適しており、スケーラビリティと柔軟性に優れています。
データの性質や成長の見込みによっては、DynamoDB が最適な選択肢になることがあります。
ページネーション方式
一般的なデータベースのページネーションは、主にオフセット型とカーソル型の 2 種類が存在します。
オフセット型
オフセット型は、指定した offset から limit 件のデータを取得する方法です。RDBMS では一般的によく使われていますが、DynamoDB ではサポートされていません。
GET /items?limit=<limit>&offset=<offset>
リクエストした limit と offset の値から、次のページを取得する場合の offset の値が決まるため、レスポンスにはページングに関する情報を含める必要がありません。
{ "items": [ ... ] }
カーソル型(本記事で紹介する方法)
カーソル型は、直前のページの最後のデータと limit 件数を指定して、次のページを取得する方法です。
本記事ではカーソル型ページネーションの実現方式を紹介します。
GET /items?limit=<limit>&lastKey=<lastKey>
レスポンスには、取得したデータと次のページのための lastKey を返します。
{ "items": [ ... ], "lastKey": "xxx" }
カーソル型のページネーションはオフセット型と比べて、以下のメリットとデメリットがあります。
メリット
- 重複・不足データが発生しない: 連続してデータを取得する際、データの増減が発生しても、データの重複や漏れが発生しません
- パフォーマンス向上: カーソル方式を採用しているため、後のページに移動してもレスポンス速度が低下しません
デメリット
- 任意のページに直接移動できません
- クライアント側が移動したページのカーソル情報を保持すれば、擬似的なページ移動が可能です
- クエリのソートで使用されるカラムに、インデックスを貼る必要があります
- 実装が少し複雑になります
前提知識
DynamoDB のインデックス
DynamoDB テーブル内のデータのクエリを行う場合、インデックス(プライマリキー もしくは セカンダリインデックス)を指定することで、効率的にデータを取得できます。
プライマリキー
テーブルを作成する際に、テーブルのプライマリキーを指定する必要があります。
プライマリキーは以下 2 種類あります。
- パーティションキー
- パーティションキーとソートキーの組み合わせ
セカンダリインデックス
プライマリキー以外のインデックスを定義する場合、セカンダリインデックスを作成する必要があります。
セカンダリインデックスはパーティションキーとソートキーの組み合わせの形ですが、以下 2 種類あります。
- グローバルセカンダリインデックス
- パーティションキーとソートキーがテーブルと異なるインデックス
- ローカルセカンダリインデックス
- パーティションキーはテーブルと同じですが、ソートキーが異なるインデックス
本記事では、パーティションキーとソートキーの組み合わせを使用した場合のページネーションを紹介します。
インデックスとソートキーについて、もっと詳しく知りたい人は以下のドキュメントを参照してください。
DynamoDB テーブルクエリのページネーション
1MB の制限
DynamoDB の クエリー操作では、最大 1MB のデータしか取得できません。
クエリー結果が 1MB 以上の場合、1MB 以内の結果セットだけを返して、クエリー結果に LastEvaluatedKey
という要素が返されます。
後続のデータを取得するには、LastEvaluatedKey
を次のクエリーの ExclusiveStartKey
として使用する必要があります。
これを繰り返して、一度に 1 ページずつ結果を取り出せます。
クエリー結果に LastEvaluatedKey
がない場合、これ以上取得する項目がないことを示します。
limit の指定
ほとんどの場合、1 ページを表示するために DB から取得するデータは 1MB 以下であるため、暗黙なページネーションをそのまま利用できません。
そのため、意図的にページネーションしたい場合には、テーブルクエリーのオプレーションに Limit
のパラメータを使用して、1 ページのデータ件数を制限できます。
DynamoDB のテーブルクエリー結果のページ分割について、もっと詳細をしたい場合は以下のドキュメントを参照してください。
実現
API インターフェースの設計
DynamoDB のテーブルクエリーを使用する場合、以下の情報が必要です。
limit: number; lastEvaluatedKey: { partitionKey: string; sortKey: string; }
これを API で受け取る場合、LastEvaluatedKey
がオブジェクト構造であるため、GET リクエストの URL に直接指定できない問題があります。
素直に設計した場合
最初に考えられる設計は、LastEvaluatedKey
の属性を分解し、それを GET リクエストのクエリパラメーターに指定する方法です。
リクエスト
- limit: 必須
- partitionKey: 任意
- sortKey: 任意
GET /items?limit=<limit>&partitionKey=<partitionKey>&sortKey=<sortKey>
レスポンス
{ "items": [ ... ], "lastEvaluatedKey": { "partitionKey": "xxx", "sortKey": "yyy" } }
しかし、この設計には以下の問題があります。
- クエリーのパラメータが多くなるため、API のリクエストが複雑になります。
- DynamoDB のキー構造のキー構造は内部的な情報なので、可能な限り露出させたくありません。
これらの問題を解決するために、以下のように設計を改善しました。
改善された設計
改善された設計は、 LastEvaluatedKey
を Base64 エンコードした文字列を GET リクエストのクエリパラメーターに指定する方法です。
必要に応じて、エンコードした文字列をさらに暗号化できます。
リクエスト
- limit: 必須
- lastKey: 任意(Base64 エンコードした文字列)
GET /items?limit=<limit>&lastKey=<base64_encoded_lastEvaluatedKey>
レスポンス
{ "items": [ ... ], "lastKey": "base64_encoded_lastEvaluatedKey" }
この設計には以下のメリットがあります。
- API のインタフェースがシンプルになりました。
- クライアント側も、DynamoDB のキー構造を意識する必要がなくなります。
ただし、Base64 エンコードは JSON オブジェクトをエンコードするためのものではないため、LastEvaluatedKey をエンコードする前に、JSON 文字列に変換する必要があります。
リクエスト仕方
以下に、提案する設計に基づいたリクエストの方法を示します。
初回のリクエスト
初回のリクエストでは、lastKey
パラメーターは指定しません。
GET /items?limit=<limit>
2 回目以降のリクエスト
2 回目以降のリクエストでは、前回のレスポンスで得られた lastKey を指定します。
GET /items?limit=<limit>&lastKey=<base64_encoded_lastEvaluatedKey>
最終ページの判定
レスポンスの lastKey がない場合、最終ページと判定し、次のページを取得する必要がないです。
処理のシーケンス図
注意点
DynamoDB のテーブルクエリーの特性ですが、指定した limit 件数までデータがある場合、LastEvaluatedKey
を返します。
例えば、limit が 10 件で、テーブルに 10 件のデータがある場合、LastEvaluatedKey
を返します。
そのため、ちょうど最後の 1 件まで返した時、LastKey
があるが、次のページを取得するとデータがないという状況が発生します。
クライアント側で、この状況を判定する必要があります。
まとめ
DynamoDB でサポートされているカーソル型のページネーションは、大規模データを扱い、頻繁にデータを書き込み又は無限スクロールでデータを表示する場合など、効率よくデータを取得できます。
しかしオフセット型と違って、ページネーションのパラメーター設定と実装が少し複雑になります。
今回は LastEvaluatedKey の Base64 化と暗号化によって、クライアントからリクエストが行いやすい形に実装ができました。
DynamoDB を採用してサービスの開発する際に、ご参考になれば幸いです。