Amplify Gen2のDataのモデル定義が分かりやすい

2024.04.19

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つ定義されています。

Gyazo

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のレコード Gyazo

メンバーから、鈴木さんのレコード Gyazo

チーム名と、チームメンバー名を表示することができました。 Gyazo

depthの考慮

modelに対するgetやlistの戻り値の型を見ると、2つのモデルが循環参照している為、不必要にデータを取得してしまう可能性があります。本来は適切なdepthの設定を行い、どの階層までデータを取得するかの判断が必要になります。

Gyazo

既存のAmplifyのAmplify codegenコマンドでは、maxDepthを設定することで、階層を指定することができました。

現時点でのAmplify Gen2は、await team.members()のように、関係性のあるデータは明示的に関数を呼び出して 値を取得しており、際限なくデータを取得するようなことは防げているように思われます。

その他

データソースの変更について、調べてみました。 AppSyncは、GraphQLのデータソースにDynamoDB、Aurora Serverless、Httpリゾルバを使用することができ、既存のAmplifyでも一部対応が可能になっています。

Amplify Gen2でも行えるのかどうか、ドキュメントを確認しましたが、データソースを変更できるような記述が見当たりませんでした。少し前は、対応を予定しているような文言があったと思うのですが・・様子見としたいと思います。

まとめ

Amplify Gen2の新しいデータモデルの定義方法は、認可戦略等も含め、直感的に記述ができる、大変分かりやすいものになっていると感じました。REST APIに代わるデータアクセスのパターンとして考慮に値する機能だと思います。