AWS Amplify Gen 2 でDynamoDBのGSIを設定してクエリする
こんにちは、なおにしです。
AWS Amplify gen2 でDynamoDBのGSI(グローバルセカンダリインデックス)を設定してクエリする機会がありましたのでご紹介します。
はじめに
前回の記事で、observeQueryを使用すると初回のデータロード時にDynamoDBのテーブルに対してScanが実行されるということに触れました。
observeQueryを使用せずにリアルタイムイベントのサブスクライブを実装する方法についても記載しましたが、題材がドキュメントにあるクイックスタートの「5. Implement delete functionality」までを適用したプロジェクトだったこともあり、機能的には初回のデータロード時にToDoリストを全件取得すれば良かったので、listメソッドを使用しました。
ですが、listメソッドでもテーブルに対するScanは実行されるため、初回のデータロード時だけとはいえ、可能であればScanを実行しない実装にした方がベターかと思います。
少量のデータであれば問題ないかもしれませんが、利用者数が増えてデータ量も増大してきた時に、コスト/性能の問題が顕在化する可能性があります。
例えば、クイックスタートを「9. Implement per-user authorization」までを終えている状態であれば、以下のようにユーザごとにToDoリストが表示される状態になっているかと思います。
画面上に表示されるリストは各ユーザのものだけになっていますが、DynamoDBテーブルへのアクセスでScanが使われなくなったわけではありません。Scan自体は実行されるものの、DynamoDBへのアクセスにはAppSyncが使用されており、認可モードにCognito ユーザープールが指定されているためそこでユーザごとにデータがフィルタリングされている状態です。
また、DynamoDB テーブルの各フィールド(アトリビュート)は、クイックスタートで実装されているamplify/data/resource.ts
ではcontents
しか明示的に定義されていませんが、実際には以下のようになっています。
ソートキーは定義されておらず、パーティションキーとして「id」が指定されています。id
についてはドキュメントに以下のとおり記載があります。
A unique identifier for an object. This scalar is serialized like a String but isn't meant to be human-readable. If not specified on create operations, a UUID will be generated.
(機械翻訳)
オブジェクトの一意の識別子。このスカラー値は文字列のようにシリアル化されますが、人間が読める形式ではありません。作成操作時に指定されない場合は、UUIDが生成されます。
UUIDのバージョンとして何が使われているのかは明記されていませんが、少なくとも特定の値を元に生成するような指定はありません。したがって、デフォルトのままでは一度データを全取得しないことにはパーティションキー(プライマリキー)を指定したGetItemやDeleteItemは使用することができないため、格納するデータに応じて設計および指定する必要があります。
例として、上記のようにクイックスタートが完了した時点のToDoリストであればどうなるか考えてみます。
ユーザごとにToDoリストを一覧表示するので、メールアドレスだったり既存アトリビュートのownerは使用したいところです。
ですがそれだけだとプライマリキーとしてレコードを一意にすることができないため、なんらかの情報を追加して一意に識別する必要があります。
すぐに思いつくものとして挙げられるのは単純な連番管理かなと思います。ToDoリストの所有者とリスト数からレコードを一意にするというやり方です。
パーティションキー(プライマリキー) | ソートキー |
---|---|
メールアドレス+連番 (e.g., naonishi@example.com#1) | 無し |
ですが連番管理の場合、例えば別々のブラウザで同時に同じユーザがToDoリストを開いていたときに片方が先に更新をかけた時にどうするのかであったり、そもそも新しい連番を振るために既存のリスト数をカウントするにはどうすれば良いのかであったり、データをソートしておかないと何番目なのか分からなくなるのではないかであったりなど、考慮事項は多くあります。
同じToDoの内容(contents)は登録できないという制限があっても問題なければ、例えば以下のようにUUIDを生成することも選択肢の一つかと思います。
パーティションキー(プライマリキー) | ソートキー |
---|---|
「メールアドレス+ToDo内容」を元にしたUUID(v5) (e.g., 6ba7b810-9dad-11d1-80b4-00c04fd430c8) | 無し |
「メールアドレス+ToDo内容」を元にしたUUID(v5)を一意な値としてプライマリキーに設定できますが、可能であればログイン後に表示する特定ユーザのToDoリストのみをクエリできると、DynamoDB テーブルに対してScanを実行する必要がなくなるためより良くなりそうです。
パーティションキーとソートキーによる複合キーを利用してクエリする場合、ソートキーだけを指定してクエリするということはできないため、カーディナリティは低くなってしまいますが例えば以下のように設定することも可能かと思います。
パーティションキー | ソートキー |
---|---|
メールアドレス (e.g.,naonishi@example.com) | 「メールアドレス+ToDo内容」を元にしたUUID(v5) (e.g., 6ba7b810-9dad-11d1-80b4-00c04fd430c8) |
ソートキーが活用できていない感はありますが、パーティションキーとソートキーで一意なプライマリキーとして機能させるには、今回のToDoリストのデータ要素では仕方なさそうです。
ところが、上記の設計に基づいたデータ取得は通常のDynamoDBのQuery操作では機能しますが、Amplify gen2のバックエンド機能(Amplify Data)を使用する場合は、本記事の執筆時点では実装することができないようです。
ドキュメントに記載のとおり、現在提供されているデータ読み取りのメソッドはlist
とget
です。前述のとおりlist
はScan操作となるため可能であれば利用しないように実装したいところですが、一度全てのデータを取得するため、フィルター機能と組み合わせることで欲しいデータを柔軟に取得することは可能です。
一方でget
メソッドについてですが、こちらはDynamoDBにおけるGetItemとして機能するため、複合キーでパーティションキーのみを指定またはパーティションキーと条件を指定したソートキーを指定してデータを取得するという、DynamoDBにおけるQueryを実行することができません。
Amplify gen2では、ソートキーを指定する場合はドキュメントに記載のとおりidentifier
メソッドを使用します。例えば前述のメールアドレスをUUID(v5)を使用した複合キーを設定する場合であれば、以下のような記述となります。
const schema = a.schema({
Todo: a
.model({
mailAddress: a.id().required(),
uuid: a.string().required(),
content: a.string()
})
.identifier(['mailAddress', 'uuid'])
.authorization(allow => [allow.owner()])
});
上記を実際に定義した上でget
メソッドの型定義を確認すると以下のようになり、メールアドレスとUUIDの指定が必須となります。
(method) get<never[]>(identifier: {
mailAddress: string;
uuid: string;
}, options?: {
selectionSet?: never[] | undefined;
authMode?: AuthMode;
authToken?: string;
headers?: CustomHeaders;
} | undefined): SingularReturnValue<...>
この制限により、複合キーを設定しても複数アイテムの取得という用途では使用することができないため、続いて検討するのがセカンダリインデックスの活用となります。
というわけで、ドキュメントに記載があるセカンダリインデックスの実装を試してみます。
セカンダリインデックスってLSIなの?GSIなの?という部分がなぜか明記されていませんが、結論から言うと上記ドキュメントで作成されるリソースとしてはGSIであり、LSIは設定することができませんでした。
やってみた
事前準備
せっかくなので前回の記事で試したobserveQueryを使わない実装と、クイックスタートの「9. Implement per-user authorization」までが完了した状態から始めたいと思います。このため、App.tsxについては以下のとおりで、その他のスクリプトについてはクイックスタートのとおりの状態です。
前提とするコード
import { useEffect, useState } from "react";
import type { Schema } from "../amplify/data/resource";
import { useAuthenticator } from '@aws-amplify/ui-react';
import { generateClient } from "aws-amplify/data";
const client = generateClient<Schema>();
function App() {
const { user, signOut } = useAuthenticator();
const [todos, setTodos] = useState<Array<Schema["Todo"]["type"]>>([]);
useEffect(() => {
const fetchTodoList = async () => {
const response = await client.models.Todo.list();
setTodos([...response.data]);
}
fetchTodoList();
const createSub = client.models.Todo.onCreate().subscribe({
next: (data) => {
setTodos((prev) => [...prev, data]);
}
});
const deleteSub = client.models.Todo.onDelete().subscribe({
next: (data) => {
setTodos((prev) => prev.filter((todo) => todo.id !== data.id));
}
});
return () => {
createSub.unsubscribe();
deleteSub.unsubscribe();
}
}, []);
function createTodo() {
client.models.Todo.create({ content: window.prompt("Todo content") });
}
function deleteTodo(id: string) {
client.models.Todo.delete({ id })
}
return (
<main>
<h1>{user?.signInDetails?.loginId}'s todos</h1>
<button onClick={createTodo}>+ new</button>
<ul>
{todos.map((todo) => (
<li onClick={() => deleteTodo(todo.id)} key={todo.id}>{todo.content}</li>
))}
</ul>
<div>
🥳 App successfully hosted. Try creating a new todo.
<br />
<a href="https://docs.amplify.aws/react/start/quickstart/#make-frontend-updates">
Review next step of this tutorial.
</a>
</div>
<button onClick={signOut}>Sign out</button>
</main>
);
}
export default App;
GSIを設定する前にまず上記のコードを以下の複合キーで使用できる形に対応させてみます。
パーティションキー | ソートキー |
---|---|
メールアドレス (e.g.,naonishi@example.com) | 「メールアドレス+ToDo内容」を元にしたUUID(v5) (e.g., 6ba7b810-9dad-11d1-80b4-00c04fd430c8) |
さらに、amplify/data/resource.ts
についても前述のとおり修正します。
修正したスクリプトは以下のとおりです。メールアドレスの情報は以前の記事と同様にユーザープール属性から取得したり、uuidを設定したりするためのコードを追加したので思ったよりも長くなってしまいました。
前提とするコード
import { useEffect, useState } from "react";
import type { Schema } from "../amplify/data/resource";
import { useAuthenticator, Loader } from '@aws-amplify/ui-react';
import { generateClient } from "aws-amplify/data";
import { FetchUserAttributesOutput, fetchUserAttributes } from 'aws-amplify/auth';
import { v5 as uuidv5 } from 'uuid';
// アプリケーション用に一意の名前空間を定義
const UUID_NAMESPACE = '1b671a64-40d5-491e-99b0-da01ff1f3341';
const client = generateClient<Schema>();
// ユーザー属性を取得するカスタムフック
function useUserAttributes() {
const [userAttributes, setUserAttributes] = useState<FetchUserAttributesOutput>();
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAttributes = async () => {
try {
const result = await fetchUserAttributes();
setUserAttributes(result);
} catch (error) {
console.error('ユーザー属性の取得に失敗しました:', error);
} finally {
setLoading(false);
}
};
fetchAttributes();
}, []);
return { userAttributes, loading };
}
function App() {
const { user, signOut } = useAuthenticator();
const [todos, setTodos] = useState<Array<Schema["Todo"]["type"]>>([]);
// カスタムフックを使用してユーザー属性を取得
const { userAttributes, loading: userLoading } = useUserAttributes();
useEffect(() => {
const fetchTodoList = async () => {
const response = await client.models.Todo.list();
setTodos([...response.data]);
}
fetchTodoList();
const createSub = client.models.Todo.onCreate().subscribe({
next: (data) => {
setTodos((prev) => [...prev, data]);
}
});
const deleteSub = client.models.Todo.onDelete().subscribe({
next: (data) => {
// 複合キーに基づいて削除されたTodoをフィルタリング
setTodos((prev) => prev.filter((todo) =>
!(todo.mailAddress === data.mailAddress && todo.uuid === data.uuid)
));
}
});
return () => {
createSub.unsubscribe();
deleteSub.unsubscribe();
}
}, []);
async function createTodo() {
if (!userAttributes?.email) return;
const mailAddress = userAttributes.email;
const content = window.prompt("Todo content") || "";
const uuid = uuidv5(`${mailAddress}:${content}`, UUID_NAMESPACE);
await client.models.Todo.create({
mailAddress: mailAddress,
content: content,
uuid: uuid
});
}
function deleteTodo(mailAddress: string, uuid: string) {
client.models.Todo.delete({
mailAddress: mailAddress,
uuid: uuid
});
}
if (userLoading) {
return (
<div className="loader-container">
<Loader size="large" />
</div>
);
}
return (
<main>
<h1>{user?.signInDetails?.loginId}'s todos</h1>
<button onClick={createTodo}>+ new</button>
<ul>
{todos.map((todo) => (
<li
onClick={() => deleteTodo(todo.mailAddress, todo.uuid)}
key={`${todo.mailAddress}-${todo.uuid}`}
>
{todo.content}
</li>
))}
</ul>
<div>
🥳 App successfully hosted. Try creating a new todo.
<br />
<a href="https://docs.amplify.aws/react/start/quickstart/#make-frontend-updates">
Review next step of this tutorial.
</a>
</div>
<button onClick={signOut}>Sign out</button>
</main>
);
}
export default App;
上記コードの時点では修正前と同様にlist
メソッドを使用したままであり、ブラウザからの操作感も変わりません。ですが、DynamoDB テーブルとしては以下のとおりソートキーが追加されています。
ToDoリストも問題なく登録できました。
GSIの設定
それではGSIを設定します。今回はメールアドレスをGSIのパーティションキーに設定するので、以下のように修正します。ソートキーは今回は使用しないため設定しません。
import { type ClientSchema, a, defineData } from "@aws-amplify/backend";
const schema = a.schema({
Todo: a
.model({
mailAddress: a.id().required(),
uuid: a.string().required(),
content: a.string()
})
.identifier(['mailAddress', 'uuid'])
+ .secondaryIndexes((index) => [index("mailAddress")
+ .name("mailAddressGSI")
+ .queryField("listByMailAddress")
])
.authorization(allow => [allow.owner()])
});
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({
schema,
authorizationModes: {
defaultAuthorizationMode: 'userPool',
},
});
上記をサンドボックスに反映すると、以下のようにGSIが定義されました。
amplify/data/resource
でメソッド名(queryField)をlistByMailAddress
と定義しているので、こちらとlist
メソッドを置き換えることでGSIを使用したクエリが実行できるようにしてみます。
mailAddressの取得が非同期処理のため依存配列の追加なども必要になりましたが、最終的には以下のようになりました。
import { useEffect, useState } from "react";
import type { Schema } from "../amplify/data/resource";
import { useAuthenticator, Loader } from '@aws-amplify/ui-react';
import { generateClient } from "aws-amplify/data";
import { FetchUserAttributesOutput, fetchUserAttributes } from 'aws-amplify/auth';
import { v5 as uuidv5 } from 'uuid';
// アプリケーション用に一意の名前空間を定義
const UUID_NAMESPACE = '1b671a64-40d5-491e-99b0-da01ff1f3341';
const client = generateClient<Schema>();
// ユーザー属性を取得するカスタムフック
function useUserAttributes() {
const [userAttributes, setUserAttributes] = useState<FetchUserAttributesOutput>();
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAttributes = async () => {
try {
const result = await fetchUserAttributes();
setUserAttributes(result);
} catch (error) {
console.error('ユーザー属性の取得に失敗しました:', error);
} finally {
setLoading(false);
}
};
fetchAttributes();
}, []);
return { userAttributes, loading };
}
function App() {
const { user, signOut } = useAuthenticator();
const [todos, setTodos] = useState<Array<Schema["Todo"]["type"]>>([]);
// カスタムフックを使用してユーザー属性を取得
const { userAttributes, loading: userLoading } = useUserAttributes();
useEffect(() => {
+ if (userLoading || !userAttributes?.email) return;
+
const fetchTodoList = async () => {
- const response = await client.models.Todo.list();
+ const response = await client.models.Todo.listByMailAddress({
+ mailAddress: userAttributes.email || ""
+ });
setTodos([...response.data]);
}
fetchTodoList();
const createSub = client.models.Todo.onCreate().subscribe({
next: (data) => {
setTodos((prev) => [...prev, data]);
}
});
const deleteSub = client.models.Todo.onDelete().subscribe({
next: (data) => {
// 複合キーに基づいて削除されたTodoをフィルタリング
setTodos((prev) => prev.filter((todo) =>
!(todo.mailAddress === data.mailAddress && todo.uuid === data.uuid)
));
}
});
return () => {
createSub.unsubscribe();
deleteSub.unsubscribe();
}
- }, []);
+ }, [userAttributes, userLoading]);
async function createTodo() {
if (!userAttributes?.email) return;
const mailAddress = userAttributes.email;
const content = window.prompt("Todo content") || "";
const uuid = uuidv5(`${mailAddress}:${content}`, UUID_NAMESPACE);
await client.models.Todo.create({
mailAddress: mailAddress,
content: content,
uuid: uuid
});
}
function deleteTodo(mailAddress: string, uuid: string) {
client.models.Todo.delete({
mailAddress: mailAddress,
uuid: uuid
});
}
if (userLoading) {
return (
<div className="loader-container">
<Loader size="large" />
</div>
);
}
return (
<main>
<h1>{user?.signInDetails?.loginId}'s todos</h1>
<button onClick={createTodo}>+ new</button>
<ul>
{todos.map((todo) => (
<li
onClick={() => deleteTodo(todo.mailAddress, todo.uuid)}
key={`${todo.mailAddress}-${todo.uuid}`}
>
{todo.content}
</li>
))}
</ul>
<div>
🥳 App successfully hosted. Try creating a new todo.
<br />
<a href="https://docs.amplify.aws/react/start/quickstart/#make-frontend-updates">
Review next step of this tutorial.
</a>
</div>
<button onClick={signOut}>Sign out</button>
</main>
);
}
export default App;
実際にToDoリストを追加/削除したりログイン/ログアウトして再読み込みさせてみたりした後のメトリクスは以下のとおりです。データ取得にScanは実行されておらず、QueryとGetItemが使用されていることが分かります。
もちろんですがブラウザから見たToDoリストの見た目は変わりませんので、ToDoリストのスクリーンショットは割愛します。
また、ドキュメントに記載されているとおりGSIに関してはソートキーを使用することで取得対象のデータを柔軟に指定してフィルターすることができるので、list
メソッドを使わずに条件を指定して複数レコードを取得する際は、基本的には今回のようにGSIを使用するのが良いかと思います。
まとめ
Amplify Gen 2ではTypeScriptによってバックエンドも構築することができるため、生成AIを活用することでコードを生成してサービス構築するというやり方と相性が良いと思います。
一方で、バックエンドの実装がどうなっているのか意識しないまま構築すると、実現できている機能も見た目も同じことから構築フェーズでは顕在化しないコスト・性能の問題が、サービス開始後に課題になってくることもありそうという、その一例が今回のケースだと思いました。
本記事がどなたかのお役に立てれば幸いです。