CLOVER🍀

That was when it all began.

Amazon DynamoDBのローカル版とDocumentClientで、セカンダリインデックスを試す

これは、なにをしたくて書いたもの?

最近、Amazon DynamoDBで遊んでいましたが、今回で一区切りにしようかなと思います。
もしかしたら、DynamoDB Streamsあたりは試したくなるかもしれませんが。

今回は、セカンダリインデックスをテーマにします。

セカンダリインデックスを使用したデータアクセス性の向上 - Amazon DynamoDB

Amazon DynamoDBのセカンダリインデックス

Amazon DynamoDBを使ったデータのアクセスの基本は、プライマリーキーを使ったアクセスです。

とはいえ、それだけではデータへ効率的にアクセスするパスが限られます。セカンダリインデックスは、プライマリーキー以外の方法で
データにアクセスする方法を提供します。

セカンダリインデックスを使用したデータアクセス性の向上 - Amazon DynamoDB

セカンダリインデックスは、ひとつのテーブルに関連付けられます。この時、テーブルのことをインデックスの「ベーステーブル」と
呼ぶみたいです。

セカンダリインデックスを作成する時には、セカンダリキー(代替キー)の定義と、ベーステーブルからコピーする属性を定義します。
この時、テーブルからインデックスに対してデータがコピーされます。

そして、データを取得する際には「テーブルに対してクエリーやスキャンを実行する時と同じように」インデックスに対してクエリーや
スキャンを実行することになるようです。
セカンダリインデックスは、Amazon DynamoDBによって自動的にメンテナンスされ、ベーステーブルのアイテムを追加、変更、削除すると
インデックスも更新され、変更が反映されます。

こうやって書くと、セカンダリインデックスというのは、少し定義を変えたベーステーブルのコピーなんだろうなという気がしてきます。

セカンダリインデックスには、以下の2種類があります。

  • グローバルセカンダリインデックス
    • パーティションキーおよびソートキーを持つが、ベーステーブルと異なるものをセカンダリキーとして定義可能
    • セカンダリキーの性質から、このインデックスを使うクエリーは全パーティション、全データを対象とする可能性があるため「グローバル」となる
    • インデックスのデータは、ベーステーブルとは別の独自のパーティションに保存され、スケーリングもベーステーブルとは別となる
  • ローカルセカンダリインデックス
    • パーティションキーはベーステーブルと同じで、ソートキーが異なるインデックス
    • つまり、パーティションの範囲がベーステーブルと同じとなり、アクセス可能な範囲も同等になるため「ローカル」となる

他にもそれなりに違いがありますね。ドキュメントに記載されている内容を、簡単にまとめます。

特徴 グローバルセカンダリインデックス ローカルセカンダリインデックス
プライマリーキー定義 パーティションキーのみ、または複合キー(パーティションキー+ソートキー) 複合キー(パーティションキー+ソートキー)
プライマリーキーの属性 パーティションキー、ソートキーのいずれも文字列、数値、バイナリ型の任意のベーステーブルの属性と同じ定義が可能 パーティションキーはベーステーブルと同じ、ソートキーは文字列、数値、バイナリ型の任意のベーステーブルの属性と同じ定義が可能
パーティションキーのサイズ制限 なし パーティションキーの値ごとに、インデックスに指定されたアイテムの合計サイズが10GB以下であること
インデックスに対するオンライン操作 ベーステーブルの作成と同時にインデックスを作成でき、後からインデックスの追加、削除を行うことも可能 ベーステーブルの作成と同時にインデックスを作成できるが、後からインデックスを追加したり削除することはできない
クエリーとパーティション 全パーティションでテーブル全体に対してクエリーを実行可能 パーティションキーで指定されたひとつのパーティションに対して実行可能
読み込み整合性 結果整合性 結果整合性、強い整合性から選択可能
プロビジョニングされたスループットの消費 読み込み/書き込みともに、インデックスに独自のプロビジョニングされたスループットがあり、クエリーやスキャン、ベーステーブル書き込み時のインデックスの更新でキャパシティユニットを消費する 読み込み/書き込みともに、ベーステーブルのキャパシティユニットを使用し、クエリーやスキャン、ベーステーブル書き込み時のインデックスの更新でベーステーブルのキャパシティユニットを消費する
取得できる属性 クエリーまたはスキャンでは、インデックスにコピーされた属性のみがリクエスト可能 クエリーまたはスキャンでは、インデックスにコピーされていない属性もリクエスト可能で、足りない属性はベーステーブルから自動的に取得する

こう見ていくと、ローカルセカンダリインデックスはベーステーブルに対して別のソートキーでクエリーを実行可能にするためのもの、
と言いたくなる気がします。
グローバルトランザクションは、ベーステーブルのテーブルが異なるレプリカといったところでしょうか。

その他。

  • セカンダリインデックスを持つテーブルを複数作成する場合、テーブルを順次作成する必要がある
    • 作成したテーブルがACTIVEになる前にセカンダリテーブルを持つテーブルを作成しようとすると、LimitExceededExceptionがスローされる
  • インデックス作成時の指定事項
    • インデックスの種類(グローバルセカンダリインデックス or ローカルセカンダリインデックス)
    • インデックスの名前
    • インデックスのキー定義
    • インデックスにコピーする属性
    • グローバルセカンダリインデックスの場合、読み込み/書き込みのキャパシティユニット

その他に詳しい情報も続くのですが。

DynamoDB のグローバルセカンダリインデックスの使用 - Amazon DynamoDB

グローバルセカンダリインデックスの管理 - Amazon DynamoDB

インデックスキー違反の検出と修正 - Amazon DynamoDB

ローカルセカンダリインデックス - Amazon DynamoDB

今回は説明を見るのはこれくらいにして、とりあえず動かしてみようかなと思います。

確認には、Amazon DynamoDBのローカル版を使います。

環境

今回の環境は、こちら。

$ aws --version
aws-cli/2.4.23 Python/3.8.8 Linux/5.4.0-100-generic exe/x86_64.ubuntu.20 prompt/off


$ java --version
openjdk 17.0.1 2021-10-19
OpenJDK Runtime Environment (build 17.0.1+12-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.1+12-Ubuntu-120.04, mixed mode, sharing)

ローカル版のAmazon DynamoDBの情報。

$ grep -A 2 'Release Notes' README.txt
Release Notes
-----------------------------
2022-1-10 (1.18.0)

インメモリーで起動させておきます。

$ java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -inMemory

ローカル版のAmazon DynamoDBに、AWS CLIでアクセスするためのクレデンシャル。

$ export AWS_ACCESS_KEY_ID=fakeMyKeyId
$ export AWS_SECRET_ACCESS_KEY=fakeSecretAccessKey
$ export AWS_DEFAULT_REGION=ap-northeast-1

確認は、AWS SDK for JavaScriptを使って、Node.jsで行います。

$ node --version
v16.14.0


$ npm --version
8.3.1

テーブル定義

今回使うテーブルですが、2つ作成することにしました。

テーブル定義のイメージは以下としますが、この時点では作成しません。セカンダリインデックス、特にローカルセカンダリインデックスは
テーブル作成時に定義するので。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name People \
    --attribute-definitions \
        AttributeName=familyId,AttributeType=N \
        AttributeName=firstName,AttributeType=S \
    --key-schema \
        AttributeName=familyId,KeyType=HASH \
        AttributeName=firstName,KeyType=RANGE \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD


$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name Books \
    --attribute-definitions \
        AttributeName=isbn,AttributeType=S \
    --key-schema \
        AttributeName=isbn,KeyType=HASH \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD

片方は、パーティションキーのみのテーブルです。

データは、こんな感じで用意。

test/people.json

[
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "サザエ",
    "age": 24
  },
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "マスオ",
    "age": 28
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "波平",
    "age": 54
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "フネ",
    "age": 50
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "カツオ",
    "age": 11
  },
  {
    "familyId": 1,
    "lastName": "磯野",
    "firstName": "ワカメ",
    "age": 9
  },
  {
    "familyId": 1,
    "lastName": "フグ田",
    "firstName": "タラオ",
    "age": 3
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "ノリスケ",
    "age": 26
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "タイコ",
    "age": 22
  },
  {
    "familyId": 2,
    "lastName": "波野",
    "firstName": "イクラ",
    "age": 1
  }
]

test/books.json

[
  {
    "isbn": "978-4815607654",
    "title": "AWSコンテナ設計・構築[本格]入門",
    "price": 3300,
    "publicationDate": "2021-10-21"
  },
  {
    "isbn": "978-4295006657",
    "title": "Amazon Web Servicesインフラサービス活用大全 システム構築/自動化、データストア、高信頼化 (impress top gear)",
    "price": 5060,
    "publicationDate": "2019-09-05"
  },
  {
    "isbn": "978-4797392579",
    "title": "Amazon Web Services パターン別構築・運用ガイド 改訂第2版",
    "price": 3740,
    "publicationDate": "2018-03-23"
  },
  {
    "isbn": "978-4797392562",
    "title": "Amazon Web Services 業務システム設計・移行ガイド",
    "price": 3520,
    "publicationDate": "2018-01-20"
  },
  {
    "isbn": "978-4774176734",
    "title": "Amazon Web Services実践入門 (WEB+DB PRESS plus)",
    "price": 2838,
    "publicationDate": "2015-11-10"
  },
  {
    "isbn": "978-4822277376",
    "title": "Amazon Web Services クラウドデザインパターン設計ガイド 改訂版",
    "price": 2970,
    "publicationDate": "2015-05-28"
  },
  {
    "isbn": "978-4822277369",
    "title": "Amazon Web Services クラウドデザインパターン実装ガイド 改訂版",
    "price": 4180,
    "publicationDate": "2015-03-09"
  }
]

セカンダリインデックスを作成する

では、セカンダリインデックスを作成してみましょう。

こちらを見ながら作成していきます。

グローバルセカンダリインデックスの操作: AWS CLI - Amazon DynamoDB

ローカルセカンダリインデックスの操作: AWS CLI - Amazon DynamoDB

使用するテーブルとプライマリーキーの定義は以下ですが、

最終的には以下になるように構成します。

Bookテーブルは、パーティションキーのみのテーブルなので、セカンダリインデックスは作成できません。それも確認していきます。

グローバルセカンダリインデックスを作成する

まずは、グローバルインデックスの作成から。

グローバルセカンダリインデックスの操作: AWS CLI - Amazon DynamoDB

パーティションキー(lastName)とソートキー(age)の複合プライマリーキーを持つ、グローバルセカンダリインデックスを作成して
みます。

--global-secondary-indexesで、グローバルセカンダリインデックスの定義を指定するようです。定義内容はJSONで。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name People \
    --attribute-definitions \
        AttributeName=familyId,AttributeType=N \
        AttributeName=firstName,AttributeType=S \
        AttributeName=lastName,AttributeType=S \
        AttributeName=age,AttributeType=N \
    --key-schema \
        AttributeName=familyId,KeyType=HASH \
        AttributeName=firstName,KeyType=RANGE \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD \
    --global-secondary-indexes \
        '
        [
            {
                "IndexName": "PeopleGlobalCompositeIndex",
                "KeySchema": [{"AttributeName":"lastName","KeyType":"HASH"},
                                {"AttributeName":"age","KeyType":"RANGE"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["firstName"]
                },
                "ProvisionedThroughput": {
                    "ReadCapacityUnits": 10,
                    "WriteCapacityUnits": 5
                }
            }
        ]
        '

最初に挙げた時からテーブルの属性定義が増えていますが、セカンダリインデックスでキーとして指定する属性は定義しなくてはならない
みたいです。

    --attribute-definitions \
        AttributeName=familyId,AttributeType=N \
        AttributeName=firstName,AttributeType=S \
        AttributeName=lastName,AttributeType=S \
        AttributeName=age,AttributeType=N \

今回作成したグローバルセカンダリインデックスの内容はこちら。インデックスの名前はPeopleGlobalCompositeIndexとしました。

        [
            {
                "IndexName": "PeopleGlobalCompositeIndex",
                "KeySchema": [{"AttributeName":"lastName","KeyType":"HASH"},
                                {"AttributeName":"age","KeyType":"RANGE"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["firstName"]
                },
                "ProvisionedThroughput": {
                    "ReadCapacityUnits": 10,
                    "WriteCapacityUnits": 5
                }
            }
        ]

なんとなく各項目の意味はわからなくもないですが、詳しくはこちらへ。

DynamoDB のグローバルセカンダリインデックスの使用 - Amazon DynamoDB

グローバルセカンダリインデックスの管理 - Amazon DynamoDB

CreateTable - Amazon DynamoDB

次は、このテーブルにパーティションキー(firstName)のみをプライマリーキーとするグローバルセカンダリインデックスを追加してみます。

コマンドは以下です。インデックスの名前はPeopleGlobalSimpleIndexとします。

$ aws dynamodb update-table \
    --endpoint-url http://localhost:8000 \
    --table-name People \
    --attribute-definitions \
        AttributeName=firstName,AttributeType=S \
    --global-secondary-index-updates \
        '
        [
            {
                "Create": {
                    "IndexName": "PeopleGlobalSimpleIndex",
                    "KeySchema": [{"AttributeName":"firstName","KeyType":"HASH"}],
                    "Projection":{
                        "ProjectionType":"INCLUDE",
                        "NonKeyAttributes":["lastName"]
                    },
                    "ProvisionedThroughput": {
                        "ReadCapacityUnits": 10,
                        "WriteCapacityUnits": 5
                    }
                }
            }
        ]
        '

--global-secondary-index-updatesオプションを使い、グローバルセカンダリインデックスの追加、更新、削除に合わせてCreate、Update、
Deleteのいずれかを指定する必要があります。

UpdateTable - Amazon DynamoDB

この時も、インデックスのキーになる属性の定義は明示(--attribute-definitions)が必要なようです。

最後に、もとのテーブルがパーティションキー(isbn)のみのテーブルに、パーティションキー(title)のみをプライマリーキーとする
グローバルセカンダリインデックスをつけてみます。インデックスの名前はBookGlobalSimpleIndexとします。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name Books \
    --attribute-definitions \
        AttributeName=isbn,AttributeType=S \
        AttributeName=title,AttributeType=S \
    --key-schema \
        AttributeName=isbn,KeyType=HASH \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD \
    --global-secondary-indexes \
        '
        [
            {
                "IndexName": "BookGlobalSimpleIndex",
                "KeySchema": [{"AttributeName":"title","KeyType":"HASH"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["price"]
                },
                "ProvisionedThroughput": {
                    "ReadCapacityUnits": 10,
                    "WriteCapacityUnits": 5
                }
            }
        ]
        '

これで、グローバルセカンダリインデックスを作成するコマンドと、グローバルセカンダリインデックスはベーステーブルのプライマリーキー定義に
関係ないキーを定義できることが確認できました。

次は、ローカルセカンダリインデックスを作成してみますが、こちらはテーブル作成時に作ることになるため、1度テーブルを削除して
おきます。

$ aws dynamodb delete-table \
    --endpoint-url http://localhost:8000 \
    --table-name People

$ aws dynamodb delete-table \
    --endpoint-url http://localhost:8000 \
    --table-name Books
ローカルセカンダリインデックスを作成する

次は、ローカルセカンダリインデックスを作成します。

ローカルセカンダリインデックスの操作: AWS CLI - Amazon DynamoDB

Peopleテーブルに対して、パーティションキー(familyId)とソートキー(age)の複合プライマリーキーとする
ローカルセカンダリインデックスを作成してみます。インデックスの名前はPeopleLocalCompositeIndexとします。

aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name People \
    --attribute-definitions \
        AttributeName=familyId,AttributeType=N \
        AttributeName=firstName,AttributeType=S \
        AttributeName=age,AttributeType=N \
    --key-schema \
        AttributeName=familyId,KeyType=HASH \
        AttributeName=firstName,KeyType=RANGE \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD \
    --local-secondary-indexes \
        '
        [
            {
                "IndexName": "PeopleLocalCompositeIndex",
                "KeySchema": [{"AttributeName":"familyId","KeyType":"HASH"},
                                {"AttributeName":"age","KeyType":"RANGE"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["firstName"]
                }
            }
        ]
        '

ローカルセカンダリインデックスの場合、グローバルセカンダリインデックスのようにProvisionedThroughputの指定は要りません。
むしろ、書いているとエラーになります。

各項目の意味は雰囲気わかりそうですが、詳しくはこちらへ。

ローカルセカンダリインデックス - Amazon DynamoDB

CreateTable - Amazon DynamoDB

ここで1度テーブルを削除して

$ aws dynamodb delete-table \
    --endpoint-url http://localhost:8000 \
    --table-name People

パーティションキーがベーステーブルと異なるローカルセカンダリインデックスの作成を試みてみます。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name People \
    --attribute-definitions \
        AttributeName=familyId,AttributeType=N \
        AttributeName=firstName,AttributeType=S \
        AttributeName=age,AttributeType=N \
    --key-schema \
        AttributeName=familyId,KeyType=HASH \
        AttributeName=firstName,KeyType=RANGE \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD \
    --local-secondary-indexes \
        '
        [
            {
                "IndexName": "PeopleLocalCompositeIndex",
                "KeySchema": [{"AttributeName":"firstName","KeyType":"HASH"},
                                {"AttributeName":"age","KeyType":"RANGE"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["lastName"]
                }
            }
        ]
        '

これは、やはりエラーになります。

An error occurred (ValidationException) when calling the CreateTable operation: Local Secondary indices must have the same hash key as the main table

というわけで、ローカルセカンダリインデックスはベーステーブルと同じパーティションキーを持つ必要があることが確認できました。

ところで、パーティションキーのみのテーブルに対して、まったく同じキー定義のローカルセカンダリインデックスを作ろうとすると
どうなるのでしょう。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name Books \
    --attribute-definitions \
        AttributeName=isbn,AttributeType=S \
        AttributeName=title,AttributeType=S \
    --key-schema \
        AttributeName=isbn,KeyType=HASH \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD \
    --local-secondary-indexes \
        '
        [
            {
                "IndexName": "BookLocalSimpleIndex",
                "KeySchema": [{"AttributeName":"isbn","KeyType":"HASH"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["title","price"]
                }
            }
        ]
        '

これは、パーティションキーの指定のみでローカルセカンダリインデックスは作成できないというエラーになります。

An error occurred (ValidationException) when calling the CreateTable operation: Local Secondary indices are not allowed on hash tables, only hash and range tables

というわけで、ローカルセカンダリインデックスは複合プライマリーキーで定義する必要があることを確認できました。

まとめて

最後に、ここまで定義してきたグローバルセカンダリインデックス、ローカルセカンダリインデックスを含めたテーブル定義にしておきます。

Peopleテーブル。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name People \
    --attribute-definitions \
        AttributeName=familyId,AttributeType=N \
        AttributeName=firstName,AttributeType=S \
        AttributeName=lastName,AttributeType=S \
        AttributeName=age,AttributeType=N \
    --key-schema \
        AttributeName=familyId,KeyType=HASH \
        AttributeName=firstName,KeyType=RANGE \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD \
    --global-secondary-indexes \
        '
        [
            {
                "IndexName": "PeopleGlobalCompositeIndex",
                "KeySchema": [{"AttributeName":"lastName","KeyType":"HASH"},
                                {"AttributeName":"age","KeyType":"RANGE"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["firstName"]
                },
                "ProvisionedThroughput": {
                    "ReadCapacityUnits": 10,
                    "WriteCapacityUnits": 5
                }
            },
            {
                "IndexName": "PeopleGlobalSimpleIndex",
                "KeySchema": [{"AttributeName":"firstName","KeyType":"HASH"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["lastName"]
                },
                "ProvisionedThroughput": {
                    "ReadCapacityUnits": 10,
                    "WriteCapacityUnits": 5
                }
            }
        ]
        ' \
    --local-secondary-indexes \
        '
        [
            {
                "IndexName": "PeopleLocalCompositeIndex",
                "KeySchema": [{"AttributeName":"familyId","KeyType":"HASH"},
                                {"AttributeName":"age","KeyType":"RANGE"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["firstName"]
                }
            }
        ]
        '

Bookテーブル。

$ aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name Books \
    --attribute-definitions \
        AttributeName=isbn,AttributeType=S \
        AttributeName=title,AttributeType=S \
    --key-schema \
        AttributeName=isbn,KeyType=HASH \
    --provisioned-throughput \
        ReadCapacityUnits=10,WriteCapacityUnits=5 \
    --table-class STANDARD \
    --global-secondary-indexes \
        '
        [
            {
                "IndexName": "BookGlobalSimpleIndex",
                "KeySchema": [{"AttributeName":"title","KeyType":"HASH"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["price"]
                },
                "ProvisionedThroughput": {
                    "ReadCapacityUnits": 10,
                    "WriteCapacityUnits": 5
                }
            }
        ]
        '

こちらを今回の定義にします。これらのテーブル、セカンダリインデックスを使うアプリケーションを書いていきましょう。

アプリケーションの準備

Node.jsプロジェクトを作成します。確認は、テストコードで行うことにします。

$ npm init -y
$ npm i -D typescript
$ npm i -D -E prettier
$ npm i -D jest @types/jest
$ npm i -D esbuild esbuild-jest
$ mkdir src test

Node.jsの型宣言とAWS SDK for JavaScript v2をインストール。

$ npm i -D @types/node@v16
$ npmi aws-sdk

依存関係は、このようになりました。

  "devDependencies": {
    "@types/jest": "^27.4.1",
    "@types/node": "^16.11.26",
    "esbuild": "^0.14.25",
    "esbuild-jest": "^0.5.0",
    "jest": "^27.5.1",
    "prettier": "2.5.1",
    "typescript": "^4.6.2"
  },
  "dependencies": {
    "aws-sdk": "^2.1087.0"
  }

設定はこちら。

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "lib": ["esnext"],
    "baseUrl": "./src",
    "outDir": "dist",
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noPropertyAccessFromIndexSignature": true,
    "esModuleInterop": true
  },
  "include": [
    "src"
  ]
}

tsconfig.typecheck.json

{
  "extends": "./tsconfig",
  "compilerOptions": {
    "baseUrl": "./",
    "noEmit": true
  },
  "include": [
    "src", "test"
  ]
}

.prettierrc.json

{
  "singleQuote": true
}

jest.config.js

module.exports = {
  testEnvironment: 'node',
  transform: {
    "^.+\\.tsx?$": "esbuild-jest"
  }
};

今回は、ドキュメントインターフェースを使っていくことにします。

ドキュメントインターフェイス - Amazon DynamoDB

APIはこちら。

Class: AWS.DynamoDB.DocumentClient — AWS SDK for JavaScript

セカンダリインデックスを使ってみる

では、セカンダリインデックスを使うプログラムを作成します。

データにマッピングするクラスを作成。

src/person.ts

export class Person {
  familyId: number;
  lastName: string;
  firstName: string;
  age: number;

  constructor(
    familyId: number,
    lastName: string,
    firstName: string,
    age: number
  ) {
    this.familyId = familyId;
    this.lastName = lastName;
    this.firstName = firstName;
    this.age = age;
  }
}

src/book.ts

export class Book {
  isbn: string;
  title: string;
  price: number;
  publicationDate: string;

  constructor(
    isbn: string,
    title: string,
    price: number,
    publicationDate: string
  ) {
    this.isbn = isbn;
    this.title = title;
    this.price = price;
    this.publicationDate = publicationDate;
  }
}

テストコードのimport部分と、DocumentClientの作成。

test/secondaryindex.test.ts

import { DocumentClient } from 'aws-sdk/clients/dynamodb';
import fs from 'fs';
import { Book } from '../src/book';
import { Person } from '../src/person';

const dynamodb = new DocumentClient({
  credentials: {
    accessKeyId: 'fakeMyKeyId',
    secretAccessKey: 'fakeSecretAccessKey',
  },
  region: 'ap-northeast-1',
  endpoint: 'http://localhost:8000',
});

// ここに、テストを書く!

テストデータのロードと、削除を最後に入れておきます。

test('load data', async () => {
  const people = JSON.parse(
    await fs.promises.readFile(`${__dirname}/people.json`, 'utf8')
  ) as Person[];

  const books = JSON.parse(
    await fs.promises.readFile(`${__dirname}/books.json`, 'utf8')
  ) as Book[];

  const params: DocumentClient.BatchWriteItemInput = {
    RequestItems: {
      People: people.map((p) => ({ PutRequest: { Item: p } })),
      Books: books.map((b) => ({ PutRequest: { Item: b } })),
    },
  };

  await dynamodb.batchWrite(params).promise();
});


// ここに、テストを書く!


test('delete data', async () => {
  const people = JSON.parse(
    await fs.promises.readFile(`${__dirname}/people.json`, 'utf8')
  ) as Person[];

  const books = JSON.parse(
    await fs.promises.readFile(`${__dirname}/books.json`, 'utf8')
  ) as Book[];

  const params: DocumentClient.BatchWriteItemInput = {
    RequestItems: {
      People: people.map((p) => ({
        DeleteRequest: {
          Key: { familyId: p.familyId, firstName: p.firstName },
        },
      })),
      Books: books.map((b) => ({ DeleteRequest: { Key: { isbn: b.isbn } } })),
    },
  };

  await dynamodb.batchWrite(params).promise();
});

セカンダリインデックスを使うには、クエリーやスキャンを使ってアクセスすることになります。というわけで、過去に書いたエントリーも
見返しながら書いていきます。

Amazon DynamoDBローカル版とDocumentClientで、クエリーを試す - CLOVER🍀

Amazon DynamoDBローカル版とDocumentClientで、スキャンを試す - CLOVER🍀

クエリーでセカンダリインデックスを使う

最初はクエリーから試していきましょう。
グローバルセカンダリインデックスからいきます。

Peopleテーブルに作成したグローバルセカンダリインデックスを使う例。こちらは複合プライマリーキーです。

test("query for people's global secondary index (composite keys)", async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    IndexName: 'PeopleGlobalCompositeIndex', // インデックス名を指定
    KeyConditionExpression: 'lastName = :lastName and age <= :age',
    ExpressionAttributeValues: {
      ':lastName': '磯野',
      ':age': 11,
    },
    ScanIndexForward: false,
    ConsistentRead: false, // グローバルセカンダリインデックスでは true にできない(デフォルト false)
  };

  const result = await dynamodb.query(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(2);

    const katsuo = result.Items[0] as Person;
    expect(katsuo.familyId).toBe(1);
    expect(katsuo.lastName).toBe('磯野');
    expect(katsuo.firstName).toBe('カツオ');
    expect(katsuo.age).toBe(11);

    const wakame = result.Items[1] as Person;
    expect(wakame.familyId).toBe(1);
    expect(wakame.lastName).toBe('磯野');
    expect(wakame.firstName).toBe('ワカメ');
    expect(wakame.age).toBe(9);
  } else {
    throw new Error('test failed');
  }
});

ポイントは、インデックス名をIndexNameとして指定することですね。合わせてテーブル名も必要になります。

  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    IndexName: 'PeopleGlobalCompositeIndex', // インデックス名を指定
    KeyConditionExpression: 'lastName = :lastName and age <= :age',
    ExpressionAttributeValues: {
      ':lastName': '磯野',
      ':age': 11,
    },
    ScanIndexForward: false,
    ConsistentRead: false, // グローバルセカンダリインデックスでは true にできない(デフォルト false)
  };

また、グローバルセカンダリインデックスの場合はConsistentReadをtrueにするとエラーになります。
これは、グローバルセカンダリインデックスの特徴として挙げられていた話ですね。

次は、パーティションキーのみのプライマリーキーの場合。

test("query for people's global secondary index (simple key)", async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    IndexName: 'PeopleGlobalSimpleIndex', // インデックス名を指定
    KeyConditionExpression: 'firstName = :firstName',
    ExpressionAttributeValues: {
      ':firstName': 'ノリスケ',
    },
    ScanIndexForward: false,
    // ConsistentRead: false, // グローバルセカンダリインデックスでは true にできない(デフォルト false)
  };

  const result = await dynamodb.query(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(1);

    const norisuke = result.Items[0] as Person;
    expect(norisuke.familyId).toBe(2);
    expect(norisuke.lastName).toBe('波野');
    expect(norisuke.firstName).toBe('ノリスケ');
    expect(norisuke.age).toBeUndefined();
  } else {
    throw new Error('test failed');
  }
});

よく見ると、undefinedの項目が混じっています。

    const norisuke = result.Items[0] as Person;
    expect(norisuke.familyId).toBe(2);
    expect(norisuke.lastName).toBe('波野');
    expect(norisuke.firstName).toBe('ノリスケ');
    expect(norisuke.age).toBeUndefined();

これは、インデックス定義の時にコピーする属性に指定していなかったからです。

            {
                "IndexName": "PeopleGlobalSimpleIndex",
                "KeySchema": [{"AttributeName":"firstName","KeyType":"HASH"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["lastName"]
                },
                "ProvisionedThroughput": {
                    "ReadCapacityUnits": 10,
                    "WriteCapacityUnits": 5
                }
            }

ここでProtectionに指定している属性はlastNameのみですが、プライマリーキーの属性は常にコピーされます。明示的に指定しなかった
ageのみが今回コピーされていないわけです。

もっとも、コピーする対象の属性を増やしたり、テーブルのすべての属性をコピーするようにすることもできるので、それはやりたいことと
設定次第です。

Bookテーブルのグローバルセカンダリインデックスにアクセスする例。

test("query for books's global secondary index(composite keys)", async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'Books',
    IndexName: 'BookGlobalSimpleIndex',
    KeyConditionExpression: 'title = :title',
    ExpressionAttributeValues: {
      ':title': 'AWSコンテナ設計・構築[本格]入門',
    },
  };

  const result = await dynamodb.query(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(1);

    const containerBook = result.Items[0] as Book;
    expect(containerBook.isbn).toBe('978-4815607654');
    expect(containerBook.title).toBe('AWSコンテナ設計・構築[本格]入門');
    expect(containerBook.price).toBe(3300);
    expect(containerBook.publicationDate).toBeUndefined();
  } else {
    throw new Error('test failed');
  }
});

ここまでの内容と、特に変わったことはありません。ただ、グローバルセカンダリインデックスの場合は、ベーステーブルとまったく異なる
プライマリーキー構成を取り、テーブルのようにクエリーを実行できることが確認できましたね。

続いて、ローカルセカンダリインデックス。これはPeopleテーブルのみです。

test("query for people's local secondary index (composite keys)", async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    IndexName: 'PeopleLocalCompositeIndex',
    KeyConditionExpression: 'familyId = :familyId and age < :age',
    ExpressionAttributeValues: {
      ':familyId': 2,
      ':age': 10,
    },
    ProjectionExpression: 'familyId,lastName,firstName,age',
  };

  const result = await dynamodb.query(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(1);

    const tarao = result.Items[0] as Person;
    expect(tarao.familyId).toBe(2);
    expect(tarao.lastName).toBe('波野');
    expect(tarao.firstName).toBe('イクラ');
    expect(tarao.age).toBe(1);
  } else {
    throw new Error('test failed');
  }
});

今回のローカルセカンダリインデックスの場合、定義がこうなのでふつうにやるとlastNameがundefinedになります。

        [
            {
                "IndexName": "PeopleLocalCompositeIndex",
                "KeySchema": [{"AttributeName":"familyId","KeyType":"HASH"},
                                {"AttributeName":"age","KeyType":"RANGE"}],
                "Projection":{
                    "ProjectionType":"INCLUDE",
                    "NonKeyAttributes":["firstName"]
                }
            }
        ]

ですが、ローカルセカンダリインデックスの場合はProjectionExpressionに属性を指定することで、コピーしていない属性も参照することが
できます。これを「フェッチ」と呼ぶそうです。

  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    IndexName: 'PeopleLocalCompositeIndex',
    KeyConditionExpression: 'familyId = :familyId and age < :age',
    ExpressionAttributeValues: {
      ':familyId': 2,
      ':age': 10,
    },
    ProjectionExpression: 'familyId,lastName,firstName,age',
  };

ProjectionExpressionを指定しない場合は、今回のテーブル、ローカルセカンダリインデックスの定義ではlastNameはundefinedになります。

ProjectionExpressionを使うことで、コピー対象に含まれていない属性もフェッチすることができますが、当然性能とトレードオフになるので
頻繁に取得する項目であればコピー対象に加えた方が良いでしょう。

ところで、グローバルセカンダリインデックスに対してコピー対象に指定していない属性をProjectionExpressionに含めると、エラーに
なります。

test("query for people's global secondary index (simple key), invalid projection", async () => {
  const params: DocumentClient.QueryInput = {
    TableName: 'People',
    IndexName: 'PeopleGlobalSimpleIndex', // インデックス名を指定
    KeyConditionExpression: 'firstName = :firstName',
    ExpressionAttributeValues: {
      ':firstName': 'ノリスケ',
    },
    ScanIndexForward: false,
    ProjectionExpression: 'familyId,lastName,firstName,age',
  };

  try {
    await dynamodb.query(params).promise();
  } catch (e) {
    const error = e as Error;
    expect(error.name).toBe('ValidationException');
    expect(error.message).toBe(
      'One or more parameter values were invalid: Global secondary index PeopleGlobalSimpleIndex does not project [age]'
    );
  }
});
スキャンでセカンダリインデックスを使う

最後はスキャンです。

グローバルセカンダリインデックスから。

test("scan for people's global secondary index (composite keys)", async () => {
  const params: DocumentClient.ScanInput = {
    TableName: 'People',
    IndexName: 'PeopleGlobalCompositeIndex',
    FilterExpression: 'lastName = :lastName and age >= :age',
    ExpressionAttributeValues: {
      ':lastName': '磯野',
      ':age': 50,
    },
  };

  const result = await dynamodb.scan(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(2);

    const fune = result.Items[0] as Person;
    expect(fune.familyId).toBe(1);
    expect(fune.lastName).toBe('磯野');
    expect(fune.firstName).toBe('フネ');
    expect(fune.age).toBe(50);

    const namihei = result.Items[1] as Person;
    expect(namihei.familyId).toBe(1);
    expect(namihei.lastName).toBe('磯野');
    expect(namihei.firstName).toBe('波平');
    expect(namihei.age).toBe(54);
  } else {
    throw new Error('test failed');
  }
});

ポイントは、クエリーの時と同様にIndexNameでインデックス名を指定するところですね。

  const params: DocumentClient.ScanInput = {
    TableName: 'People',
    IndexName: 'PeopleGlobalCompositeIndex',
    FilterExpression: 'lastName = :lastName and age >= :age',
    ExpressionAttributeValues: {
      ':lastName': '磯野',
      ':age': 50,
    },
  };

グローバルセカンダリインデックスの場合はConsistentReadをtrueに指定できないこと、ProjectionExpressionにコピー対象に
指定していない属性を指定できないのもクエリーと同じです。

test("scan for people's global secondary index (simple key)", async () => {
  const params: DocumentClient.ScanInput = {
    TableName: 'People',
    IndexName: 'PeopleGlobalSimpleIndex',
    FilterExpression: 'lastName = :lastName and age >= :age',
    ExpressionAttributeValues: {
      ':lastName': '磯野',
      ':age': 50,
    },
  };

  const result = await dynamodb.scan(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(2);

    const fune = result.Items[0] as Person;
    expect(fune.familyId).toBe(1);
    expect(fune.lastName).toBe('磯野');
    expect(fune.firstName).toBe('フネ');
    expect(fune.age).toBeUndefined();

    const namihei = result.Items[1] as Person;
    expect(namihei.familyId).toBe(1);
    expect(namihei.lastName).toBe('磯野');
    expect(namihei.firstName).toBe('波平');
    expect(namihei.age).toBeUndefined();
  } else {
    throw new Error('test failed');
  }
});

ローカルセカンダリインデックスの場合は、ProjectionExpressionを利用することでコピー対象に指定しない属性の値をフェッチできるのも
クエリーと同じですね。

test("scan for people's local secondary index (composite keys)", async () => {
  const params: DocumentClient.ScanInput = {
    TableName: 'People',
    IndexName: 'PeopleLocalCompositeIndex',
    FilterExpression: 'lastName = :lastName and age >= :age',
    ExpressionAttributeValues: {
      ':lastName': '磯野',
      ':age': 50,
    },
    ProjectionExpression: 'familyId,lastName,firstName,age',
    ConsistentRead: true,
  };

  const result = await dynamodb.scan(params).promise();

  if (result.Items) {
    expect(result.Count).toBe(2);

    const fune = result.Items[0] as Person;
    expect(fune.familyId).toBe(1);
    expect(fune.lastName).toBe('磯野');
    expect(fune.firstName).toBe('フネ');
    expect(fune.age).toBe(50);

    const namihei = result.Items[1] as Person;
    expect(namihei.familyId).toBe(1);
    expect(namihei.lastName).toBe('磯野');
    expect(namihei.firstName).toBe('波平');
    expect(namihei.age).toBe(54);
  } else {
    throw new Error('test failed');
  }
});

クエリーを見た後だと、あまり特徴的なことがありませんでした…。

とりあえず、参照のみですがセカンダリインデックスの雰囲気は良しとしましょう。

まとめ

今回は、2種類のセカンダリインデックスを定義して、クエリーやスキャンで利用するところをやってみました。

ざっくりとセカンダリインデックスの性質はわかったので、今回は目的を果たしたかな、と思います。

ちゃんと使おうと思うとまだまだたくさん知らなくてはいけないことがあると思うのですが、今回はこれで区切りで…。