🔍

AWS CDK v2でAppSyncを使用したwebアプリを作成する

2024/11/14に公開

AppSyncを触る機会があったのですが、AWS CDK で作っていく際に v2 での実装事例があまり多く出てこなかったこともあるので、事例として Web アプリケーションを構築してみようと思いました。

題目としては Using an AWS AppSync API with the AWS CDK - AWS AppSync を参考にして、こちらの記事に従って機能とかを作っていきます。

(Amplify使えばいいじゃんというのは今回なしで、、)

前提

少し内容を端折ったりするので、下記の前提をおいています。

  • AWS CDK が何ができるかはわかる
  • AWS のサービスがある程度わかる
  • Web アプリについて少しわかる
    • GraphQL 少し知ってる

端折ったりしても参照情報は置くようにしますが、わかりにくかったらすいません。
ちなみにこの環境を検証するためにかかるコストは 0 円でした(無料枠の範囲内)。

検証環境

  • MacBook Air M1 (Sequoia 15.0.1)
  • Docker version 27.3.1, build ce12230 (最近は*OrbStack (1.7.5)*から使っていますのでそちらも導入前提で)
  • Node v20.10.0
  • aws-cli/2.18.15 Python/3.12.7 Darwin/24.0.0 source/arm64 (homebrew で入れてます)

想定する成果物と動作

作るのは掲示板みたいな Web アプリ(適当に sns-sapmle と命名します)で、下記のようなユースケースが実現できる物を作ります。

  • ユーザーはログインできる
  • ユーザーは内容を Post できる
  • ユーザーは Post 一覧を閲覧できる
  • ユーザーは別のユーザーの Post を閲覧できる
  • ユーザーは他のユーザーからメンションがきたらリアルタイムで通知が来る

実際に今回作成した AWS CDK と Web アプリケーションの実装は下記のリポジトリに置いてありますので、適宜参考にしていただければ幸いです。

https://github.com/okojomoeko/aws-cdk-appsync-webapp

アーキテクチャ

Web アプリを S3 にデプロイしてもいいのですが、AWS CDK で AppSync を利用する事例を作るのが目的なので、ローカルに立ち上げた検証環境で Web アプリの動作確認します。

アーキテクチャ図

ひとまずドキュメント通り AppSync で AWS CDK で使えるようにこなしていく

いきなり難易度を上がる感じがしますが、まずは公式がいい感じに AWS CDK で AppSync を使えるようにドキュメントを用意していますので、そちらを題材にしてプロジェクトの基礎を作っていきます。

最初に AWS CDK を使ったプロジェクトの準備とかありますが今回は省略します。
下記にプロジェクトのセットアップについて言及しているので参考にしてください。

今回のサンプル例では aws-cdk-appsync-webapp というプロジェクト名で作成します。

GraphQL スキーマの導入

GraphQL を扱う際に、まずは操作できるクエリとか型とかを定義する必要があります。

こちらの通り、作成された CDK プロジェクトに GraphQL スキーマを導入します。

schema/schema.graphql
input CreatePostInput {
  title: String
  content: String
}

type Post {
  id: ID!
  title: String
  content: String
}

type Mutation {
  createPost(input: CreatePostInput!): Post
}

type Query {
  getPost: [Post]
}

schema.graphql を作成したら、ドキュメント通り AWS CDK で AppSync の GraphQL API を定義します。フロントエンド側との接続については後述しますが、API のエンドポイント URL や API Key を出力しておきます。

lib/aws-cdk-appsync-webapp-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as appsync from 'aws-cdk-lib/aws-appsync';
import { Construct } from 'constructs';
export class AwsCdkAppsyncWebappStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // バックエンドのGraphQL APIを作成
    const api = new appsync.GraphqlApi(this, "SnsSapmleApi", {
      name: "appsync-webapp-api",
      schema: appsync.SchemaFile.fromAsset("schema/schema.graphql"),
    });
    // Prints out URL
    new cdk.CfnOutput(this, "GraphQLAPIURL", {
      value: api.graphqlUrl,
    });

    // Prints out the AppSync GraphQL API key to the terminal
    new cdk.CfnOutput(this, "GraphQLAPIKey", {
      value: api.apiKey || "",
    });

    // Prints out the stack region to the terminal
    new cdk.CfnOutput(this, "Stack Region", {
      value: this.region,
    });
  }
}

ドキュメントではこのまま AWS にデプロイしてしまっていますが、ひとまず作り上げることが目的なのでスルーします。

データソースの追加

GraphQL のスキーマで操作できるクエリとか型とか定義したら、 AppSync はどのデータに対して操作するのかをデータソース(Data Source)として定義します。

要件によってどんなデータソースを使うかは異なりますが、今回は DynamoDB を使うので、 DynamoDB のテーブルを作成します。

lib/aws-cdk-appsync-webapp-stack.ts
import * as cdk from 'aws-cdk-lib';
import * as appsync from 'aws-cdk-lib/aws-appsync';
+import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';
export class AwsCdkAppsyncWebappStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // バックエンドのGraphQL APIを作成
    const api = new appsync.GraphqlApi(this, "Api", {
    });
+    //creates a DDB table
+    const add_ddb_table = new dynamodb.Table(this, "SnsSapmleTable", {
+      partitionKey: {
+        name: "id",
+        type: dynamodb.AttributeType.STRING,
+      },
+    });

  }
}

リゾルバの追加

AppSync を使う際の最大の難所(だと思っている)のがリゾルバです。 ここまでで、GraphQL のスキーマを定義して(どんな API なのか)、データソースを定義した(どこに対してデータ操作したいのか)ので、どんなデータ操作するのかをリゾルバとして定義します。
下記一部省略しています。

lib/aws-cdk-appsync-webapp-stack.ts
+    // Creates a function for query
+    const add_func = new appsync.AppsyncFunction(this, "FuncGetPost", {
+      name: "get_posts_func_1",
+      api,
+      dataSource: api.addDynamoDbDataSource("TableForPosts", add_ddb_table),
+      code: appsync.Code.fromInline(`
+          export function request(ctx) {
+          return { operation: 'Scan' };
+          }
+
+          export function response(ctx) {
+          return ctx.result.items;
+          }
+      `),
+      runtime: appsync.FunctionRuntime.JS_1_0_0,
+    });
+        // Adds a pipeline resolver with the get function
+    new appsync.Resolver(this, "PipelineResolverGetPosts", {
+      api,
+      typeName: "Query",
+      fieldName: "getPost",
+      code: appsync.Code.fromInline(`
+          export function request(ctx) {
+          return {};
+          }
+
+          export function response(ctx) {
+          return ctx.prev.result;
+          }
+      `),
+      runtime: appsync.FunctionRuntime.JS_1_0_0,
+      pipelineConfig: [add_func],
+    });

一応の動作確認

ここまでで、AppSync の API を作成して、データソースを定義して、リゾルバを定義しました。
公式に従って、実際に cdk deploy してさくっと動作確認をします。

公式で説明している内容が実装と異なっている箇所があるので、そこだけ注意してください。
具体的には、type Postcontent: String でデプロイしたはずが、説明では date: AWSDateTime になっている点です。

AppSync の Queries から createPost を実行して、 DynamoDB にデータが挿入されているかを確認します。

AppSyncでMutation実行

DynamoDBテーブルのデータ確認
続いて、 getPost を実行して、 DynamoDB に挿入したデータが取得できるかを確認します。

AppSyncでQuery実行

以上で動作確認完了です。
cdk destroy して、デプロイしたリソースをひとまず削除します。

続いて、これをフロントエンドで使えるようにしていきます。

ドキュメントの仕様に沿ったフロントエンドの作成

公式では AWS CDK による AppSync のバックエンドを簡単に作ることを焦点にしていましたが、より実践的なユースケースとしてフロントエンドも作成します。まずは公式のバックエンドに沿ってフロントエンドを簡単に作り、その後独自の機能を追加実装します。

今回はフロントエンドも AWS CDK で作成して管理するので、Monorepo 構成で作成します。

技術スタックとしては Vite + React でシンプルに高速に作ります。

今までの AWS CDK のバックエンドを frontend_infra のディレクトリとして分けて、 npm create vite@latest frontend -- --template react-ts で Vite プロジェクトを作ります。
下記のディレクトリ構造とします。

.
├── frontend
│   .
│   ├── package-lock.json
│   ├── package.json
│   ├── public
│   ├── src
│   ├── tsconfig.node.json
│   └── vite.config.ts
└── frontend_infra
    .
    ├── package-lock.json
    ├── package.json
    ├── schema
    ├── test
    ├── test.eraserdiagram
    └── tsconfig.json

ログイン機能を作る

今回のユースケースというか題材が機能もりもりなので、ひとまず現状のバックエンドの構成に合わせて適当にフロントエンドを作り、ログイン機能をもたせます。

まずは AWS CDK で Cognito のユーザープールを作成します。ついでに後々フロントエンド側で使用する CognitoUserPoolIdCognitoUserPoolClientId を出力しておきます。

frontend_infra/lib/aws-cdk-appsync-webapp-stack.ts
export class AwsCdkAppsyncWebappStack extends cdk.Stack {
 constructor(scope: Construct, id: string, props?: cdk.StackProps) {
  super(scope, id, props);

+  const userPool = new cognito.UserPool(this, "CognitoUserPool", {});
+  const userPoolClient = new cognito.UserPoolClient(this, "CognitoUserPoolClient", {
+    userPool,
+   });
  // バックエンドのGraphQL APIを作成
  .
  .
+  new cdk.CfnOutput(this, "CognitoUserPoolId", {
+   value: userPool.userPoolId,
+  });
+
+  new cdk.CfnOutput(this, "CognitoUserPoolClientId", {
+   value: userPoolClient.userPoolClientId,
+  });

一旦これでデプロイすると Cognito のユーザープールが作成されるので、 AWS コンソールからお試しユーザーを作成してしまいます。

Cognitoのユーザー作成

User Pool にユーザーが作成されている状態

続いて、ログイン機能を簡単に作成するために今回は Amplify を利用します。
Amplify については深く触れませんが、Cognito を用いたユーザー認証したり GraphQL API の定義を生成するために使用したいので、 Gen2 ではなく Gen1 の機能を使います。
(自分がまだ Gen2 を触ったことないのでご容赦ください)

Amplify の UI framework を frontend に追加します。

npm install @aws-amplify/ui-react aws-amplify

あとは React のコンポーネントを作成するだけなのですが、Amplify が Cognito と接続するための設定をして、<App /> コンポーネントを wrap するだけで完了します。

Gen2 とかだと Amplify のプロジェクト設定して自動生成される設定ファイルを使うと思いますが、今回は情報をベタ書きで設定します。 CDK デプロイ時に出力した CognitoUserPoolIdCognitoUserPoolClientId を使って設定します。

frontend/src/App.tsx
+import { Authenticator } from "@aws-amplify/ui-react";
+import "@aws-amplify/ui-react/styles.css";
+Amplify.configure({
+  Auth: {
+    Cognito: {
+      userPoolId: <CognitoUserPoolId>,
+      userPoolClientId: <CognitoUserPoolClientId>,
+    },
+  },
+});

createRoot(document.getElementById("root")!).render(
  <StrictMode>
+    <Authenticator>
      <ThemeProvider theme={theme}>
        <CssBaseline />
        <App />
      </ThemeProvider>
+    </Authenticator>
  </StrictMode>
);

<App /> では、画面の綺麗さコードの詳細とかは今回省略しますがMUI を使って画面を作ります。

ログイン後の画面に Sign Out ボタンを作っておきましょう。その他のコンポーネントについては、後ほど詳細に実装するのでここでは説明しません。単純適当なものなので気になる人はレポジトリを参考にしていただければ。

frontend/src/App.tsx
import { useAuthenticator } from "@aws-amplify/ui-react";
import { Button } from "@mui/material";

function App() {
  const { signOut } = useAuthenticator();
  return (
    <Container maxWidth="sm">
      <Button onClick={signOut}>Sign out</Button>
      <Box sx={{ my: 4 }}>
        <Notification />
        <PostForm />
        <PostList />
      </Box>
    </Container>
  );
}

frontend ディレクトリで npm run dev を実行すると、ログイン画面が表示されます。

Amplifyによるログイン画面

先ほど Cognito で作成したユーザーでログインしてみて、下記のように「Sign Out」ボタン付きの画面が表示されたらログイン機能完成です。

Sign Outボタンがある状態

Post 投稿の API を実装してみる

ログインしてもサンプルのデータがあるだけなので、AppSync の API を叩くように実装します。

この API との接続について、AppSync の AWS コンソール上でアプリと統合の手順が記載されているので、これを参考に実施します。

AWSコンソールでデプロイされているAPIから確認

すでに Cognito との統合で Amplify.configure を設定しているので、追記する形で API を設定します。

AWS CDK で AppSync の API を作成したとき、すでに CfnOutput で API のエンドポイント URL と API Key を出力しているので、それを追記します。

frontend/src/App.tsx
Amplify.configure({
+  API: {
+    GraphQL: {
+      endpoint: <GraphQLAPIURL>,
+      region: 'ap-northeast-1',
+      defaultAuthMode: 'apiKey',
+      apiKey: <GraphQLAPIKey>,
+    }
+  }
});

あとは下記コマンドを frontend ディレクトリで実行して、フロントエンド開発に必要な型定義情報とか自動で出力します。

npx @aws-amplify/cli codegen add --apiId xxxxxxxxxxxxx
? Choose the type of app that you're building javascript
? What javascript framework are you using react
✖ Getting API details
AppSync API was not found in region us-east-1
? Do you want to choose a different region Yes
? Choose AWS Region Asia Pacific (Tokyo)
✔ Getting API details
? Choose the code generation language target typescript
? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.ts
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested] 2
? Enter the file name for the generated code src/API.ts
? Do you want to generate code for your newly created GraphQL API Yes

実行後に src/API.ts が生成されているので、これを使ってフロントエンド側で API を叩くように、まずは createPost から実装します。

下記は実装の一例ですが、 PostForm コンポーネントを作成して、フォームの入力値を createPost に渡しています。

frontend/src/PostForm.tsx
const client = generateClient();
const PostForm = () => {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");

  const handlePost = async () => {
    const result = await client.graphql({
      query: createPost,
      variables: { input: { content: content, title: title } },
    });
    setContent("");
    setTitle("");
  };

  return (
    <Container>
      <TextField label="Title" variant="filled" value={title} onChange={(e) => setTitle(e.target.value)}  />
      <TextField
        label="New Post"
        multiline
        maxRows={4}
        value={content}
        onChange={(e) => setContent(e.target.value)}
      />
      <Button onClick={handlePost}>Post</Button>
    </Container>
  );
};

export default PostForm;

試しにこれで npm run dev して、フォームに入力して投稿してみます。
デベロッパーツールで確認するとなんかいい感じに API を実行できているのが確認できます。

createPostが実行されていることを確認

DynamoDB にもデータが挿入されているのが確認できます。
DynamoDBテーブルにデータが登録されていることを確認

Post 一覧の API を実装してみる

本来であれば listPosts という API を実装するところですが、公式に従って実装した AWS CDK による AppSync のバックエンドでは、 getPost という query 名であるにも関わらず DynamoDB に対して Scan を実行しているので、これを叩くようにフロントエンドを実装します。

このとき、 Amplify のクライアントを React App のすべての場所で利用したいので、Context Provider として定義し直します。

frontend/src/contexts/AmplifyClientContext.tsx
import { generateClient } from "aws-amplify/api";
import { createContext, useContext } from "react";

type AmplifyClientType = ReturnType<typeof generateClient>;
export const AmplifyClientContext = createContext<AmplifyClientType | null>(null);
export const AmplifyClientProvider = ({ children: children }: { children: React.ReactNode }) => {
  const AmplifyClient = generateClient();
  return (
    <AmplifyClientContext.Provider value={AmplifyClient}>
      {children}
    </AmplifyClientContext.Provider>
  );
};

export const useAmplifyClient = () => {
  const context = useContext(AmplifyClientContext);
  if (!context) {
    throw new Error("useAmplifyClient must be used within a AmplifyClientProvider");
  }
  return context;
};

Context Provider の利用に伴い、既存の PostForm も Context として提供される AmplifyClient を利用するように修正します。

frontend/src/components/PostForm.tsx
+import { useAmplifyClient } from "../contexts/AmplifyClientContext";

-const client = generateClient();
const PostForm = () => {
+  const client = useAmplifyClient();
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");

PostList.tsx のコンポーネントに対して、 getPost を下記のように実装できます。

frontend/src/components/PostList.tsx
+import { getPost } from "../graphql/queries";
+import { useAmplifyClient } from "../contexts/AmplifyClientContext";
+import type { Post } from "../API";

const PostList = () => {
+  const client = useAmplifyClient();
  const [posts, setPosts] = useState<Post[]>([]);

+  useEffect(() => {
+    const posts = async () => {
+      const result = await client.graphql({
+        query: getPost,
+      });
+      setPosts(result.data?.getPost as Post[] || []);
+    };
+    posts().catch(console.error);
+  }, []);

これで初期レンダリング時に getPost を叩いて Post 一覧を取得して表示できるようになりました。

Post 追加するたびにブラウザをリロードして確認するのが面倒なので、更新ボタンも追加しています(getPost を叩くゴミ実装なのはサンプルなので許してください)。

下記のように、POST ボタンを押下して適当な投稿をしたあと、GET POSTS を押下して先ほど入力した Post が一覧に表示されることを確認します。

getPostの動作確認

残りの追加の API とフロントエンドの実装

ここまでで AWS CDK での AppSync バックエンドに従ったフロントエンド実装ができたので、次は独自の機能を追加していきます(やっと本番?)。

残りの機能は下記のとおりで、順番に実装します。

  • ユーザーは別のユーザーの Post を閲覧できる(listPostsByUser)
  • ユーザーは他のユーザーからメンションがきたらリアルタイムで通知が来る(subscribeToPostNotifications)

今回は amplify-cli を使ってフロントエンドの型定義とかを生成しているので、AWS CDK で AppSync のバックエンドに機能実装してからフロントエンド実装するという流れでやっていきます。

listPostsByUser を実装する

まずは GraphQL のスキーマから作ります。

Post の型に userId を追加して、さらに userId を input としてクエリできるように listPostsByUser を追加します。

frontend_infra/schema/schema.graphql
type Post {
  id: ID!
+  userId: String!
  title: String
  content: String
}

type Query {
  getPost: [Post]
+  listPostsByUserId(userId: String!): [Post]
}

続いて関数とリゾルバを追加・修正します。

DynamoDB のテーブル設計について触れていませんでしたが、今回のお題では Post の id がパーティションキーになっています。

そのため、このままでは userId を検索するときに Scan が毎回発生してしまうので、userId で DynamoDB にクエリできるようにuserId をパーティションキーとしたグローバルセカンダリインデックスを追加で作成します。(参考)

また、すでに例で作成している AWS CDK では、 JavaScript リゾルバを扱う際に Pipeline Resolver を使用して inline code で実装されています。しかし、Unit Resolver もサポートされているので、ここでは Unit Resolver を使用してコードを外部定義して実装する例にしたいと思います。

リゾルバのコードは TypeScript でも実装できますが、今回は JavaScript で実装します(こちらこちらが参考になります)。

frontend_infranpm install @aws-appsync/utils リゾルバ作成に必要なパッケージを追加します。

続いて src/resolvers/listPostsByUserId.js を作成して、リゾルバを実装します。

frontend_infra/src/resolvers/listPostsByUserId.js
import * as ddb from "@aws-appsync/utils/dynamodb";

// 公式ドキュメントでは、 TypeScript 実装の場合は Amplify を使って、
// 型定義情報を生成して ctx の型として利用しています
export function request(ctx) {
  const { limit = 10, nextToken, userId } = ctx.args;
  return ddb.query({
    index: "gsi-userId",
    limit: limit,
    query: {
      userId: { eq: userId },
    },
    nextToken: nextToken,
  });

}

export function response(ctx) {
  const { items: posts = [], nextToken } = ctx.result;
  return { posts, nextToken };
}

これを CDK で Unit Resolver として追加します。

frontend_infra/lib/aws-cdk-appsync-webapp-stack.ts
+    new appsync.Resolver(this, "UnitResolverListPostsByUserId", {
+      api,
+      typeName: "Query",
+      fieldName: "listPostsByUserId",
+      dataSource: snsSampleTableDs,
+      code: appsync.AssetCode.fromAsset("resolvers/listPostsByUserId.js"),
+      runtime: appsync.FunctionRuntime.JS_1_0_0,
+    });

ここまできたら cdk deploy してデプロイします。

バックエンドをデプロイしているので、あとはフロントエンド側で query を実装するために、frontendnpx @aws-amplify/cli codegen を実行して型定義情報を更新しておきます。

続いて既存の PostForm.tsx にて、 Post 時にユーザー ID を含めるように修正します。

frontend/src/components/PostForm.tsx
  const handlePost = async () => {
+    const userId = (await getCurrentUser()).userId;
    const result = await client.graphql({
      query: createPost,
+      variables: { input: { content: content, title: title, userId: userId } },
    });
    setContent("");
    setTitle("");
  };

新しく UI を作るのが面倒なので、検索結果については既存の PostList.tsx をそのまま使います。

テストで登録した DynamoDB のテーブルのデータは、userId がないので AWS コンソールから削除しておくと良いと思います。

まずは動作としてすでに作成しているユーザーで適当にいくつか Post します。

postしてuserIdが取得されていることを確認

続いて、AWS コンソール上の Cognito から新しく適当にユーザーを作成して、そのユーザーで再度サインインして Post してみます。
下記のような形で、異なるユーザーが Post されていることが確認できます。

別のuserIdでpostしていることを確認

それではいよいよ、User ID の Text Field に特定の User ID を入力してフィルタする形で Post 一覧を取得してみます。

下記が、新しく作ったユーザー(177~ のやつ)がログインして、既存のユーザーの Post を取得している様子です。

結果

subscribeToPostNotifications を実装する

ここまで来たら最後はリアルタイム通知の実装です。

簡単に実装するためにロジックは複雑にしませんが、 Post のコンテンツに @userId を含む場合は、その userId の人にリアルタイムで通知が来るようにします。

少し寄り道しますが、AppSync のクエリを実行する際に API キーによる認証を使っていました。このままでも良いのですが、実際の場面では AppSync の API は Cognito で認証されたユーザーからのみリクエストを受け付ければ良いので、API キーによる認証を Cognito による認証に変更します。
(GraphQL を保護するための認証と認証の設定 APIs - AWS AppSync)

余談ですが、公式ドキュメントから実装した GraphqlApi では schema 設定が deprecated だったのでついでに更新しています。

frontend_infra/lib/aws-cdk-appsync-webapp-stack.ts
  const api = new appsync.GraphqlApi(this, "SnsSapmleApi", {
   name: "appsync-webapp-api",
+   schema: appsync.Definition.fromFile("schema/schema.graphql"),
+   authorizationConfig: {
+    defaultAuthorization: {
+     authorizationType: appsync.AuthorizationType.USER_POOL,
+     userPoolConfig: {
+      userPool: userPool,
+     },
+    },
   },
  });

フロントエンド側の Amplify の設定も Cognito に変更しておきます。

frontend/src/App.tsx
Amplify.configure({
  API: {
    GraphQL: {
      endpoint: <GraphQLAPIURL>,
      region: 'ap-northeast-1',
-      apiKey: <GraphQLAPIKey>,
+      defaultAuthMode: "userPool",
    }
  }
});

これで API キーを利用せずに、 Cognito の User Pool で認証されたユーザーからのリクエスト API を叩けるようになりました。

本題に戻り、GraphQL の Subscription を実装します。

CreatePost の実行を subscribe し、その Post 内の toUserId が自分のものであればリアルタイムで通知するようにします。

frontend_infra/schema/schema.graphql
input CreatePostInput {
  userId: String!
  title: String
  content: String
+  toUserId: String
}

type Post {
  id: ID!
  userId: String!
  title: String
  content: String
+  toUserId: String
}
+type Subscription {
+  onCreatePost(toUserId: String!): Post
+  @aws_subscribe(mutations: ["createPost"])
+}

スキーマの定義自体はご覧の通り簡単です。 クライアント側でどのような情報を subscribe するのかが肝になるので、フロントエンド側を重点的に見ていきましょう。

まずは npx @aws-amplify/cli codegen を実行して、型定義情報を更新します。

続いて、バックエンド側で Cognito の User Pool で認証されたユーザーからのリクエストを受け付けるようにしているので、 generateClient に authMode: "userPool" を設定します。

また、今回 Subscription で利用する userId は PostForm のコンポーネントでも取得・使用しているので、共通化してコンテキストで管理してしまいます。

frontend/src/contexts/AmplifyClientContext.tsx
+type AmplifyClientContextType = {
+  AmplifyClient: AmplifyClientType;
+  userId: string;
+};
+export const AmplifyClientContext = createContext<AmplifyClientContextType | null>(
+  null
+);
export const AmplifyClientProvider = ({ children }) => {
+  const AmplifyClient = generateClient({
+    authMode: "userPool",
+  });
+  const [userId, setUserId] = useState("");
+  const [loading, setLoading] = useState(true);

+  useEffect(() => {
+    (async () => {
+      const user = await getCurrentUser();
+      setUserId(user.userId);
+      setLoading(false);
+    })();
+  }, []);

+  if (loading) {
+    return <div>Loading...</div>; // ローディング状態のUIを表示
+  }

  return (
+    <AmplifyClientContext.Provider value={{ AmplifyClient, userId }}>
      {children}
    </AmplifyClientContext.Provider>
  );
}

これで Web アプリにログインした後、 userId を取得して利用できるようになりました。

最後に、subscribe で通知バーが表示されるようにコンポーネントを作成します。

frontend/src/components/Notification.tsx
import { useEffect, useState } from "react";
import { Snackbar } from "@mui/material";
import { onCreatePost } from "../graphql/subscriptions";
import { useAmplifyClient } from "../contexts/AmplifyClientContext";

const Notification = () => {
  const {AmplifyClient, userId} = useAmplifyClient();
  const [mention, setMention] = useState<string | null>(null);

  useEffect(() => {
    const subscription = AmplifyClient.graphql({
        query: onCreatePost,
        variables: {
          toUserId: userId,
        },
      })
      .subscribe({
        next: ({ data }) => {
          const post = data.onCreatePost.content;
          const fromUserId = data.onCreatePost.userId;
          setMention(`New post from ${fromUserId}: ${post}`);
        },
        error: (error) => {
          console.warn("Subscription error:", error);
        },
      });

    // Cleanup the subscription on component unmount
    return () => {
      subscription.unsubscribe();
    };

  }, []);

  return (
    <Snackbar
      open={Boolean(mention)}
      message={mention}
      autoHideDuration={4000}
      onClose={() => setMention(null)}
    />
  );
};

export default Notification;

いい感じにフロントエンドも実装できたので、DynamoDB のテーブルデータを一旦削除して、2 画面でそれぞれ別のユーザーでログインしてリアルタイム通知の動作確認をします。

下記動作の様子になります。

Subscriptionの動作確認

ご覧の通り、相手のユーザーに対して @ でメンションを飛ばし、対象のユーザーが通知バーで内容が表示されることを確認できました。

さいごに

本記事では、AWS CDK v2 を使った AppSync の GraphQL API の実装事例を Web アプリケーションを題材として紹介しました。
題材としては AppSync の AWS CDK 実装の公式ドキュメントを基にしましたが、ログインやリアルタイム通知など現実の場で必要となる機能も併せて事例として紹介できたかなと思います。

AppSync のキャッシング、DynamoDB のテーブル設計などのパフォーマンスチューニングや、WAF の利用などのセキュリティ強化などが深掘りポイントとして考えられるので、誰かの参考になれば幸いです。

Discussion