これは、なにをしたくて書いたもの?
前に、Amazon DynamoDBのローカル版を試してみました。
Amazon DynamoDBのローカル版を試してみる - CLOVER🍀
Amazon DynamoDBを使った操作を少しずつ見ていこうと思います。
今回は、クエリーをテーマにしてみましょう。
DynamoDB でのクエリの使用 - Amazon DynamoDB
使用するAmazon DynamoDBは、変わらずローカル版です。
Amazon DynamoDBのクエリー
Amazon DynamoDBのクエリーに関するドキュメントを見てみましょう。
まず、概要はこちら。
Amazon DynamoDB での Query オペレーションでは、プライマリキーの値に基づいて項目を探します。
パーティションキー属性の名前、および属性の単一値を指定する必要があります。Query はそのパーティションキー値を持つすべての項目を返します。必要に応じて、ソートキーの属性を指定し、比較演算子を使用して、検索結果をさらに絞り込むことができます。
DynamoDB でのクエリの使用 - Amazon DynamoDB
「クエリー」という名前ではありますが、パーティションキーの値を指定して、ソートキーで絞り込むという使い方になるようです。
つまり、クエリーを使うにはテーブルが複合主キーで構成されていることが前提になりそうですね。
パーティションキーは等価で指定し、ソートキーは比較演算子やbetween
、begins_with
関数などで絞り込めるようです。
検索条件を指定するには、キー条件式 (テーブルまたはインデックスから読み取る項目を決定する文字列) を使用します。
等価条件としてパーティションキーの名前と値を指定する必要があります。
オプションで、ソートキーに 2 番目の条件を指定できます (存在する場合)。ソートキーの条件では、次の比較演算子の 1 つを使用する必要があります。
プライマリーキーを指定する特性上、あまり巨大なデータセットにはならない気もしますが、1回のクエリーで扱えるデータの上限は1MB
となるようです。
1 回の Query オペレーションで、最大 1 MB のデータを取得できます。
結果のソート順は、ソートキーに依存するようです。昇順、降順の制御もソートキーの指定ですね。
Query の結果は常にソートキーの値によってソートされます。ソートキーのデータ型が Number である場合は、結果が番号順で返されます。その他の場合は、UTF-8 バイトの順序で結果が返されます。デフォルトの並べ替え順序は昇順です。順序を反転させるには、ScanIndexForward パラメータを false に設定します。
パーティションキーおよびソートキーでの絞り込みに加えて、フィルターを使った絞り込みもできるようです。
Query 結果の絞り込みが必要な場合は、オプションでフィルタ式を指定できます。フィルタ式によって、Query 結果の返される項目が決まります。他のすべての結果は破棄されます。
これは、クエリーで絞り込むというよりは、クエリの結果をさらにフィルタリングするもののようですね。なので、フィルターでは
スキャンする対象を減らせるわけではないようです。
フィルタ式は、Query の完了後、結果が返される前に適用されます。そのため、Query は、フィルタ式があるかどうかにかかわらず、同じ量の読み込みキャパシティーを消費します。
このため、フィルターの適用はクエリーのデータ取得上限には影響しないことになります。
1 回の Query オペレーションで、最大 1 MB のデータを取得できます。この制限は、フィルタ式を評価する前に適用されます。
読み込むデータの整合性について。デフォルトは、結果整合性のようです。
Query オペレーションは、結果的に整合性のある読み込みをデフォルトで行います。つまり、Query 結果が、最近完了した PutItem または UpdateItem オペレーションによる変更を反映しない場合があります。
DynamoDB でのクエリの使用 / クエリの読み込み整合性
強力な整合性のある読み込みが必要な場合は、ConsistentRead リクエストで true パラメータを Query に設定します。
また、セカンダリインデックスもテーマとしてはあるようですが、こちらについては今回は対象外にしておきます。
Amazon DynamoDB によって、プライマリキーの値を指定して、テーブルの項目に高速アクセスすることが可能になります。しかし多くのアプリケーションでは、プライマリキー以外の属性を使って、データに効率的にアクセスできるようにセカンダリ(または代替)キーを 1 つ以上設定することで、メリットが得られることがあります。これに対応するために、1 つのテーブルで 1 つ以上のセカンダリインデックスを作成して、それらのインデックスに対して Query または Scan リクエストを実行することができます。
セカンダリインデックス は、テーブルからの属性のサブセットと、Query オペレーションをサポートする代替キーで構成されるデータ構造です。Query をテーブルで使用する場合と同じように、Query を使用してインデックスからデータを取得できます。テーブルには、複数のセカンダリインデックスを含めることができます。これにより、アプリケーションは複数の異なるクエリパターンにアクセスできます。
セカンダリインデックスを使用したデータアクセス性の向上 - Amazon DynamoDB
内容はこれくらいにして、AWS SDKで使っていきたいと思います。
環境
今回の環境は、こちらです。
$ aws --version aws-cli/2.4.16 Python/3.8.8 Linux/5.4.0-97-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.13.2 $ npm --version 8.1.2
準備
Node.jsプロジェクトを作成します。テストコードで確認することにしましょう。
$ npm init -y $ npm i -D typescript $ npm i -D -E prettier $ npm i -D jest @types/jest $ npm i -D npm i -D esbuild esbuild-jest $ mkdir src test
Node.jsの型宣言とAWS SDK for JavaScript v2をインストール。
$ npm i -D @types/node@v16 $ npm i aws-sdk
依存関係は、こうなりました。
"devDependencies": { "@types/jest": "^27.4.0", "@types/node": "^16.11.22", "esbuild": "^0.14.18", "esbuild-jest": "^0.5.0", "jest": "^27.4.7", "prettier": "2.5.1", "typescript": "^4.5.5" }, "dependencies": { "aws-sdk": "^2.1069.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.conf
{ "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
テーブルは、こちらの定義にしました。
$ aws dynamodb create-table \ --endpoint-url http://localhost:8000 \ --table-name People \ --attribute-definitions \ AttributeName=familyId,AttributeType=N \ AttributeName=age,AttributeType=N \ --key-schema \ AttributeName=familyId,KeyType=HASH \ AttributeName=age,KeyType=RANGE \ --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 }, { "familyId": 3, "lastName": "伊佐坂", "firstName": "難物", "age": 60 }, { "familyId": 3, "lastName": "伊佐坂", "firstName": "お軽", "age": 50 }, { "familyId": 3, "lastName": "伊佐坂", "firstName": "甚六", "age": 20 }, { "familyId": 3, "lastName": "伊佐坂", "firstName": "浮江", "age": 16 } ]
ソートキーをage
(年齢)にするのは微妙なのですが、キーの条件指定を考えると数値の方が良いかなと思い、今回はこちらに
しておきました。
プログラムを作成する
では、クエリーを使うプログラムを作成します。
データにマッピングするクラスを作成。
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; } }
テストコードのimport
部分と、DocumentClient
の作成。
test/query.test.ts
import { DocumentClient } from 'aws-sdk/clients/dynamodb'; import file from 'fs'; import { Person } from '../src/person'; const dynamodb = new DocumentClient({ credentials: { accessKeyId: 'fakeMyKeyId', secretAccessKey: 'fakeSecretAccessKey', }, region: 'ap-northeast-1', endpoint: 'http://localhost:8000', }); // ここに、テストを書く!
テストデータのロードと、削除を最後に入れておきます。
test('load people', async () => { const people = JSON.parse( await file.promises.readFile(`${__dirname}/people.json`, 'utf8') ) as Person[]; const params: DocumentClient.BatchWriteItemInput = { RequestItems: { People: people.map((p) => ({ PutRequest: { Item: p } })), }, }; const writed = await dynamodb.batchWrite(params).promise(); expect(writed.UnprocessedItems).toEqual({}); }); // ここに、テストを書く! test('delete people', async () => { const people = JSON.parse( await file.promises.readFile(`${__dirname}/people.json`, 'utf8') ) as Person[]; for (const person of people) { const params: DocumentClient.DeleteItemInput = { TableName: 'People', Key: { familyId: person.familyId, age: person.age, }, ReturnValues: 'ALL_OLD', }; try { const deleted = await dynamodb.delete(params).promise(); if (deleted.Attributes) { expect(deleted.Attributes['familyId']).toBe(person.familyId); expect(deleted.Attributes['firstName']).toBe(person.firstName); } else { throw new Error('fail'); } } catch (e) { throw e; } } return people; });
では、クエリーを使っていきます。
DocumentClient#query
の説明はこちらにあるのですが、
Class: AWS.DynamoDB.DocumentClient / query
説明のないパラメーターも多いので、APIも合わせて見た方が良いかもしれません。
ソートキーを等価比較で行う場合。
test('query equals', async () => { const params: DocumentClient.QueryInput = { TableName: 'People', KeyConditionExpression: 'familyId = :familyId and age = :age', ExpressionAttributeValues: { ':familyId': 1, ':age': 24, }, ConsistentRead: true, }; const result = await dynamodb.query(params).promise(); if (result.Items) { expect(result.Count).toBe(1); const sazae = result.Items[0] as Person; expect(sazae.familyId).toBe(1); expect(sazae.lastName).toBe('フグ田'); expect(sazae.firstName).toBe('サザエ'); expect(sazae.age).toBe(24); } else { throw new Error('fail'); } });
キーの条件指定(KeyConditionExpression
)は、こんな感じですね。埋め込む値は、ExpressionAttributeValues
で指定。
KeyConditionExpression: 'familyId = :familyId and age = :age', ExpressionAttributeValues: { ':familyId': 1, ':age': 24, },
この場合は、1件の取得になります。DocumentClient#get
でいいのでは、という感じではありますが。
読み込み整合性は、今回は一貫して強力な整合性(ConsistentRead: true
)に設定しておきます。
次は、ソートキーの指定を不等号にしてみましょう。並び順は、ソートキーの降順にしておきます。
test('query <=, reverse sort', async () => { const params: DocumentClient.QueryInput = { TableName: 'People', KeyConditionExpression: 'familyId = :familyId and age <= :age', ExpressionAttributeValues: { ':familyId': 1, ':age': 11, }, ScanIndexForward: false, ConsistentRead: true, }; const result = await dynamodb.query(params).promise(); if (result.Items) { expect(result.Count).toBe(3); 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); const tarao = result.Items[2] as Person; expect(tarao.familyId).toBe(1); expect(tarao.lastName).toBe('フグ田'); expect(tarao.firstName).toBe('タラオ'); expect(tarao.age).toBe(3); } else { throw new Error('fail'); } });
ソートキーの指定を不等号に、並び順を降順にするにはScanIndexForward
をfalse
にします。並び順に影響する値は、あくまで
ソートキーの値です。
KeyConditionExpression: 'familyId = :familyId and age <= :age', ExpressionAttributeValues: { ':familyId': 1, ':age': 11, }, ScanIndexForward: false,
フィルターも使ってみましょう。
test('query with filter', async () => { const params: DocumentClient.QueryInput = { TableName: 'People', KeyConditionExpression: 'familyId = :familyId and age > :age', FilterExpression: 'firstName = :firstName', ExpressionAttributeValues: { ':familyId': 2, ':age': 20, ':firstName': 'ノリスケ', }, ConsistentRead: true, }; 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).toBe(26); } else { throw new Error('fail'); } });
フィルターは、FilterExpression
で指定します。
KeyConditionExpression: 'familyId = :familyId and age > :age', FilterExpression: 'firstName = :firstName', ExpressionAttributeValues: { ':familyId': 2, ':age': 20, ':firstName': 'ノリスケ', },
構文は、キーの条件指定と同じものになるようです。
フィルタ式の構文は、キー条件式の構文と同じです。フィルタ式では、不等価演算子 (<>) に加えて、キー条件式と同じコンパレータ、関数および論理演算子を使用できます。
ExpressionAttributeValues
で指定した定義も、FilterExpression
で使えるようですね。
ところで、クエリーを使う時にパーティションキーだけ指定するとどうなるのかな?と思いましたが、ふつうに使えましたね。
test('query, partition key onky', async () => { const params: DocumentClient.QueryInput = { TableName: 'People', KeyConditionExpression: 'familyId = :familyId', ExpressionAttributeValues: { ':familyId': 2, }, ScanIndexForward: false, ConsistentRead: true, }; const result = await dynamodb.query(params).promise(); if (result.Items) { expect(result.Count).toBe(3); expect((result.Items[0] as Person).firstName).toBe('ノリスケ'); expect((result.Items[1] as Person).firstName).toBe('タイコ'); expect((result.Items[2] as Person).firstName).toBe('イクラ'); } else { throw new Error('fail'); } });
同じパーティションキーを持つアイテムを取得する場合は、こういった使い方になるんでしょうね。
今回は、こんな感じかなと思います。
まとめ
今回は、Amazon DynamoDBのクエリーを試してみました。
雰囲気はまあまあわかったのと、概ね予想通りだったかなと思います。
次はスキャンを試していこうかなと思います。