NTT東日本の中村です。
昨年発表されたAmplify Gen2(プレビュー)ですが、日々機能が少しづつ追加されており、完成度が高まってきました。 今回はAmplify Data(AppSync)のモデルの調査と、動きの確認をしてみました。
Amplify Dataとは
Amplify Gen2で、AppSync(GraphQL)を使用して、バックエンドとデータのやり取りを行う機能です。
- 既存のAmplifyでは、データのやり取りはAmplify APIというカテゴリでまとめられており、AppSyncとREST API(Lambda+API Gateway)から選択する仕組みでした。
- Amplify Gen2では、AppSyncを使うものがAmplify Dataという名称になり、REST APIはCustom Resourceで構築してね、という仕組みに代わりました。
元々AmplifyはAppSync推しの雰囲気がありましたが、Gen2になり、よりAppSyncが全面に押し出されている印象です。
Gen2ではどのようにAppSyncにアプローチを行うのか、動作をチェックしてみました。
参考までに、Amplify Gen2のREST APIのページはこちらです。
最初の構築
いつも通り、NextJS(Server Components)で構築しています。
ドキュメントに従い進めますが、Amplify Authの認可機能を確認したいので、defaultAuthorizationModeをAPI_KEYからuserPoolに変更し、Todoスキーマのauthorizationをprivate(ログイン者のみ)に変更しました。
import { a, defineData, type ClientSchema } from '@aws-amplify/backend';
const schema = a.schema({
Todo: a
.model({
content: a.string(),
isDone: a.boolean()
})
.authorization([a.allow.private()]), // publicから、privateに変更
});
// Used for code completion / highlighting when making requests from frontend
export type Schema = ClientSchema<typeof schema>;
// defines the data resource to be deployed
export const data = defineData({
schema,
authorizationModes: {
defaultAuthorizationMode: 'userPool', // apiKeyから、userPoolに変更
// apiKeyAuthorizationMode: { expiresInDays: 30 } // 不要なのでコメントアウト
}
});
Amplify Authを使う場合、ConfigureAmplify.tsxの読み込みが必要です。下記のドキュメントに従って導入しました。
ユーザの作成も済ませておきます。
TodoList.tsxの内容は、/app/page.tsxに貼り付けました。
"use client";
import { useState, useEffect } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";
const client = generateClient<Schema>();
export function App({
searchParams,
}: {
searchParams?: { [key: string]: string };
}) {
const [todos, setTodos] = useState<Schema["Todo"][]>([]);
useEffect(() => {
const sub = client.models.Todo.observeQuery().subscribe({
next: ({ items }) => {
setTodos([...items]);
},
});
return () => sub.unsubscribe();
}, []);
const createTodo = async () => {
await client.models.Todo.create({
content: window.prompt("Todo content?"),
isDone: false,
});
// no more manual refetchTodos required!
// - fetchTodos()
};
return (
<div>
<button onClick={createTodo}>Add new todo</button>
<ul>
{todos.map(({ id, content }) => (
<li key={id}>{content}</li>
))}
</ul>
</div>
);
}
export default App;
以上で、ToDoアプリが作成され、ToDoの作成とリスト取得、SubScribeによるリアルタイムなデータの取得が完成します。
ここまで、GraphQLクエリの文字列を見ることなく完了しました。いわゆるGraphQLっぽさが無くなり、直感的で分かりやすくなったと感じました。
AppSyncのデータモデルの定義について
今日の話題の部分です。 既存のAmplifyでは、データモデルの定義はAmplify CLIを使用して、インタラクティブにフィールドの定義を行ったり、AWS Amplify GraphQL Transformer v2に従い、Schema.GraphQLをエディタで編集して定義を行っていました。
Amplify Gen2では、amplify/data/resource.tsに簡潔な記述をすることで、モデルを定義できるようになりました。 一見「Amplifyならではの知識」になってしまいそうなのですが、それを補って余りあるメリットがあり、AppSync導入のハードルが大きく下がると感じました。
先程の、モデル定義の部分ですが・・
const schema = a.schema({
Todo: a
.model({
content: a.string(),
isDone: a.boolean(),
})
.authorization([a.allow.private()]),
});
model()というメソッドで、フィールドの定義を行っています。Gen1の@modelディレクティブと同じ立ち位置のものですね。 sandboxを作成すると、AWS上にAppSync APIが作成され、スキーマ定義で下記が定義されることが分かります。
- Query:getTodo、listTodos(※Dynamo.Scanで実行)
- Mutation:createTodo、updateTodo、deleteTodo
- Subscription:onCreateTodo、onUpdateTodo、onDeleteTodo
resource.tsの記述から、ここまでが自動で生成されています。とても便利ですね!
フィールドの型、配列、requiredの定義も、フィールド定義にチェインさせることで分かりやすく記述できます。 困ったときはこのページを見るようにしています。
認可戦略
.authorization()をスキーマ定義にチェインさせることで、データアクセスの認可を設定できます。
- public(APIキー)
- owner(Cognito等のOIDCで、自分が作成したレコード)
- private(サインイン済のユーザのみ)
- group(Cognito等のOIDCで、自分が作成したグループ))
- custom(lambda等で認可ルールを作成)
となり、それぞれについてcreate, read, update,Deleteの権限を付与します。
例えば、
- Cognitoでサインインしたユーザは自分のレコードをcreate, read, update,Deleteできる
- Cognitoの「admin」グループは全てのレコードのreadとupdateができる
といった場合
.authorization([a.allow.owner(), a.allow.specificGroup("admin").to(['read', 'update']])
と記述できます。
owner()を使った場合、DynamoDBにはowner属性がリゾルバにより追加され、Cgnitoでは<sub>::<username>の形式の値が入ります。
注意点
ownerは、自分のレコードのownerの値を任意のものに指定して、「他人がownerのレコード」を作成できてしまいます。リゾルバでなんとかなりそうな気がするのですが・・・
現状は、ownerフィールドを明示的に定義し、authorizationを適用することで、ownerフィールドは作成者であっても作成・更新を禁止させる権限設定にします。 安全面を考えると、これは必須の設定だと感じました。
const schema = a.schema({
Todo: a
.model({
content: a.string(),
owner: a.string().authorization([a.allow.owner().to(['read', 'delete'])]),
})
.authorization([a.allow.owner()]),
});
関係のモデリング
ドキュメントに従い、1対多を表現してみます。
const schema = a.schema({
Member: a.model({
name: a.string().required(),
teamId: a.id(),
team: a.belongsTo('Team', 'teamId'),
})
.authorization([a.allow.private()]),
Team: a.model({
mantra: a.string().required(),
members: a.hasMany('Member', 'teamId'),
})
.authorization([a.allow.private()]),
});
2つのモデルを定義したため、DynamoDBのテーブルが2つ定義されています。
Formをgenerareして、レコードの登録を行おうとしたのですが、フォームは関係性までは考慮されていないため、適切なレコードを作成できませんでした。
そのため、ドキュメントの通りにレコードの作成と表示を行います。 ボタンを押すと、チームAと、チームAに紐づくメンバーを二人登録します。
画面をリロードすると、Teamモデルのレコードをリストで取得し、それぞれのmembers関数を実行することで、Teamに紐づくMemberを取得することができます。
"use client";
import { useState, useEffect, Fragment } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";
const client = generateClient<Schema>();
interface Team {
team: Schema["Team"];
members: Schema["Member"][];
}
export function App({
searchParams,
}: {
searchParams?: { [key: string]: string };
}) {
const [teams, setTeams] = useState<Team[]>([]);
useEffect(() => {
(async () => {
const teamList: Team[] = [];
const teamRecord = (await client.models.Team.list()).data;
for (const team of teamRecord) {
const members = (await team.members()).data;
teamList.push({ team, members });
}
setTeams(teamList);
})();
}, []);
const onClick = async () => {
const { data: team } = await client.models.Team.create({
mantra: "チームA",
});
await client.models.Member.create({
name: "山田さん",
team,
});
await client.models.Member.create({
name: "鈴木さん",
team,
});
};
return (
<div>
<button onClick={onClick}>Create Team</button>
<ul>
{teams.map(({ team, members }) => (
<Fragment key={team.mantra}>
<li>{team.mantra}</li>
<ul>
{members.map(({ name }) => (
<li key={name}>{name}</li>
))}
</ul>
</Fragment>
))}
</ul>
</div>
);
}
export default App;
チームAのレコード
メンバーから、鈴木さんのレコード
チーム名と、チームメンバー名を表示することができました。
depthの考慮
modelに対するgetやlistの戻り値の型を見ると、2つのモデルが循環参照している為、不必要にデータを取得してしまう可能性があります。本来は適切なdepthの設定を行い、どの階層までデータを取得するかの判断が必要になります。
既存のAmplifyのAmplify codegenコマンドでは、maxDepthを設定することで、階層を指定することができました。
現時点でのAmplify Gen2は、await team.members()のように、関係性のあるデータは明示的に関数を呼び出して 値を取得しており、際限なくデータを取得するようなことは防げているように思われます。
その他
データソースの変更について、調べてみました。 AppSyncは、GraphQLのデータソースにDynamoDB、Aurora Serverless、Httpリゾルバを使用することができ、既存のAmplifyでも一部対応が可能になっています。
Amplify Gen2でも行えるのかどうか、ドキュメントを確認しましたが、データソースを変更できるような記述が見当たりませんでした。少し前は、対応を予定しているような文言があったと思うのですが・・様子見としたいと思います。
まとめ
Amplify Gen2の新しいデータモデルの定義方法は、認可戦略等も含め、直感的に記述ができる、大変分かりやすいものになっていると感じました。REST APIに代わるデータアクセスのパターンとして考慮に値する機能だと思います。