こんにちは、広野です。
ある 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 でプロビジョニングしているのですが、本記事の構成が一式動くものはコード量が非常に大量になってしまうので、掲載は避けました。
本記事が皆様のお役に立てれば幸いです。