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

[続編](https://qiita.com/Akio-1978/items/d5c9a8e4c2b353396e78)もやっと書きました。
## 概要
- やってみたこと
 - DynamoDBから、ソートキー順にデータを取り出して、ページに分割（ページング）[^paging]する。
 - 分割した各ページにWebの画面から飛べるようにインデックスを作りたい。
 - boto3のPaginatorを使って、インデックスに必要なデータを生成してみる。
- その結果
 - PaginatorはWebで使うためには向かない（これは薄々気づいていた）。
    - バッチ処理とかで、「n件ずつ処理する」といった場合に使うものだと思われる。
 - それでも、単純なクエリに対応するだけのコードを書いてみた。
 - クエリにフィルタをかける場合、Paginatorでは対応できないので、次回の課題とする。
- ちょっと重要な気付き
    - DynamoDBのクエリは、検索結果を、「ちょうど最後の1件まで返した時」にもLastEvaluatedKeyを返す。
- 環境
    - python 3.6.7
    - boto3 1.9.86
    
DynamoDBについては基本的なことは知っている前提で書きます。それでは以下本題です。
## Webのページングに使うインデックスって？
![page-navigation.png](https://qiita-image-store.s3.amazonaws.com/0/158145/5a70b54c-f34f-dc36-6779-31319a642f46.png)
これです。ページ分割された任意のページに飛ぶためのリンクをここではそう呼んでいます。**インデックスじゃなくてナビゲーションの方が適切だったかもしれません。**でも、もう書いちゃったんですよ…
そしてこれを、DynamoDBでうまく作れるか、そして簡単にできるか。試してみました。
なお、**この記事の範囲はインデックスに使えるjsonを返すところまで**で、フロントエンドについては省略します。

## テストに使うデータ
```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](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#paginators)なるものがありました。いかにも役に立ちそうな名前ですが、よく調べてみると、目的は違うもので、以下のように動作しているようです。

- 通常のクエリパラメータに加えて、PageSize、MaxItemsなど、ページ分割のためのパラメータを取る。
- PageSizeをLimitに設定して、クエリを投げる。
- クエリの結果を1ページとしてレスポンスを返す。
- 次のページがある場合、ページ内のレスポンスにLastEvaluatedKeyが設定される。
- 2ページ目以降、LastEvaluatedKeysをクエリのExclusiveStartKeyに設定してクエリを投げる。
- 検索結果がなくなるか、MaxItemsに達するまで繰り返す。

ページ毎にクエリを投げる（と思う）ので、Web用に使うには非効率ですね。もっと1ページ当たりのサイズが大きいもの、バッチ処理なんかに向いている気がします。
しかし、時には、「楽ならそれでよし」という場合もあるので、とりあえず使ってみます。

### Paginator版インデックス生成
こんなコードを書いてみました。[^source]

```python: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を返します。キーの値に型情報が入るところが長ったらしいですが、とりあえずここではこのままにしておきます。
これをフロント側に返せば、目的のインデックスが作成できるでしょう。

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

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

```python: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などでは扱えないので、[この方の記事](https://qiita.com/ekzemplaro/items/5fa8900212252ab554a3)のようにdumpsに引数defaultでDecimalを処理する関数を渡すか、simplejson等のライブラリを使います。[^simplejson]

```json: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の挙動では、フィルタは機能しそうにないですが、いちおう試してみます。

```python: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は目的とはだいぶ違うものでした。
では、**ないのなら作りましょう。**次回は自力でページングの仕組みを作成します。[^rcu]

2019/02/19 [実際に作ってみました。](https://qiita.com/Akio-1978/items/d5c9a8e4c2b353396e78)

[^paging]: ページングとかページネーションとか呼ばれますが、英語的正しさはとりあえず考えません。
[^source]: ソースコード中でindexesと書いてますが、indiceと迷って、少し調べました。Webのページを辿る様子を考えたら、indexesでいいかなぁとしました。自信はないです。
[^rcu]: 扱うレコードの数にもよりますが、ページ毎にクエリを投げるPaginatorはRCUの消費も高そうなので、フィルタを使わずとも自力で作った方が効率が良い気がします。
[^simplejson]: simplejsonも楽でいいんですが、例えば、Lambdaでちょっとしたコードを書きたい時とか、ライブラリを追加せずに書けると楽ですよね。