AWS AppSync + Amazon DynamoDB + React で簡易チャット WEB アプリをつくる

こんにちは、広野です。

ある WEB アプリを開発している過程で、おまけ機能として簡易チャット機能を組み込む機会がありました。抜粋になりますが主に設定等を紹介します。GraphQL のコード例が主になります。

やってみてですが、チャットアプリ開発は AWS AppSync の良い学習題材だと思いました。

つくったものの全体像

以下のようなアーキテクチャです。メイン機能のアーキテクチャは削除しています。

  • フロントエンドの開発フレームワークは React 18
  • Amazon S3 + Amazon CloudFront で WEB ホスティング
  • Amazon Cognito ユーザープールでアプリのログイン認証
  • チャットデータは Amazon DynamoDB に格納
  • フロントエンド UI とチャットデータのやり取りを AppSync が仲介

チャット機能の設計

チャット機能は以下のような設計にしています。

  • チャットアプリなので、ユーザ間でお互いのメッセージを共有する。
  • ただし、「サービスID」が同じユーザ間でのみ共有する。
  • 同じ「サービスID」の他のユーザが書き込みしたとき、自分のチャット画面が自動的に更新され、他人の書いたメッセージがすぐに表示される。AWS AppSync のプッシュ通知機能 (Subscription) を活用。
  • 用意した機能は「読取」以外では「書込」のみ。一度書いたメッセージの修正や削除は不可。(簡易チャット機能なので・・・ごめんなさい)
  • 表示メッセージ数は最新 30 件。(簡易チャット機能なので・・・ごめんなさい)

アプリ画面イメージ

こんな感じのチャット画面になっています。最新メッセージが上に追加されていきます。

静止画像では表現できず恐縮ですが、他ユーザが書き込むと自分の画面も更新されますのでリアルタイムな会話が可能です。

飾り気なくてすみません。おまけ機能なもので・・・。

 

実装

Amazon DynamoDB のテーブル設計

テーブルのアイテムとデータ例です。

serviceid
サービスID
[Partition Key]
datetimeuser
書込日時とユーザ名
[Sort Key]
datetime
書込日時
message
メッセージ
user
ユーザ名
AAA 2023-02-07T07:36:38.495Z#hirono 2023-02-07T07:36:38.495Z 見えたー hirono
AAA 2023-02-07T07:36:35.281Z#t.urano 2023-02-07T07:36:35.281Z 見えてる!!! t.urano
BBB 2023-02-08T08:02:56.112Z#tanaka 2023-02-08T08:02:56.112Z テスト tanaka
  • サービスID 単位でデータを取得するため、サービスID をパーティションキーにする。
  • 書込日時でデータを並べ替えしたいが、完全な同時刻に複数のユーザが書き込みするとデータが一意にならなくなるため、書込日時に # 区切りでユーザ名を付加したものをソートキーにする。
  • 日時データは ISO8601 フォーマットにした。JavaScript で扱いやすいため。

最低限必要なデータのみにしたため、シンプルでわかりやすいと思います。

AWS AppSync の設定

アプリからは以下 3 つのオペレーションを実行します。AWS AppSync は GraphQL という言語を使い、アプリ側では Query、Mutation、Subscription という 3 種類のクエリーが使用できます。AWS AppSync 側ではそれを受けるためのスキーマという設定が必要になります。

  • 指定したサービスID のデータを読み取り (Query)
  • 指定したサービスID にメッセージを書き込み (Mutation)
  • 上記 Mutation をトリガーに、プッシュ通知を送信 (Subscription)

スキーマ

スキーマとは、その AWS AppSync で取り扱うデータの定義をするようなものです。本記事のケースではかなりシンプルです。通常はさらに肥大化、複雑になります。(それが難点だが、Amazon API Gateway を利用する構成だと AWS Lambda 関数が大量に必要になるのも困りものなので、どっちもどっち。)

schema {
  query: Query
  mutation: Mutation
  subscription: Subscription
}
type Chatdata {
  serviceid: String
  datetimeuser: String
  datetime: String
  message: String
  user: String
}
type ChatdataConnection {
  items: [Chatdata]
  nextToken: String
}
type Mutation {
  putChatdata(input: PutChatdataInput!): Chatdata
}
type Query {
  queryChatdataByServiceid(serviceid: String!): ChatdataConnection
}
type Subscription {
  onPutChatdata(serviceid: String!): Chatdata
    @aws_subscribe(mutations : ["putChatdata"])
}
input PutChatdataInput {
  serviceid: String!
  datetimeuser: String!
  datetime: String!
  message: String!
  user: String!
}
  • type Chatdata は DynamoDB テーブルの項目と合わせておく。
  • type ChatdataConnection は DynamoDB テーブルへの接続を定義する。別途、データソースの設定で DynamoDB テーブルを登録しておく。
  • type Mutation でチャットデータをどこに書き込むか定義する。
  • input PutChatdataInput で書き込む項目を定義する。他の定義にもあるが、”!” は必須項目であることを意味する。
  • type Query でチャットデータをどこから読み取るか定義する。ここでは、serviceid を引数として渡し、ChatdataConnection からデータを取得する。
  • type Subscription で、アプリへのプッシュ通知を定義する。Subscription はペアとなる Mutation がないと機能しない。ここでは、putChatdata の Mutation とペアにする onPutChatdata の Subscription を定義している。つまり、putChatdata が呼び出されると、onPutChatdata が発動することになる。ここで重要なのは、Mutation と引数を同じにしておくこと。ここでは serviceid が引数となっている。

このスキーマ定義は非常にわかりにくいですが、わかってくるとコピペでサクサク作れるようになります。

リゾルバ

リゾルバとは、スキーマで定義した Query、Mutation、Subcription に対応する処理を定義 (マッピングと呼ばれる) するものです。例えば、データソース (ここでは DynamoDB テーブル) に対してデータをやり取りするためのクエリーを書きます。そのため、AWS AppSync ではリゾルバで定義できる範囲であれば Amazon DynamoDB にクエリーするための AWS Lambda 関数が不要です。

Query (queryChatdataByServiceid)

serviceid を引数として受け取り、それと一致するパーティションキーのデータを全て取得するクエリーです。本記事のケースでは最大 30 件を取得するように limit が定義されています。このリゾルバにより、指定されたサービスID に所属しているユーザは他ユーザのメッセージも含め全て取得できます。

  • リクエストマッピング
{
  "version": "2018-05-29",
  "operation": "Query",
  "query": {
    "expression": "#serviceid = :serviceid",
    "expressionNames": {
      "#serviceid": "serviceid"
    },
    "expressionValues": {
      ":serviceid": $util.dynamodb.toDynamoDBJson($context.arguments.serviceid)
    }
  },
  "limit": $util.defaultIfNull($context.arguments.first, 30),
  "nextToken": $util.toJson($util.defaultIfNullOrEmpty($context.arguments.after, null)),
  "scanIndexForward": false,
  "consistentRead": false,
  "select": "ALL_ATTRIBUTES"
}
  • レスポンスマッピング
$utils.toJson($context.result)
Mutation (putChatdata)

書き込むデータを 1 件、serviceid を含め引数として受け取り、DynamoDB テーブルに PutItem で書き込みます。serviceid はパーティションキー、datetimeuser はソートキーなので明示的に引数を個別に定義していますが、それ以外の属性は attributeValues でまとめて渡されたものをそのまま書き込む仕様で定義されています。

  • リクエストマッピング
{
  "version": "2018-05-29",
  "operation": "PutItem",
  "key": {
    "serviceid": $util.dynamodb.toDynamoDBJson($ctx.args.input.serviceid),
    "datetimeuser": $util.dynamodb.toDynamoDBJson($ctx.args.input.datetimeuser)
  },
  "attributeValues": $util.dynamodb.toMapValuesJson($ctx.args.input),
  "condition": {
    "expression": "attribute_not_exists(#serviceid) AND attribute_not_exists(#datetimeuser)",
    "expressionNames": {
      "#serviceid": "serviceid",
      "#datetimeuser": "datetimeuser",
    }
  }
}
  • レスポンスマッピング
$util.toJson($context.result)
Subscription (onPutChatdata)

Subscription にはリゾルバがありません。データソース (ここでは DynamoDB テーブル) に何かすることがないためです。この Subscription をサブスクライブしているユーザに、対応する Mutation に関するプッシュ通知を送ることになります。

スキーマ、リゾルバ以外にも設定はありますが、難しくないので割愛します。

React コード

 Amazon DynamoDB、AWS AppSync が出来上がれば、適切にフロントエンドアプリのコードを書けば動作します。

 

必要モジュール

必要モジュールをインストールします。(npm の例) 本記事で抜粋した機能以外に必要なモジュールは省略しています。UI パーツとして MUI を使用していますので、それらモジュールも入れています。

npm install --save aws-amplify date-fns graphql-tag @mui/material @mui/icons-material @emotion/react @emotion/styled

 

AWS AppSync 連携用設定

App.js 等、上位のコンポーネントに記述しておくと子、孫コンポーネントでも設定が有効になります。

  • App.js
import React from 'react';
import Amplify from 'aws-amplify';

//Cognito, AppSync 連携設定
Amplify.configure({
  Auth: {
    region: process.env.REACT_APP_REGION,
    userPoolId: process.env.REACT_APP_USERPOOLID,
    userPoolWebClientId: process.env.REACT_APP_USERPOOLWEBCLIENTID
  },
  aws_appsync_graphqlEndpoint: process.env.REACT_APP_APPSYNC,
  aws_appsync_region: process.env.REACT_APP_REGION,
  aws_appsync_authenticationType: "AMAZON_COGNITO_USER_POOLS",
  aws_appsync_apiKey: "null"
});

上記コードでは、Amazon Cognito との連携設定も Auth: に記載しています。私がつくった環境には Amazon Cognito のアプリ認証があり、かつ AWS AppSync へのアクセスの認証を Amazon Cognito にしているので、aws_appsync_authenticationType を固定値で “AMAZON_COGNITO_USER_POOLS” に、そして API キーは使用しないので aws_appsync_apiKey を固定値で “null” にしています。

Amazon Cognito との連携設定も同じく Amplify.configure の中で行います。それもセットで行えば、AWS AppSync への認証設定はこれだけで自動的に裏で処理してくれます。(楽すぎます)

その他パラメータは process.env.REACT_APP_ から始まる環境変数を当て込んでいます。AWS CloudFormation で環境をプロビジョニングしてできたリソース情報をアプリに環境変数として渡すためそのようなコードになっていますが、実際には以下のような値が入ることを紹介しておきます。

パラメータ 値 (例)
region ap-northeast-1
userPoolId ap-northeast-1_xxxxxxxxx
userPoolWebClientId xxxxxxxxxxxxxxxxxxxxxxxxxx (26 桁の英数字)
aws_appsync_graphqlEndpoint https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql
(xxxxxxxxxxxxxxxxxxxxxxxxxx は 26桁の英数字)
aws_appsync_region ap-northeast-1

 

チャット画面コード

解説はインラインで書きます。GraphQL のクエリーを書く必要があります。

  • Chat.js
import React, { useState, useEffect } from 'react';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import Divider from '@mui/material/Divider';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import SendIcon from '@mui/icons-material/Send';
import { API } from 'aws-amplify';
import gql from 'graphql-tag';
import { format } from 'date-fns';

const Chat = (props) => {
  //定数定義 これらは親コンポーネントから渡されるものです。
  const username = props.username;
  const serviceid = props.serviceid;

  //state初期化
  const [message, setMessage] = useState("");
  const [chatdata, setChatdata] = useState();
  
  //メッセージ取得クエリー
  //queryChatdataByServiced リゾルバを呼び出します。引数として serviceid を渡します。
  //レスポンスに欲しいアイテムを、items 以下に記述します。
  const queryGetChat = gql`
    query queryChatdataByServiceid($serviceid: String!) {
      queryChatdataByServiceid(serviceid: $serviceid) {
        items {
          datetime
          user
          message
        }
      }
    }
  `;
  //メッセージ取得関数
  //上に定義したクエリーを AWS AppSync に投げます。
  const getChat = async () => {
    const res = await API.graphql({
      query: queryGetChat,
      variables: {
        serviceid: serviceid
      },
      authMode: "AMAZON_COGNITO_USER_POOLS"
    });
    //レスポンスを res 定数で受け取ると、以下に表現した階層構造でデータを受け取れます。
    setChatdata(res.data.queryChatdataByServiceid.items);
  };
  //メッセージ登録クエリー
  //queryPutChat リゾルバを呼び出します。引数として、DynamoDB テーブルの項目を渡します。
  //ここで重要なのが、serviceid を最後にポツンと定義しているところです。ここは、Subscription と合わせる必要があります。
  const queryPutChat = gql`
    mutation putChatdata(
      $serviceid: String!,
      $datetimeuser: String!,
      $datetime: String!,
      $message: String!,
      $user: String!
    ) {
      putChatdata(input: {
        serviceid: $serviceid,
        datetimeuser: $datetimeuser,
        datetime: $datetime,
        message: $message,
        user: $user
      }) {
        serviceid
      }
    }
  `;
  //メッセージ登録関数
  //上に定義したクエリーを AWS AppSync に投げます。
  const putChat = async () => {
    const dt = (new Date()).toISOString();
    await API.graphql({
      query: queryPutChat,
      variables: {
        serviceid: serviceid,
        datetimeuser: dt + "#" + username,
        datetime: dt,
        message: message,
        user: username
      },
      authMode: "AMAZON_COGNITO_USER_POOLS"
    });
    //メッセージ入力欄クリア
    setMessage("");
  };
  //メッセージ更新サブスクライブクエリー
  //自分/他人のユーザ問わず、同じ serviceid のメッセージが DynamoDB テーブルに登録されたら
  //プッシュ通知を受け取るためのクエリーです。最後にポツンと serviceid が定義されていますが、
  //Mutation の定義と合わせる必要があります。
  const querySubscribePutChat = gql`
    subscription onPutChatdata($serviceid: String!) {
      onPutChatdata(serviceid: $serviceid) {
        serviceid
      }
    }
  `;
  //メッセージ更新サブスクライブ関数
  //この関数は他2つと異なり、コンポーネントが実行されたときに最初に実行し、常駐するものです。
  //上に定義したクエリーを AWS AppSync に投げます。
  const subscribePutChat = async () => {
    const subscription = await API.graphql({
      query: querySubscribePutChat,
      variables: {
        serviceid: serviceid
      },
      authMode: 'AMAZON_COGNITO_USER_POOLS'
    }).subscribe({
      next: (response) => {
        //サブスクリプション通知時のアクション = メッセージ表示欄を更新
        //ここで、通知を受けたときのアクションを定義することができます。
        if (response.value.data.onPutChatdata.serviceid === serviceid) {
          getChat();
        }
      },
      error: (err) => {
        console.log(err);
      }
    });
    return () => subscription.unsubscribe();
  };
  //コンポーネント表示時に実行
  useEffect(() => {
    //メッセージ取得 画面表示時点のメッセージデータを取得します。
    getChat();
    //サブスクリプション実行 これにより、DynamoDB テーブルへのデータ書込のプッシュ通知を受けられる状態にします。
    subscribePutChat();
  }, []);

  //以下、画面表示のコード
  return (
    <Box sx={{p:2}}>
      <Typography variant="body2" component="h3" style={{fontWeight:"bold"}}>Messages</Typography>
      <Divider sx={{my:1}} />
      <Grid container spacing={2}>
        {/* Messages */}
        <Grid item xs={12} md={6} lg={12}>
          {chatdata?.map((row) => (
            <React.Fragment>
              <Stack direction="row" justifyContent="flex-start" alignItems="flex-start" spacing={1}>
                <Typography variant="caption" component="span" color="text.secondary">{row.user.split("@")[0]}</Typography>
                <Typography variant="caption" component="span" color="text.secondary">{format(Date.parse(row.datetime),"yyyy-MM-dd HH:mm")}</Typography>
              </Stack>
              <Typography variant="caption" component="div" sx={{mb:1}} style={{whiteSpace:"pre-line"}}>{row.message}</Typography>
              <Divider sx={{my:1}} />
            </React.Fragment>
          ))}
        </Grid>
        {/* Chat form */}
        <Grid item xs={12} md={6} lg={12}>
          <TextField id="message" placeholder="Type message" value={message} onChange={e => setMessage(e.target.value)} size="small" multiline minRows={1} fullWidth />
          <Button
            onClick={() => putChat()}
            variant="contained"
            size="small"
            color="success"
            sx={{my:2}}
            disabled={(message) ? false : true}
            startIcon={<SendIcon />}
          >
            コメント
          </Button>
        </Grid>
      </Grid>
    </Box>
  );
};

export default Chat;

正常にビルド、デプロイできれば以下のようなチャット画面が出来上がります。

 

まとめ

いかがでしたでしょうか?

当初は AWS AppSync というよりは GraphQL の理解に苦しみました。が、理解してしまえばそれまでの苦労は忘れてしまうもので、プッシュ通知の可能性を考えると、今後積極的に AWS AppSync を活用していきたいと思いました。

環境は AWS CloudFormation でプロビジョニングしているのですが、本記事の構成が一式動くものはコード量が非常に大量になってしまうので、掲載は避けました。

本記事が皆様のお役に立てれば幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャを駆使して社内クラウド人材育成アプリとコンテンツづくりに勤しんでいます。React で SPA を書き始めたら快適すぎて、他の言語には戻れなくなりました。サーバーレス & React 仲間を増やしたいです。AWSは好きですが、それよりもAWSすげー!って気持ちの方が強いです。
取得資格:AWS 認定は12資格、ITサービスマネージャ、ITIL v3 Expert 等
2020 - 2023 Japan AWS Top Engineer 受賞
2022 - 2023 Japan AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS Amplify / AWS AppSync / Amazon Cognito / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする
クラウドに強いによるエンジニアブログです。
SCSKは専門性と豊富な実績を活かしたクラウドサービス USiZE(ユーサイズ)を提供しています。
USiZEサービスサイトでは、お客様のDX推進をワンストップで支援するサービスの詳細や導入事例を紹介しています。
AWSアプリケーション開発クラウドソリューションデータベース
シェアする
タイトルとURLをコピーしました