COLUMN
Amplify Gen2のDataをAPIキャッシュする
ベランダの修理に興味のある中村です。 |
2024年5月に、アプリ開発フレームワークであるAmplifyの第2世代(Gen2)がリリースされました。
社内でもAmplifyを活用したプロジェクトが始まっており、Amplify Gen2のノウハウ・新機能のキャッチアップを積極的に行っています。
今回は、AmplifyでAppSyncのAPIキャッシュを使う方法について、調査を行いました。
概要
Amplify Gen2はAWSの新しいアプリ開発フレームワークです。Amplifyを使用すると、認証やホスティング、データベースとの通信部分をAWSサービスと統合することにより、効率的なアプリ開発を行うことができます。
以前のAmplify(Gen1)はツールファーストでしたが、Amplify Gen2は開発者の要望を受けて設計方針がコードファーストとなりました。
基礎機能は簡単に構築でき、CDKをベースとしたカスタマイズも可能になりました。
CDKの知識が必要になりましたが、エンジニアにとっては使いやすくなったと感じています。
Amplify Gen2のDataの概要
統合されたAppSync(GraphQL)を使用して、バックエンドとデータのやり取りを行う機能です。
通常、データベースを使いたい、となった時、データベースを構築し、接続文字列をシークレットに管理し、アプリケーションと接続を行う・・という手間が発生します。
Amplifyでは、アプリケーションの構築のコマンドを実行すると、10分もあれば、Auth(Amazon Cognito)と、このData(AppSync)が自動で構築され、認証ベースでデータベースと接続できるアプリケーションが完成します。
https://docs.amplify.aws/react/build-a-backend/data/
AppSyncは、AWSが提供するマネージドのGraphQLサービスです。GraphQLエンドポイントを経由し、データベースやAWSのクラウドサービスと接続できます。
AmplifyはRESTベースのAPIよりもこのGraphQL APIを推奨しています。
AppSyncのAPIキャッシュの概要
APIキャッシュは、AppSyncと統合されたサービスで、専用のKey-Value型のキャッシュサーバーを使用し、データベースへの頻繁なアクセスを抑止します。
クライアントからAppSyncを経由したデータの流れです。
まずはキャッシュサーバーが存在しない場合です。
1リクエスト毎に、リゾルバという機能を経由して、DynamoDBからデータを受け取り、クライアントに返却します。
毎回リクエストが発生するため、DynamoDBには、RCUという、読み取りのコストが発生します。
リクエスト数が多かったり、一度にDynamoDBから取得するデータ量が多い場合、月々の請求額が跳ね上がることになります。
次に、キャッシュサーバーが存在する場合です。
APIキャッシュがレスポンスを返すため、DynamoDBのコストが抑止できていることが解ります。
また、リゾルバ等を経由しない為、レスポンス速度の向上もメリットです。
APIキャッシュの注意点は、キャッシュのTTL(Time To Live)が1時間であることです。
そのため、24時間の高頻度アクセスが発生した場合、24回はデータベースへのアクセスが発生します。
また、APIキャッシュは、内部的にAmazon ElastiCacheのインスタンスを立てることで実現しているため、インスタンスのサイズに合わせた料金が発生します。
データベースのアクセスのコストと、ElastiCacheのコストを比較し、メリットがあるかどうかを把握することが必要です。
AppSyncのキャッシュについての詳細は、こちらのページに書かれています。
https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/enabling-caching.html
Amplify Gen2のDataに、APIキャッシュを設定する
APIキャッシュはAmplifyですぐに利用できるわけではありません。
カスタムリソースでキャッシュサーバーを作成し、Amplify Dataに紐づける必要があります。
今回もReactのプロジェクトで進めていきます。
QuickStartを参考にしつつ、amplify-vite-react-templateをローカルにcloneする所まで進めます。
https://docs.amplify.aws/react/start/quickstart/
amplify-vite-react-templateリポジトリが作成され、ToDoアプリケーションが構築されています。
今回はAWSリソースの書き換えが多数発生するので、SandBox環境を作成し、リソースを確認していきます。
npx ampx sandbox --identifier sample
作成されたリソースの確認
まず、amplify/data/resourc.tsを確認し、Dataのスキーマをチェックします。
省略されていますが、id型のid、そしてstring型のcontentで構成されたスキーマになります。
a.model()メソッドでスキーマを定義することで、Todoモデルに対するcreate,delete,update,listのメソッドを自動で生成します。
https://docs.amplify.aws/react/build-a-backend/data/set-up-data/
次に、キャッシュサーバーを定義します。backend.tsにCDKで定義します。
/amplify/backend.ts
new appsync.CfnApiCache(backend.data, "AmplifyGqlApiCache", {
apiCachingBehavior: "PER_RESOLVER_CACHING",
apiId: backend.data.apiId,
ttl: 3600,
type: "SMALL",
});
apiCachingBehaviorは、キャッシュ方法の選択です。
- FULL_REQUEST_CACHING:全てを自動でキャッシュする。一括キャッシュクリアができる。
- PER_RESOLVER_CACHING:getやlist等のリゾルバ毎にキャッシュを設定。一括・個別のキャッシュクリアができる。
今回はキャッシュの細かい制御を行うために、PER_RESOLVER_CACHINGを選択しています。
Getをキャッシュする
getとlist、それぞれに紐づくリゾルバにキャッシュを設定可能ですが、まずはgetにキャッシュを設定します。
AppSyncから、getのSchemaを確認します。
getTodoのリゾルバは、mappingTemplateに挟まれた、3つのリゾルバが連続する、パイプラインリゾルバとして構成されていることが解ります。
Amplifyのスキーマは、基本パイプラインリゾルバで構築されます。IaCならではの、複雑な構成が簡単に構築出来ていることが解ります。
次に、getTodoに対してキャッシュを紐づけます。
/amplify/backend.ts
backend.data.resources.cfnResources.cfnResolvers["Query.getTodo"].cachingConfig = {
cachingKeys: [
"$context.arguments.id",
],
ttl: 3600,
};
ttlのみでも、キャッシュ設定は完了します。
cachingKeysは、キャッシュの特定し・削除を行うためのキーとなるパラメータです。
Todoをgetする時は、「どのデータを取得したいか」というidを必ず入力するので、このidをキャッシュキーとして入力します。
今回は行いませんが、複数のcachingKeyを組み合わせることで、認証情報に基づいたパーソナルなキャッシュを作成することも可能です。
データが更新された時、キャッシュをクリアする
キャッシュのクリアを考慮します。
例えば、誰かが10時にデータの取得を行うと、11時までのキャッシュが有効になります。
10時30分に誰かがデータを更新して、その後10時45分に誰かがアプリケーションにアクセスすると、キャッシュは10時の時点の古いデータなので、最新のデータを受け取れないことになります。
対策として、AppSyncから提供されているevictFromApiCacheというメソッドを実行し、データの更新が行われた時にキャッシュをクリアするようにします。
クリアするタイミングは、createTodo、updateTodo、deleteTodoの処理後で、パイプラインリゾルバのresponseMappingTemplateに書くのが最も容易です。
なお、AmplifyはresponseMappingTemplateをVTLで書いているので、VTLとして挿入する必要があります。
この項目にキャッシュクリアのコマンドを追加するイメージです。
/amplify/backend.ts
backend.data.resources.cfnResources.cfnResolvers[
"Mutation.createTodo"
].responseMappingTemplate = [
`$extensions.evictFromApiCache('Query',"getTodo",{"context.arguments.id":$context.arguments.input.id})`,
`$util.toJson($ctx.prev.result)`,
].join("\\n");
キャッシュキーに、データ作成時のIDを設定するのがポイントで、idごとのキャッシュを作成することができます。
createTodoのみ設定していますが、実際はdeteleTodo、updateTodoにも設定することになります。
ここまでの、backend.tsの内容です。
/amplify/backend.ts
import { defineBackend } from "@aws-amplify/backend";
import { auth } from "./auth/resource";
import { data } from "./data/resource";
import * as AppSync from "aws-cdk-lib/aws-appsync";
const backend = defineBackend({
auth,
data,
});
new AppSync.CfnApiCache(backend.data, "AmplifyGqlApiCache", {
apiCachingBehavior: "PER_RESOLVER_CACHING",
apiId: backend.data.apiId,
ttl: 3600,
type: "SMALL",
});
backend.data.resources.cfnResources.cfnResolvers["Query.getTodo"].cachingConfig = {
cachingKeys: [
"$context.arguments.id",
],
ttl: 3600,
};
backend.data.resources.cfnResources.cfnResolvers[
"Mutation.createTodo"
].responseMappingTemplate = [
`$extensions.evictFromApiCache('Query',"getTodo",{"context.arguments.id":$context.arguments.input.id})`,
`$util.toJson($ctx.prev.result)`,
].join("\\n");
App.tsxを修正します。 作成の時に入力したIDを覚えていれば、データを取り出すことができます。
/src/App.tsx
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";
const client = generateClient<Schema>();
function App() {
function createTodo() {
const id = window.prompt("idを入力して下さい")!
const content = window.prompt("Todoを入力して下さい")!
client.models.Todo.create({ id, content });
}
async function getTodo() {
const data = await client.models.Todo.get({ id: window.prompt("idを入力して下さい")! });
alert(JSON.stringify(data))
}
return (
<main>
<button onClick={createTodo}>作成</button>
<button onClick={getTodo}>呼び出し</button>
</main>
);
}
export default App;
id=1で、Todoを追加してみます。この時、キャッシュがクリアされています。
呼び出しでid=1を指定すると、DynamoDBからデータを取得できました。この時、getTodoに対するid=1のキャッシュが作成されています。
キャッシュからデータが取得できることを確認するために、DynamoDBから直接レコードを削除します。DynamoDB経由のデータ更新は、AppSyncのキャッシュに作用しないので、キャッシュはそのままです。
再度id=1で取得を行い、キャッシュからデータを取得できました。
AppSyncのキャッシュメニューから、全てのキャッシュをクリアすると、AppSyncはDynamoDBへデータを参照するためキャッシュからデータが取得できなくなったことを確認できました。
Listをキャッシュする
listデータのキャッシュには幾つかのハードルがあります。
getのキャッシュはid毎に存在しましたが、listのキャッシュは基本一つです。そのため、キャッシュのクリアは、
- 予め、キャッシュクリア用のキーの文字列を決めておく
- listのAPIをリクエストをした時の入力パラメータに、キー文字列を含める
- createTodo等の更新APIをリクエストした時、そのキー文字列を使ってキャッシュクリアする
という流れになります。
しかし、listメソッドのパラメータは型定義されており、任意のキー文字列を設定できる余地がありません。
listのキャッシュデータも、更新時にはクリアしたいので、何とかキャッシュキーを設定したい所です。
もう一つはnextTokenの存在です。
DynamoDBからデータを取得する場合、DynamoDB側のクォータにより、一度のAPIリクエストで全てのデータを取得出来ない場合は、nextTokenが返却されます。
そのキーを使って、再度APIにリクエストすることで、次のページのデータを取得します。
このnextTokenは一時的な値の為、キャッシュのキーとして使うのに不向きなので、AppSyncのキャッシュ機能で、ページ毎にキャッシュするのは難しいです。
カスタムクエリの作成
一つの解決策として、listTodosをWrapするカスタムクエリを作成し、カスタムクエリに対してキャッシュを設定するようにしました。
カスタムクエリには任意のパラメータを設定できるので、他に影響の無いキャッシュキーとして利用可能です。
nextTokenについては、ページごとのキャッシュ保存を行わず、全件取得した結果をキャッシュするように修正しています。
Amplifyのカスタムクエリの詳細は、こちらのページに書かれています。
https://docs.amplify.aws/react/build-a-backend/data/custom-business-logic/
次に、リゾルバのLamdaを定義します。
先ほど生成したgraphQLのコードをリクエストに使用します。
スキーマやlistTodosのクエリはこの後生成するので、型エラーが発生する部分は、一時的にコメントアウトして下さい。
/amplify/data/resolvers/customListTodos/index.ts
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Schema } from "../../resource";
import { generateClient } from "aws-amplify/data";
import { env } from "$amplify/env/customListTodos";
import { Amplify } from "aws-amplify";
import { listTodos } from "../queries";
const graphQLEndpointKey: string = Object.keys(process.env).find((e) =>
e.includes("GRAPHQL_ENDPOINT"),
)!;
Amplify.configure(
{
API: {
GraphQL: {
defaultAuthMode: "identityPool",
endpoint: process.env[graphQLEndpointKey] ?? "",
region: env.AWS_REGION,
},
},
},
{
Auth: {
credentialsProvider: {
clearCredentialsAndIdentityId: () => {},
getCredentialsAndIdentityId: async () => ({
credentials: {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
sessionToken: env.AWS_SESSION_TOKEN,
},
}),
},
},
},
);
const dataClient = generateClient<Schema>();
export const handler: Schema["customListTodos"]["functionHandler"] = async (
event: any,
context: any,
) => {
let items: Schema["Todo"]["type"][] = [];
let nextPageToken: string | null = null;
try {
do {
const response: any = await dataClient.graphql({
query: listTodos,
variables: {
nextToken: nextPageToken,
},
});
items = [...items, ...response.data.listTodos.items];
nextPageToken = response.nextToken ?? "";
} while (nextPageToken);
} catch (e) {
console.log(e);
}
return items;
};
スキーマの定義を追加し、customListTodosを追加します。パラメータにcacheKeyを設定します。
/amplify/data/resource.ts
const schema = a
.schema({
Todo: a
.model({
content: a.string(),
})
.authorization((allow) => [allow.publicApiKey()]),
CustomListTodosResponse: a.customType({
id: a.string(),
content: a.string(),
}),
customListTodos: a
.query()
.arguments({
cacheKey: a.string(),
id: a.string(),
})
.returns(a.ref("CustomListTodosResponse").array())
.authorization((allow) => [allow.publicApiKey()])
.handler(a.handler.function(customListTodosHandler)),
})
.authorization((allow) => [
// functionへアクセス権限を付与
allow.resource(customListTodosHandler).to(["query"]),
]);
変更がSandBoxに反映されたら、LambdaからAppSyncのQueryを実行するために、graphQLのコードを生成します。
(amplify/data/resolversで実行)
npx ampx generate graphql-client-code --stack amplify-amplifyvitereacttemplate-sample-sandbox-xxxx
コード生成後、最初に作成したLambdaのコメントアウトを解除し、Lambdaからコードを参照できるようにして、SandBoxに反映を行って下さい。
次に、カスタムリソースの追加を行います。
- customListTodosへキャッシュを設定します。リクエスト時、{cacheKey:"customListTodosKey"}項目を含めます。
- createTodoがリクエストされた時、getTodoだけではなく、customListTodosもキャッシュクリアするよう追加しました。
/amplify/backend.ts
backend.data.resources.cfnResources.cfnResolvers[
"Query.customListTodos"
].cachingConfig = {
cachingKeys: ["$context.arguments.cacheKey"],
ttl: 3600,
};
backend.data.resources.cfnResources.cfnResolvers[
"Mutation.createTodo"
].responseMappingTemplate = [
`$extensions.evictFromApiCache('Query',"getTodo",{"context.arguments.id":$context.arguments.input.id})`,
`$extensions.evictFromApiCache('Query',"customListTodos",{"context.arguments.cacheKey":"customListTodosKey"})`,
`$util.toJson($ctx.prev.result)`,
]. join("\n");
最後にApp.tsxを修正し、customListTodosから取得したデータを表示します。
/src/App.tsx
import { useEffect, useState } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";
const client = generateClient<Schema>();
type Todo = Schema['CustomListTodosResponse']['type'];
function App() {
const [todos, setTodos] = useState<Todo[]>([]);
useEffect(() => {
async function fetchTodos() {
const { data } = await client.queries.customListTodos({
cacheKey: "customListTodosKey",
});
setTodos(
(data as Todo[])?.map((todo) => { return { id: todo.id!, content: todo.content! } })
)
}
fetchTodos();
}, []);
function createTodo() {
const id = window.prompt("idを入力して下さい")!
const content = window.prompt("Todoを入力して下さい")!
client.models.Todo.create({ id, content });
}
async function getTodo() {
const data = await client.models.Todo.get({ id: window.prompt("idを入力して下さい")! });
console.log(data)
alert(JSON.stringify(data))
}
return (
<main>
<button onClick={createTodo}>作成</button>
<button onClick={getTodo}>呼び出し</button>
<ul>
{todos.map((todo) => (<li>{todo.id}:{todo.content}</li>))}
</ul>
</main>
);
}
export default App;
動作チェックのために、データを3件ほど登録してみました。
3回createTodoを行ったので、キャッシュのクリアが三度行われ、その後ブラウザのリロードで、customListTodosで3件のデータを取得し、再度キャッシュが行われた状態です。
データベース上から2を削除してみましたが、キャッシュは残っているので変わらず3件を取得できています。
id=4を登録してみます。
createTodoによりキャッシュがクリアされ、 データベースから、2の削除と、4の追加が反映された最新のデータを取得できています。
まとめ
リゾルバ毎に細かいキャッシュ条件を設定し、データの更新に合わせてキャッシュをクリアできることを確認しました。
実運用では、更新頻度の高いデータと遅いデータでモデルにより差があるため、PER_RESOLVER_CACHINGを使うことが多いと予想されます。
DynamoDBのコスト節約策として検討できる有効な手法だと考えられます。
RECOMMEND
その他のコラム
相談無料!プロが中立的にアドバイスいたします
クラウド・AWS・Azureでお困りの方はお気軽にご相談ください。