AWS Amplify gen2 のobserveQueryによるDynamoDBへのスキャン実行タイミング
こんにちは、なおにしです。
AWS Amplify gen2 のobserveQueryによるDynamoDBへのアクセス挙動を確認する機会がありましたのでご紹介します。
はじめに
AWS Amplify Gen 2 (以降、Amplify)では、内部的にはAWS AppSyncを利用したリアルタイムイベントのサブスクライブ機能が提供されています。
上記の記事やドキュメントのクイックスタートでも使用されているobserveQueryメソッドがサブスクライブするための主な方法のようですが、サブスクライブに関するドキュメントには以下の記載があります。
observeQuery
fetches and paginates through all of your available data in the cloud. While data is syncing from the cloud, snapshots will contain all of the items synced so far and anisSynced
status offalse
. When the sync process is complete, a snapshot will be emitted with all the records in the local store and anisSynced
status oftrue
.(機械翻訳)
observeQueryはクラウド内の利用可能なすべてのデータを取得し、ページ分割します。クラウドからデータを同期している間、スナップショットにはこれまでに同期されたすべてのアイテムが含まれ、isSynced ステータスは false になります。同期プロセスが完了すると、ローカル ストア内のすべてのレコードと、isSynced ステータスが true のスナップショットが生成されます
observeQueryはクラウド内の利用可能なすべてのデータを取得という部分が気になりますが、実際どのような挙動になるのでしょうか。
先に結論
- observeQueryは初回のデータロード時にDynamoDBに対してScanを実行します
- 意図しないScan操作は想定外の料金発生につながる可能性があるのでご注意ください
- 初回のScanによってデータがロードされた後は、作成/更新/削除された対象データだけがWebSocketによって送信されます
- observeQueryのコールバック関数の引数は作成/更新/削除後の全データになっていますが、毎回Scan操作が実行されているわけではありません
- 作成/更新/削除された対象データのみをコールバック関数で取得する場合は、observeQueryではなくonCreate/onUpdate/onDeleteメソッドといった各種データ操作に紐づくサブスクライブを使用します
というわけで実際にクイックスタートの内容を基に、動作確認とobserveQueryを使わない実装を試してみます。
やってみた
動作確認
ドキュメントにあるクイックスタートの「5. Implement delete functionality」までを適用したプロジェクトをサンドボックスで動かせている状態という前提で進めます。
上記の状態で、サブスクライブによって取得されているデータがどのような内容になっているか確認するためにコンソールへのログ出力を追加します。
import { useEffect, useState } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";
const client = generateClient<Schema>();
function App() {
const [todos, setTodos] = useState<Array<Schema["Todo"]["type"]>>([]);
useEffect(() => {
client.models.Todo.observeQuery().subscribe({
next: (data) => {
setTodos([...data.items]);
+ console.log(data.items);
}
});
}, []);
function createTodo() {
client.models.Todo.create({ content: window.prompt("Todo content") });
}
function deleteTodo(id: string) {
client.models.Todo.delete({ id })
}
return (
<main>
<h1>My 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>
</main>
);
}
export default App;
それではToDoリストを追加してみます。
1つ目追加。
登録自体はできていて画面上の表示も問題ないのですが、同じデータが2回返ってきています。
本記事の趣旨とずれるので補足事項にするか悩んだのですが、前提とするコードが変わってくるのでこのまま原因についても触れます。
2回データが返されてしまう原因は、React18のStrictModeによるものです。どういった挙動をするのかは以下の記事・ドキュメントをご参照ください。
クイックスタートに従ってテンプレートからプロジェクトを作成した場合、以下のようにStrictModeが適用されているはずです。
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { Amplify } from "aws-amplify";
import outputs from "../amplify_outputs.json";
Amplify.configure(outputs);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
こちらに関してはuseEffectでクリーンアップ関数が定義されていないことが問題なので追記します。実際、サブスクライブに関する以下のドキュメントではきちんとクリーンアップ関数としてサブスクライブの解除処理が定義されていますので、そのとおりにします。
useEffect(() => {
+ const sub = client.models.Todo.observeQuery().subscribe({
next: (data) => {
setTodos([...data.items]);
console.log(data.items);
}
});
+ return () => {
+ sub.unsubscribe();
+ }
}, []);
気を取り直してToDoリストを追加していきます。
返ってくるデータが想定どおり1つになりました。(補足:初回ロード時の空データ([])についてはStrictModeの挙動として2回返ってくるのでは?とも思ったのですが、サブスクライブによるコールバック関数の実行はStrictModeによるチェックの後に1回しか返ってこないようです)
続いて2つ目を登録します。
3つ目。
ここまでで挙動としては見えてきたかと思いますが、リストを追加する度にこれまで登録したデータも一緒に取得されています。
このタイミングで「もしかして毎回DynamoDBにScanを実行している...?」と疑ってしまったのですが、結果的には杞憂でした。
ChromeのデベロッパーツールではWebSocketの通信内容を見ることもできるようなので確認してみました。
4つ目のToDoリストを追加する操作を行ったブラウザとは別のブラウザで確認した結果が以下のとおりです。[ネットワーク]タブで[WS]を選択して、[graphql]の[メッセージ]から表示できます。
表示されているとおり、ペイロードとしては4つ目のToDoリストデータのみとなっています。
その他に確認したこととして、DynamoDBから直接4つ目のToDoリストデータを削除した上で5つ目を追加してみましたが、4つ目は表示されたままの状態でした(都度Scanが実行されるのであれば手動で削除した4つ目のデータの表示は消えるはずです)。
なお、3つ目を登録した時点で改めてブラウザを更新し、DynamoDBのメトリクスを確認したところ、以下のとおりスキャンのカウントが記録されていました。
observeQueryを使わない実装
今回のクイックスタートにあるToDoリストの場合、機能としては以下のように分割することができます。
- ブラウザで開いた時(初期データのロード時)に登録されているToDoリストを全件取得して表示する
- +newボタンによってToDoリストが作成されたらブラウザに表示する
- 特定のToDoリストをクリックすることで対象が削除されたらブラウザからも表示を消す
observeQueryによるサブスクライブで上記を実装している場合、初期データのロード以降の操作も全データを使用したステートの上書きになっています。シンプルかつ今回のToDoリストという用途では問題ないかと思いますが、作成/更新/削除されたデータだけを対象に操作したいケースもあるかと思います。
そこで、実際にobserveQueryを使わない形で上記機能を実装してみます。
まず、初期データのロードについてはドキュメントにあるlistメソッドに置き換え可能です。こちらはobserveQueryの初回ロードと同じようにDynamoDBに対してScanが実行されます。
useEffect(() => {
const fetchTodoList = async () => {
const response = await client.models.Todo.list();
console.log("全件取得", response.data);
setTodos([...response.data]);
}
fetchTodoList();
続いて、ToDoリストの作成/削除時のサブスクライブを実装します。それぞれでサブスクライブするため、クリーンアップ関数でもそれぞれをアンサブスクライブする必要があります。また、今回のToDoリストの機能では更新操作が無いためonUpdateについては使用しません。
// AppSync経由でデータが作成された時にコールバック関数が実行される
const createSub = client.models.Todo.onCreate().subscribe({
next: (data) => {
console.log("データ作成", data);
setTodos((prev) => [...prev, data]);
}
});
// AppSync経由でデータが削除された時にコールバック関数が実行される
const deleteSub = client.models.Todo.onDelete().subscribe({
next: (data) => {
console.log("データ削除", data);
// data.idと一致するidを持つToDoリストを除外
setTodos((prev) => prev.filter((todo) => todo.id !== data.id));
}
});
return () => {
createSub.unsubscribe();
deleteSub.unsubscribe();
}
}, []);
onCreateとonDeleteではそれぞれ作成・削除した対象のデータだけが引数となってコールバック関数が実行されます。このため、useStateフックで前の状態の値を対象にデータの追加・削除を行っています。
ここまでの内容で、App.tsxのコード全体は以下のようになっています。
import { useEffect, useState } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";
const client = generateClient<Schema>();
function App() {
const [todos, setTodos] = useState<Array<Schema["Todo"]["type"]>>([]);
useEffect(() => {
const fetchTodoList = async () => {
const response = await client.models.Todo.list();
console.log("全件取得", response.data);
setTodos([...response.data]);
}
fetchTodoList();
const createSub = client.models.Todo.onCreate().subscribe({
next: (data) => {
console.log("データ作成", data);
setTodos((prev) => [...prev, data]);
}
});
const deleteSub = client.models.Todo.onDelete().subscribe({
next: (data) => {
console.log("データ削除", 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>My 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>
</main>
);
}
export default App;
改めて上記のコードでToDoリストを追加・削除してみます。
1つ目追加。
listメソッドを使用しているため、observeQueryによるサブスクライブとは違ってStrictModeによって初回ロード時の全件取得が2回実行されているようです。続いて2つ目を追加します。
1つ目のデータは出力されず、2つ目のデータだけが対象になっています。念の為3つ目も追加してみます。
問題なさそうです。最後に3つ目削除してみます。
作成・削除したデータだけを対象に処理することができました。
また、DynamoDB側で実行されたリクエストについても以下の流れでどうなるか確認してみました。
-
13:25 ブラウザ表示
-
13:26 ToDoリストを1つ作成
-
13:27 ToDoリストを1つ削除
ポイントが小さくて少し見えにくいかと思いますが、結果は以下のとおりです。
以下のとおりリクエストが実行されていました。
-
13:25 Scanが2回リクエスト
-
13:26 PutItemが1回リクエスト
-
13:27 DeleteItemとGetItemが1回リクエスト
削除自体はidのみ指定して実行されているため、id以外のデータを取得するためにDeleteItemと併せてGetItemもリクエストされているかと思います。この辺りはAppSyncのスキーマや関数を読み解けば裏付け可能なのですが、複雑かつGraphQLについては学習不足のため割愛させていただきます。
まとめ
Amplify gen2で作成したアプリの動作を確認している際、DynamoDBのメトリクスでScanがカウントされていたため、どのタイミングで実行されているのかを洗い出すために調査してみました。
本記事がどなたかのお役に立てれば幸いです。