Amplify Gen2のDataのモデル定義が分かりやすい
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に代わるデータアクセスのパターンとして考慮に値する機能だと思います。