LoginSignup
10
9

More than 3 years have passed since last update.

DynamoDBでWebのページングをやりたい#1 Paginator編

Last updated at Posted at 2019-02-06

続編もやっと書きました。

概要

  • やってみたこと
    • DynamoDBから、ソートキー順にデータを取り出して、ページに分割(ページング)1する。
    • 分割した各ページにWebの画面から飛べるようにインデックスを作りたい。
    • boto3のPaginatorを使って、インデックスに必要なデータを生成してみる。
  • その結果
    • PaginatorはWebで使うためには向かない(これは薄々気づいていた)。
    • バッチ処理とかで、「n件ずつ処理する」といった場合に使うものだと思われる。
    • それでも、単純なクエリに対応するだけのコードを書いてみた。
    • クエリにフィルタをかける場合、Paginatorでは対応できないので、次回の課題とする。
  • ちょっと重要な気付き
    • DynamoDBのクエリは、検索結果を、「ちょうど最後の1件まで返した時」にもLastEvaluatedKeyを返す。
  • 環境
    • python 3.6.7
    • boto3 1.9.86

DynamoDBについては基本的なことは知っている前提で書きます。それでは以下本題です。

Webのページングに使うインデックスって?

page-navigation.png
これです。ページ分割された任意のページに飛ぶためのリンクをここではそう呼んでいます。インデックスじゃなくてナビゲーションの方が適切だったかもしれません。でも、もう書いちゃったんですよ…
そしてこれを、DynamoDBでうまく作れるか、そして簡単にできるか。試してみました。
なお、この記事の範囲はインデックスに使えるjsonを返すところまでで、フロントエンドについては省略します。

テストに使うデータ

テーブルpage.test
{"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

PaginatorIndexer.py
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()

コードの解説

コメントに番号を入れた箇所について説明していきます。
1. 使ってしばらくして気が付きましたが、PaginatorってServiceResourceじゃなくて、Clientを使ってますね。Clientの方は低レベルAPIという位置づけのようで。キーの値を設定するにもいちいち型を指定して{'S': 'value'}みたいに書かなきゃいけないし、出力も同じ形式で来ます。
2. いきなり後述しますが、Paginatorは2つの場合に空のページを返してくる場合があります。
3. 中身のあるページが返された場合、ページの先頭をキー(startKey)としてインデックスを作成します。

Paginatorが空のページを返すとき

以下の二通りのパターンがあります。

  1. 検索結果が1件もなかったとき。
  2. 検索結果の件数がページサイズで割り切れるとき。

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まで続く)

インデックスからページの取り出し

作成されたインデックスに従って特定のページを取り出すクエリは以下のようになります。

PageGetter.py
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

1ページ目
{"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の挙動では、フィルタは機能しそうにないですが、いちおう試してみます。

PaginatorIndexer.py(抜粋)
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 実際に作ってみました。


  1. ページングとかページネーションとか呼ばれますが、英語的正しさはとりあえず考えません。 

  2. ソースコード中でindexesと書いてますが、indiceと迷って、少し調べました。Webのページを辿る様子を考えたら、indexesでいいかなぁとしました。自信はないです。 

  3. simplejsonも楽でいいんですが、例えば、Lambdaでちょっとしたコードを書きたい時とか、ライブラリを追加せずに書けると楽ですよね。 

  4. 扱うレコードの数にもよりますが、ページ毎にクエリを投げるPaginatorはRCUの消費も高そうなので、フィルタを使わずとも自力で作った方が効率が良い気がします。 

10
9
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
9