Google Datastore でページング
今回は、Googleの NoSQLデータベース Cloud Datastore でページングを実装するための方法を紹介します。
はじめに
Webアプリやモバイルアプリでページングを行うには、以下のような方法があります。
- 「最初へ」「ページ番号」「最後へ」を使用して任意のページを表示する。
- 「もっと表示」を使用して次のページのデータを追加表示する。
- 「前へ」「次へ」を使用して1ページずつ移動する。
データストアで 1 の実装はできなくはないですが、どのページを表示するか想定できないため、全てのページのクエリカーソルを保持するか、オフセットを利用して全件検索が必要です。これはとてもオーバーヘッドが大きく、現実的ではないでしょう。
2 は、データストアを使用したアプリを作成するには一番簡単な方法です。「もっと表示」するたびに次のデータが追加表示されます。下方向にスクロールして追加データを表示可能なアプリにとっては有効なページング方法です。
次のクエリカーソルをクライアントで保持すれば、次のデータを取得することは簡単です。
3 は、下にスクロールしてデータを表示できないアプリの場合に有効な方法です。
それでは、 3 についての実装方法を紹介します。
概要
「前へ」「次へ」を使用して前後のページを取得する方法を紹介します。
データストアのクエリでは、制限(limit)とクエリカーソル、並び替え順序を使用できます。
これらを使ってページングを実現します。
Note:
データストアのクエリはオフセットをサポートしていますが、データストア内部ではオフセットで指定した箇所より前のエンティティも取得されています。内部で取得されたとしても、オーバヘッドがあり、割り当てが消費されてしまうのでオフセットは使用しません。
以下のように3回の検索を行います。
-
表示対象ページを検索
・表示対象ページのエンティティを(クライアントからカーソルを指定されていればそのカーソルを使って)取得します。 -
前のページを検索
・表示対象ページの開始位置から逆順に検索し、ページが存在すればそのページの最初のカーソルを取得します。 -
次のページを検索
・表示対象ページの最後のカーソルから1件のみ検索し、データが存在するか判定します。
Note:
表示対象ページについてはエンティティを取得しますが、前のページと次のページについてはキーのみ取得します。
課金対象割り当ての消費は、表示対象ページのエンティティを取得するコストのみです。キーのみのクエリは課金されません。
ソースコード
それではいきなりJavaソースコードです。
データストアへアクセスするライブラリとして、objectifyを使用しています。
データストアを検索するのはSearchResult
クラスの search(Class<T>, Object, int, String, List<? extends Filter>, String, String)
です。
まずは、SearchResult
クラスを使う側のコードです。
Key<AnyEntity> parentkey = Key.create(AnyEntity.class, parentId);
// 検索条件
List<? extends Filter> filters = null;
// 最大取得件数
int limit = 10;
// クライアントから渡されたクエリカーソル
String cursor = "..."
// 検索
SearchResult<AnyEntity> result = new SearchResult<AnyEntity>().search(
AnyEntity.class, parentkey, limit, cursor,
filters, "-__key__", "__key__");
シンプルですね。
cursor
は、クライアントから渡された、ページを検索するための最初のデータの位置を示すカーソルです。
最初のページであればcursor
はnull
ですね。
次はSearchResult
クラスです。
import static com.googlecode.objectify.ObjectifyService.ofy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import com.google.appengine.api.datastore.Cursor;
import com.google.appengine.api.datastore.Query.Filter;
import com.google.appengine.api.datastore.QueryResultIterator;
import com.googlecode.objectify.Key;
import com.googlecode.objectify.cmd.Query;
import com.googlecode.objectify.cmd.QueryKeys;
public class SearchResult<T> {
public List<T> data;
public Integer size;
// 次のページがある場合は次のページから読み込むためのカーソル
public String nextCursor;
// 前のページがある場合は次のページから読み込むためのカーソル
public String prevCursor;
// 現在のページのカーソル
public String cursor;
public Integer limit;
// 次のページがあるかどうか
public Boolean hasNext;
// 前のページがあるかどうか
public Boolean hasPrev;
public SearchResult<T> search(Class<T> entityClass, int limit,
String cursor, Filter filter, String order, String reverseOrder) {
return search(entityClass, null, limit, cursor, Arrays.asList(filter),
order, reverseOrder);
}
private Query<T> getQuery(Class<T> entityClass, Object parent,
List<? extends Filter> filters) {
Query<T> query = ofy().load().type(entityClass);
if (parent != null) {
query = query.ancestor(parent);
}
if (filters != null) {
for (Filter filter : filters) {
query = query.filter(filter);
}
}
return query;
}
/**
*
* @param entityClass
* @param parent
* @param limit
* @param cursor 取得するページの開始カーソル
* @param filters
* @param order ソート条件
* @param reverseOrder 前ページを検索するための、 order とは逆順のソート条件
* @return
*/
public SearchResult<T> search(Class<T> entityClass, Object parent,
int limit, String cursor, List<? extends Filter> filters,
String order, String reverseOrder) {
// 現在のページを検索
Query<T> query = getQuery(entityClass, parent, filters).order(order);
query = query.limit(limit);
if (StringUtils.isNotBlank(cursor)) {
query = query.startAt(Cursor.fromWebSafeString(cursor));
}
QueryResultIterator<T> iterator = query.iterator();
List<T> stores = new ArrayList<T>();
while (iterator.hasNext()) {
T store = iterator.next();
stores.add(store);
}
Cursor cursorobj = iterator.getCursor();
this.nextCursor = cursorobj == null ? null : cursorobj
.toWebSafeString();
this.limit = limit;
this.data = stores;
this.size = this.data.size();
// 前のページがあるかどうか
if (StringUtils.isNotBlank(cursor)) {
QueryKeys<T> prevQuery = getQuery(entityClass, parent, filters)
.order(reverseOrder).limit(limit)
.startAt(Cursor.fromWebSafeString(cursor)).keys();
QueryResultIterator<Key<T>> prevIterator = prevQuery.iterator();
if (prevIterator.hasNext()) {
prevIterator.next();
while (prevIterator.hasNext()) {
prevIterator.next();
}
this.prevCursor = prevIterator.getCursor() == null ? null
: prevIterator.getCursor().toWebSafeString();
this.hasPrev = Boolean.TRUE;
} else {
this.hasPrev = Boolean.FALSE;
}
} else {
this.hasPrev = Boolean.FALSE;
}
// 次のページがあるかどうか
if (this.nextCursor != null
&& getQuery(entityClass, parent, filters).order(order)
.startAt(Cursor.fromWebSafeString(this.nextCursor))
.limit(1).keys().iterator().hasNext()) {
this.hasNext = Boolean.TRUE;
} else {
this.hasNext = Boolean.FALSE;
}
return this;
}
}
ここでは3回の検索を行っています。
- クライアントから渡されたカーソルを使って表示対象のページを検索する。
- クライアントから渡されたカーソルを使って、1 とは逆順のソート条件で前のページが存在するかチェックする。
- 1で検索した結果で取得できたカーソルを使って、さらに次のページが存在するかチェックする。
これにより以下の情報を導き出せます。
-
hasNext
: 次のページがあるかどうか -
nextCursor
: 次のページの開始位置のカーソル -
hasPrev
: 前のページがあるかどうか -
prevCursor
: 前のページの開始位置のカーソル
これらの情報があれば、クライアント側で「前のページ」「次のページ」の表示可否と、カーソルを使ってそれぞれのページをフェッチすることは容易です。
まとめ
表示対象ページを取得する課金対象割り当ての消費と同じコストで、前のページ有無とそれをフェッチするカーソル、次のページの有無とそれをフェッチするカーソルを取得する方法を紹介しました。
リレーショナル・データベースでは簡単に実現できるページングですが、NoSqlのデータストアでもそれに似た体験をユーザに提供することができます。
さらに、データストアではデータ量に依存せず、常に一定の速度でクエリ結果を返すことができるので、大量データを扱うアプリでも安心して利用することができます。