続編もやっと書きました。
概要
- やってみたこと
- DynamoDBから、ソートキー順にデータを取り出して、ページに分割(ページング)1する。
- 分割した各ページにWebの画面から飛べるようにインデックスを作りたい。
- boto3のPaginatorを使って、インデックスに必要なデータを生成してみる。
- その結果
- PaginatorはWebで使うためには向かない(これは薄々気づいていた)。
- バッチ処理とかで、「n件ずつ処理する」といった場合に使うものだと思われる。
- それでも、単純なクエリに対応するだけのコードを書いてみた。
- クエリにフィルタをかける場合、Paginatorでは対応できないので、次回の課題とする。
- ちょっと重要な気付き
- DynamoDBのクエリは、検索結果を、「ちょうど最後の1件まで返した時」にもLastEvaluatedKeyを返す。
- 環境
- python 3.6.7
- boto3 1.9.86
DynamoDBについては基本的なことは知っている前提で書きます。それでは以下本題です。
Webのページングに使うインデックスって?
これです。ページ分割された任意のページに飛ぶためのリンクをここではそう呼んでいます。**インデックスじゃなくてナビゲーションの方が適切だったかもしれません。**でも、もう書いちゃったんですよ…
そしてこれを、DynamoDBでうまく作れるか、そして簡単にできるか。試してみました。
なお、この記事の範囲はインデックスに使えるjsonを返すところまでで、フロントエンドについては省略します。
テストに使うデータ
{"itemKey":{"S":"test-record"},"itemSortKey":{"N":"0"},"itemType":{"N":"1"}}
{"itemKey":{"S":"test-record"},"itemSortKey":{"N":"1"},"itemType":{"N":"2"}}
{"itemKey":{"S":"test-record"},"itemSortKey":{"N":"2"},"itemType":{"N":"3"}}
(以下itemSortKey149まで続く)
こんな感じのデータを、DynamoDBのテーブル"test.page"に入れておきます。パーティションキー"itemKey"は"test-recored"で全件固定に、ソートキー"itemSortKey"は0-149まで、計150件格納しています。項目"itemType"は順に1-2-3を繰り返しています(各itemTypeが50ずつ)。itemTypeはこの記事の最後で使います。
boto3のPaginatorを使ってみる
pythonでコードを書いていますが、boto3を見てみたら、Paginatorなるものがありました。いかにも役に立ちそうな名前ですが、よく調べてみると、目的は違うもので、以下のように動作しているようです。
- 通常のクエリパラメータに加えて、PageSize、MaxItemsなど、ページ分割のためのパラメータを取る。
- PageSizeをLimitに設定して、クエリを投げる。
- クエリの結果を1ページとしてレスポンスを返す。
- 次のページがある場合、ページ内のレスポンスにLastEvaluatedKeyが設定される。
- 2ページ目以降、LastEvaluatedKeysをクエリのExclusiveStartKeyに設定してクエリを投げる。
- 検索結果がなくなるか、MaxItemsに達するまで繰り返す。
ページ毎にクエリを投げる(と思う)ので、Web用に使うには非効率ですね。もっと1ページ当たりのサイズが大きいもの、バッチ処理なんかに向いている気がします。
しかし、時には、「楽ならそれでよし」という場合もあるので、とりあえず使ってみます。
Paginator版インデックス生成
こんなコードを書いてみました。2
import boto3
def main(): # 呼び出し側の関数、クエリ内容を先に書きたいので上に持って来ました
dynamodb_client = boto3.client('dynamodb')
indexer = PaginatorIndexer(client = dynamodb_client)
pages = indexer.buildIndex(
TableName='page.test',
Select='SPECIFIC_ATTRIBUTES',
ProjectionExpression='itemKey,itemSortKey',
KeyConditionExpression ="itemKey = :keyVal",
ExpressionAttributeValues = {
# 1.ServiceResourceではなくてClientの機能なので、型指定して書く必要がある
':keyVal':{'S': 'test-record'},
},
PaginationConfig={
'PageSize': 10
}
)
for page in pages:
print(page)
class PaginatorIndexer: # 各ページのインデックスを生成するクラス
def __init__(self, *, client):
self.client = client
def buildIndex(self, **queryargs):
pageSize = queryargs['PaginationConfig']['PageSize']
pages = self.client.get_paginator('query').paginate(**queryargs)
indexes = []
for idx, page in enumerate(pages):
pageContent = page['Items']
size = len(pageContent)
# 2.ページサイズで検索結果の件数が割り切れる場合、空のページが来るので無視する
if size == 0:
break
# 3.ページの先頭をstartKeyとして設定する
index = {'pageNo':idx + 1, 'pageSize':pageSize}
index['startKey'] = pageContent[0]
indexes.append(index)
return indexes
if __name__ == "__main__":
main()
コードの解説
コメントに番号を入れた箇所について説明していきます。
- 使ってしばらくして気が付きましたが、PaginatorってServiceResourceじゃなくて、Clientを使ってますね。Clientの方は低レベルAPIという位置づけのようで。キーの値を設定するにもいちいち型を指定して
{'S': 'value'}
みたいに書かなきゃいけないし、出力も同じ形式で来ます。 - いきなり後述しますが、Paginatorは2つの場合に空のページを返してくる場合があります。
- 中身のあるページが返された場合、ページの先頭をキー(startKey)としてインデックスを作成します。
Paginatorが空のページを返すとき
以下の二通りのパターンがあります。
- 検索結果が1件もなかったとき。
- 検索結果の件数がページサイズで割り切れるとき。
1はともかくとして、2のケースですが、今回のテストデータは150件ですから、ページサイズ10件の場合15ページぴったりに収まります。ここでPaginatorは150件目のキーをLastEvaluatedKeyに返してきます。最後の150件目から先を検索してもレコードはないので、Paginatorはこの後再びクエリを発行しますが、空のページを返します。
これはDynamoDBの仕様だと思います。PaginatorがLimitを10件に指定して、141件目から検索する場合、150件より先にデータが存在するかは不明なので、DynamoDBとしては、その先を検索するならこのLastEvaluatedKeyを使うべし。というしかないでしょう。
インデックス出力
こんな感じのjsonを返します。キーの値に型情報が入るところが長ったらしいですが、とりあえずここではこのままにしておきます。
これをフロント側に返せば、目的のインデックスが作成できるでしょう。
{'pageNo': 1, 'pageSize': 10,
'startKey': {'itemKey': {'S': 'test-record'}, 'itemSortKey': {'N': '0'}}}
{'pageNo': 2, 'pageSize': 10,
'startKey': {'itemKey': {'S': 'test-record'}, 'itemSortKey': {'N': '10'}}}
{'pageNo': 3, 'pageSize': 10,
'startKey': {'itemKey': {'S': 'test-record'}, 'itemSortKey': {'N': '20'}}}
(以下pageNo:15まで続く)
インデックスからページの取り出し
作成されたインデックスに従って特定のページを取り出すクエリは以下のようになります。
class PageGetter: # インデックスからページの内容を取り出すクラス
# ClientじゃなくてServiceResourceを使う
def __init__(self, resource):
self.resource = resource
def get_page(self, index):
query_params = dict(
TableName='page.test',
Select='SPECIFIC_ATTRIBUTES',
ReturnConsumedCapacity='TOTAL',
ProjectionExpression='itemKey,itemSortKey,itemType',
Limit=index['pageSize'],
ExpressionAttributeValues={
':keyVal': "test-record",
}
)
# Clientが返した戻り値をServiceResourceのクエリに使うのでやや面倒
query_params['ExpressionAttributeValues'][':startKey'] = \
int(index['startKey']['itemSortKey']['N'])
query_params['KeyConditionExpression'] = \
'itemKey = :keyVal AND itemSortKey >= :startKey'
return self.resource.Table('page.test').query(
**query_params
)
検索対象のソートキーの範囲が解っているので、ExclusiveStartKeyは使わずに、KeyConditionExpressionで指定します。pageSizeはLimitに使用します。
また、Paginatorが返す{'S': 'value'}
のような形式のレスポンスを処理するのは面倒なので、ここではTableクラスのqueryメソッドを使います。ExpressionAttributeValuesの指定が少しだけ面倒です。
取得してみた例
Clientのクエリと違って型記号が付いてこないので、すっきりしています。これならアプリケーションからも扱いやすいでしょう。
しかし、逆にTable.queryが返す結果は、数値がDecimal('0')
の形式で返されて来ます(他のパターンはあるのかな?)。これは標準のjson.dumpsなどでは扱えないので、この方の記事のようにdumpsに引数defaultでDecimalを処理する関数を渡すか、simplejson等のライブラリを使います。3
{"itemKey": "test-record", "itemType": 1, "itemSortKey": 0}
{"itemKey": "test-record", "itemType": 2, "itemSortKey": 1}
{"itemKey": "test-record", "itemType": 3, "itemSortKey": 2}
{"itemKey": "test-record", "itemType": 1, "itemSortKey": 3}
{"itemKey": "test-record", "itemType": 2, "itemSortKey": 4}
{"itemKey": "test-record", "itemType": 3, "itemSortKey": 5}
{"itemKey": "test-record", "itemType": 1, "itemSortKey": 6}
{"itemKey": "test-record", "itemType": 2, "itemSortKey": 7}
{"itemKey": "test-record", "itemType": 3, "itemSortKey": 8}
{"itemKey": "test-record", "itemType": 1, "itemSortKey": 9}
フィルタが使えないだろうか
ここまでに説明したPaginatorの挙動では、フィルタは機能しそうにないですが、いちおう試してみます。
if __name__ == "__main__":
dynamodb = boto3.client('dynamodb')
indexer = PaginatorIndexer(client = dynamodb)
pages = indexer.buildIndex(
TableName='page.test',
Select='SPECIFIC_ATTRIBUTES',
ProjectionExpression='itemKey,itemSortKey,itemType',
KeyConditionExpression ="itemKey = :keyVal",
FilterExpression='itemType = :filterVal',
ExpressionAttributeValues = {
':keyVal':{'S': 'test-record'},
':filterVal':{'N' : '3'}
},
PaginationConfig={
'PageSize': 10
}
)
これをやったら、各ページの長さが3~4のページが複数返ってきました。
PaginatorがPageSizeをLimitとしてクエリを投げている以上は、各ページ内のフィルタに合致したレコードのみが返されるので、これは仕方ないかと思います。
でも、フィルタをかけたページングって、需要がないわけでもないような気もします。
続きは次回
どうやら、Paginatorは目的とはだいぶ違うものでした。
では、**ないのなら作りましょう。**次回は自力でページングの仕組みを作成します。4
2019/02/19 実際に作ってみました。