AWS AppSync Chat Starterを研究してみる

2019.02.10

はじめに

以前このAppSync Chat Starterを実際に動かしてみてAWS Amplifyでのバックエンドの構築や、AWS AppSyncを使ったGraphQL APIの作成を試してみた。

AWS AppSync Chat Starterを試してみる

その後、AmplifyやAppSyncについてそれぞれじっくり勉強して、AppSyncを使って実際にアプリケーションを設計する段階に来たので改めてChatQLのコードを追っていき、Apollo Clientを使ったAppSyncの呼び出し方を学ぼうと思う。

ChatQLとは?

ChatQL: An AWS AppSync Chat Starter App written in Angular

ChatQLとは、AWSが提供しているサンプルアプリケーション。AngularでAWS AppSyncのAPIにアクセスする。ユーザー認証はAmazon Cognitoを、アプリケーションのデータ保存はAmazon DynamoDBを使っている。

それらのバックエンドをコード管理して、ローカルのCLIから立ち上げたり、変更するのにAWS Amplifyを使っている。

環境

  • macOS Mojave 10.14.2
  • iTerm
  • anyenv
  • ndenv 0.4.0-4-ga339097
  • Node.js v10.4.1
  • @angular/cli 7.0.3
  • @amplify/cli 0.1.44

Amplify CLIからバックエンドを操作するためのAPIキーを作成する

AWSマネジメントコンソールにログインして、新しくIAMユーザーを作成する。権限はAdministratorAccessをアタッチした。

ChatQLをクローンする

まずはアプリケーションをクローンする。



# プロジェクトをクローン
$ git clone git@github.com:aws-samples/aws-mobile-appsync-chat-starter-angular.git

# ディレクトリに入る
$ cd aws-mobile-appsync-chat-starter-angular

Amplifyを初期化する

AWS Amplifyでバックエンドを構築する準備を行う。対話形式でAmplifyが動作するのに必要な情報を指定していく。



$ amplify init
Note: It is recommended to run this command from the root of your app directory

# プロジェクト名を指定(任意)
? Enter a name for the project chatql

# エディタを指定
? Choose your default editor:
  Sublime Text
  Visual Studio Code
  Atom Editor
  IDEA 14 CE
  Vim (via Terminal, Mac OS only)
  Emacs (via Terminal, Mac OS only)
❯ None

# 開発しているプラットフォームを指定
? Choose the type of app that you're building (Use arrow keys)
  android
  ios
❯ javascript

# 開発に使っているフレームワークを指定
? What javascript framework are you using (Use arrow keys)
❯ angular
  ember
  ionic
  react
  react-native
  vue
  none

# ソースコードのディレクトリを指定
? Source Directory Path:  (src)

# 配布用アプリケーションが生成されるディレクトリを指定
? Distribution Directory Path: (dist)

# ビルドコマンドを指定
? Build Command:  (npm run-script build)

# 開発用サーバー起動コマンドを指定
? Start Command: (ng serve)

# AWSへのアクセスにプロファイルを使うか指定(今回は使う)
? Do you want to use an AWS profile? (Y/n) Y

# 使用するプロファイルを選択(~/.awsにある設定がリストアップされるので選択肢は各環境による)
? Please choose the profile you want to use
  profile01
  profile02
❯ amplify

# CloudFormationが動き出してバックエンドの構築が始まる
⠋ Initializing project in the cloud...

必要なnpmパッケージをインストールする



$ npm install

Amplifyの「auth」をセットアップする

AWS Amplifyでは、$ amplify add <追加する機能>で様々な機能をアプリケーションに追加することができる。$ amplify add authで認証機能を追加すると、バックエンドではAmazon CognitoのユーザープールとIDプールが立ち上がる。



$ amplify add auth

Using service: Cognito, provided by: awscloudformation
 The current configured provider is Amazon Cognito.

# デフォルトの設定を利用するか指定する
 Do you want to use the default authentication and security configuration? (Use arrow keys)
❯ Yes, use the default configuration.
  No, I will set up my own configuration.
  I want to learn more.

デフォルトの設定を利用する場合、他に指定することは特にない。デフォルトの設定を利用しない場合は、MFA(多要素認証)を利用するかどうかや、パスワードの強度などを自分で設定できる。

Amplifyの「analytics」をセットアップする

同様に$ amplify add analyticsで分析機能を追加する。



$ amplify add analytics

Using service: Pinpoint, provided by: awscloudformation

# Pinpointのプロジェクトを作成する
? Provide your pinpoint resource name: (chatql)

# 未認証のユーザーからのイベントを収集するか指定する
Adding analytics would add the Auth category to the project if not already added.
? Apps need authorization to send analytics events. Do you want to allow guests and unauthenticated users to send anal
ytics events? (we recommend you allow this when getting started) (Y/n)

Amplifyのステータスを確認する

AWS Amplifyでは、$ amplify add <機能名>を行うだけではバックエンドはまだ変更されない。一通りローカルで変更した後、$ amplify pushを実行することでバックエンドに設定が反映される。反映する設定があるかどうかは$ amplify statusコマンドを使うことで確認できる。



$ amplify status
| Category  | Resource name   | Operation | Provider plugin   |
| --------- | --------------- | --------- | ----------------- |
| Auth      | cognito991b2f41 | Create    | awscloudformation |
| Analytics | chatql          | Create    | awscloudformation |

バックエンドを構築する

ローカルで設定したバックエンドの情報を実際に適用する。



$ amplify push
| Category  | Resource name   | Operation | Provider plugin   |
| --------- | --------------- | --------- | ----------------- |
| Auth      | cognito991b2f41 | Create    | awscloudformation |
| Analytics | chatql          | Create    | awscloudformation |
? Are you sure you want to continue? (Y/n)

AppSyncのAPIを作成する

ChatQLでは、AppSyncのAPIはAmplifyを使わずに構築して、後からAmplifyに関連づける方法を取るらしい。AWSマネジメントコンソールからAppSync APIを構築する方法と、AWS CLIからCloudFormationを呼び出して構築する方法がある。

前回はマネジメントコンソールからAppSyncのAPIを作成したので、今回はAWS CLIからCloudFormationを操作する方法を試してみる。



# ユーザープールID・リージョン・プロファイルは各自の環境に合わせる
$ aws cloudformation create-stack --stack-name ChatQL --template-body file://backend/deploy-cfn.yml --parameters ParameterKey=userPoolId,ParameterValue=<CognitoユーザープールID> --capabilities CAPABILITY_IAM --region <リージョン> --profile <プロファイル名>

作成したAppSync APIのIDをAWS CLIを使って手に入れる。



# リージョン・プロファイルは各自の環境に合わせる
$ aws cloudformation describe-stacks --stack-name ChatQL --query Stacks[0].Outputs --region <リージョン> --profile <プロファイル名>

[
    {
        "Description": "The Endpoint URL of your GraphQL API.",
        "OutputKey": "ChatQLApiUrl",
        "OutputValue": "https://XXXXXXXX.appsync-api.ap-northeast-1.amazonaws.com/graphql"
    },
    {
        "Description": "Unique AWS AppSync GraphQL API Identifier",
        "OutputKey": "ChatQLApiId",
        "OutputValue": "XXXXXXXXXX" // ←これを控える
    }
]

最後に、CloudFormationで作成したAppSync APIをローカルのプロジェクトと紐付ける。



# APIのIDは一つ前の手順で控えた文字列
$ amplify add codegen --apiId <APIのID>

✔ Getting API details
Successfully added API ChatQL to your Amplify project

# 生成するコードの対象となるプラットフォームを指定する
? Choose the code generation language target (Use arrow keys)
❯ angular
  typescript

# GraphQLのクエリファイルを配置するパスを指定する
? Enter the file name pattern of graphql queries, mutations and subscriptions (src/graphql/**/*.graphql)

# GraphQLのコードを一通り生成するか指定する
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions (Y/n)

# Angular向けのサービスの配置場所を指定する
? Enter the file name for the generated code src/app/API.service.ts

# 詳細不明
? Do you want to generate code for your newly created GraphQL API (Y/n)

もともとChatQLのコードには必要なGraphQLのクエリ等が入っているので、$ amplify add codegenを行ってもsrc/API.service.tsが生成される程度の変更しか行われない。

アプリケーションを動かしてみる



$ ng serve

サーバーが立ち上がったら、ブラウザで http://localhost:4200/ を開く。

AWS AppSync Chat Starterを研究してみる

基本的な機能を使ってみる

ユーザー登録機能でユーザーを登録する

  • ユーザー名
  • メールアドレス
  • パスワード
  • 電話番号

を入力してユーザーを作成することができる。デフォルトの設定でAmazon Cognitoを設定したが、デフォルトではパスワードは大文字を含むアルファベット、数字、記号を使った6文字以上が必要となる。

AWS AppSync Chat Starterを研究してみる

MFA(多要素認証)でメールアドレスの開通確認を行う

ユーザー登録を行うと、入力したメールアドレスにメールで認証コードが送信される。この認証コードを入力することでアカウント作成が完了し、ログインできるようになる。メールアドレスが有効なものか検証することができる。

AWS AppSync Chat Starterを研究してみる

AWS AppSync Chat Starterを研究してみる

ログインする

認証が完了したら、登録した情報でログインしてみる。まだユーザーが一人しかいないので、チャットは開始できない模様。

AWS AppSync Chat Starterを研究してみる

このユーザーはGoogle Chromeで作成したので、Safariを立ち上げて別のユーザーを作成してみる。

他のユーザーとのチャットを開始する

他のユーザーを作成すると、メイン画面の左側のユーザー一覧に現れる。これをクリックするとメッセージルームのようなものが出来上がった。

試しにメッセージを送ってみると、もう片方のアカウントでログインしている画面に即座に反映された。

AWS AppSync Chat Starterを研究してみる

オフラインで送信してみる

macのWi-Fiをオフにしてメッセージを送信すると、送信者側の画面にはメッセージがすぐに表示された。メッセージの横にあるチェックマークはグレーになっており、これが実際にはまだ送信されていないことを表しているらしい。

AWS AppSync Chat Starterを研究してみる

macのWi-Fiをオンにするとすぐにこのチェックマークが緑色になって、実際にDynamoDBにレコードが追加された。

送信側はこのようにオフラインでもユーザー体験が損なわれないようになっているが、受信側はスムーズに同期されるようにはなっていないと感じた。

他のユーザーが送信したメッセージが、オンラインに復帰しても同期されない。画面を再読み込みするとメッセージが表示された。

LineやSlackなどの一般的なチャットアプリでは、オンラインに復帰すると自動的に最新のメッセージを同期してくれるので、そのような実装の方がユーザー体験は良いかもしれない。

AppSyncをJavaScriptから扱う練習として、この動作を実現するような変更に挑戦してみようと思う。

DynamoDBのテーブルを確認してみる

AppSyncのAPIをCloudFormationで作成したときに、DynamoDBのテーブルも一緒に作成されている。AWSのマネジメントコンソールからテーブルを見てみると、以下のようなテーブルができていた。

AWS AppSync Chat Starterを研究してみる

各テーブルをのぞいてデータ構造を見てみると、以下のようになっていることがわかった。

usersTable

ユーザーを管理するためのテーブル。Amazon CognitoのユーザーIDをキーとして持つ。パスワードやメールアドレス等の情報は全てCognito側で管理する。

カラム名 役割 その他
cognitoId Amazon CognitoユーザープールでのユーザーID DynamoDBのパーティションキー(文字列型)
id cognitoIdと同じ値
registered
username ユーザー名

registeredカラムはMFA認証を完了していないユーザーはfalseになるのかと思っていたが、実際にユーザーを作成してみてもまだusersTableにはレコードが追加されなかったためよくわからなかった。

アプリケーションでメールアドレスを利用したい場合はどうするのかがまだ現段階ではわからない。

conversationsTable

メッセージルームを管理するためのテーブル。

カラム名 役割 その他
id メッセージルームのID DynamoDBのパーティションキー(文字列型)
createdAt メッセージルームの作成日時
name メッセージルーム名

messagesTable

メッセージ本文を管理するためのテーブル。

カラム名 役割 その他
conversationId メッセージルームのID DynamoDBのパーティションキー(文字列型)
createdAt メッセージの送信日時(?) DynamoDBのソートキー(文字列型)
content メッセージ本文
id メッセージ本文
isSent
sender 送信者のユーザーID

createdAtとidに同じ値が入っているように見える。そしてconversationsTableのcreatedAtと違い、2019-02-10T15:23:46.553Z_28525dc7-852c-4907-9099-2d0ee749b71eのように日付にGUIDを繋げたような形式で格納されている。

クエリを効率化するためのなんらかの工夫なのだろうか。コードを追って確認したい。

userConversationsTable

ユーザーとメッセージルームを関連づけるためのテーブル。

カラム名 役割 その他
userId ユーザーID DynamoDBのパーティションキー(文字列型)
conversationId メッセージルームのID DynamoDBのソートキー(文字列型)

DynamoDBのスキーマ設計を見てみて

DynamoDB アプリケーションではできるだけ少ないテーブルを維持する必要があります。設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。 DynamoDB に合わせた NoSQL 設計 - AWS ドキュメント

DynamoDBのスキーマ設計を学ぶに当たって、AWSの公式ドキュメントを読んだときにこんな記述を発見した。ChatQLではテーブルが4つも作られている...

せっかくNoSQLなのに、こんな風にRDBのようなスキーマ設計をしてしまって良いのだろうか...?

この部分もDynamoDBのスキーマ設計のベストプラクティスに則ってテーブル一つに情報を集約するようなリファクタリングを行ってみたい。

AppSyncのAPIを見てみる

GraphQLスキーマ



type Conversation {
	#  The Conversation's timestamp.
	createdAt: String
	#  A unique identifier for the Conversation.
	id: ID!
	#  The Conversation's messages.
	messages(after: String, first: Int): MessageConnection
	#  The Conversation's name.
	name: String!
}

type Message {
	#  The author object. Note: `authorId` is only available because we list it in `extraAttributes` in `Conversation.messages`
	author: User
	#  The message content.
	content: String!
	#  The id of the Conversation this message belongs to. This is the table primary key.
	conversationId: ID!
	#  The message timestamp. This is also the table sort key.
	createdAt: String
	#  Generated id for a message -- read-only
	id: ID!
	#  Flag denoting if this message has been accepted by the server or not.
	isSent: Boolean
	recipient: User
	sender: String
}

type MessageConnection {
	messages: [Message]
	nextToken: String
}

type Mutation {
	#  Create a Conversation. Use some of the cooked in template functions for UUID and DateTime.
	createConversation(createdAt: String, id: ID!, name: String!): Conversation
	#  Create a message in a Conversation.
	createMessage(
		content: String,
		conversationId: ID!,
		createdAt: String!,
		id: ID!
	): Message
	#  Put a single value of type 'User'. If an item does not exist with the same key the item will be created. If there exists an item at that key already, it will be updated.
	createUser(username: String!): User
	#  Put a single value of type 'UserConversations'. If an item does not exist with the same key the item will be created. If there exists an item at that key already, it will be updated.
	createUserConversations(conversationId: ID!, userId: ID!): UserConversations
}

type Query {
	#  Scan through all values of type 'Message'. Use the 'after' and 'before' arguments with the 'nextToken' returned by the 'MessageConnection' result to fetch pages.
	allMessage(after: String, conversationId: ID!, first: Int): [Message]
	#  Scan through all values of type 'MessageConnection'. Use the 'after' and 'before' arguments with the 'nextToken' returned by the 'MessageConnectionConnection' result to fetch pages.
	allMessageConnection(after: String, conversationId: ID!, first: Int): MessageConnection
	allMessageFrom(
		after: String,
		conversationId: ID!,
		first: Int,
		sender: String!
	): [Message]
	#  Scan through all values of type 'User'. Use the 'after' and 'before' arguments with the 'nextToken' returned by the 'UserConnection' result to fetch pages.
	allUser(after: String, first: Int): [User]
	#  Get my user.
	me: User
}

type Subscription {
	#  Subscribes to all new messages in a given Conversation.
	subscribeToNewMessage(conversationId: ID!): Message
		@aws_subscribe(mutations: ["createMessage"])
	subscribeToNewUCs(userId: ID!): UserConversations
		@aws_subscribe(mutations: ["createUserConversations"])
	subscribeToNewUsers: User
		@aws_subscribe(mutations: ["createUser"])
}

type User {
	#  A unique identifier for the user.
	cognitoId: ID!
	#  A user's enrolled Conversations. This is an interesting case. This is an interesting pagination case.
	conversations(after: String, first: Int): UserConverstationsConnection
	#  Generated id for a user. read-only
	id: ID!
	#  Get a users messages by querying a GSI on the Messages table.
	messages(after: String, first: Int): MessageConnection
	#  The username
	username: String!
	# is the user registered?
	registered: Boolean
}

type UserConversations {
	associated: [UserConversations]
	conversation: Conversation
	conversationId: ID!
	user: User
	userId: ID!
}

type UserConverstationsConnection {
	nextToken: String
	userConversations: [UserConversations]
}

schema {
	query: Query
	mutation: Mutation
	subscription: Subscription
}

大量のレコードを扱うアプリケーションでは、いちいち全件を読み込み直すと無駄が多いので、ページネーションという形で段階的に読み込むことが多い。GraphQLでは、*Connectionという型を定義して、レコードの配列とnextTokenという目印を返すことでこれを実現している模様。

まとめ

長くなってきたのでこの記事ではここまでにして、改めて続きをまとめていくことにする。次はメッセージを送信する処理あたりを実際にコードを追って見ていこうと思う。

GraphQLのリクエストをDynamoDBへつなぐリゾルバーの実装もじっくり見ていきたい。