2019.02.10
以前このAppSync Chat Starterを実際に動かしてみてAWS Amplifyでのバックエンドの構築や、AWS AppSyncを使ったGraphQL APIの作成を試してみた。
AWS AppSync Chat Starterを試してみる
その後、AmplifyやAppSyncについてそれぞれじっくり勉強して、AppSyncを使って実際にアプリケーションを設計する段階に来たので改めてChatQLのコードを追っていき、Apollo Clientを使ったAppSyncの呼び出し方を学ぼうと思う。
ChatQL: An AWS AppSync Chat Starter App written in Angular
ChatQLとは、AWSが提供しているサンプルアプリケーション。AngularでAWS AppSyncのAPIにアクセスする。ユーザー認証はAmazon Cognitoを、アプリケーションのデータ保存はAmazon DynamoDBを使っている。
それらのバックエンドをコード管理して、ローカルのCLIから立ち上げたり、変更するのにAWS Amplifyを使っている。
AWSマネジメントコンソールにログインして、新しくIAMユーザーを作成する。権限はAdministratorAccessをアタッチした。
まずはアプリケーションをクローンする。
# プロジェクトをクローン
$ git clone git@github.com:aws-samples/aws-mobile-appsync-chat-starter-angular.git
# ディレクトリに入る
$ cd aws-mobile-appsync-chat-starter-angular
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 install
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 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)
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)
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/ を開く。
を入力してユーザーを作成することができる。デフォルトの設定でAmazon Cognitoを設定したが、デフォルトではパスワードは大文字を含むアルファベット、数字、記号を使った6文字以上が必要となる。
ユーザー登録を行うと、入力したメールアドレスにメールで認証コードが送信される。この認証コードを入力することでアカウント作成が完了し、ログインできるようになる。メールアドレスが有効なものか検証することができる。
認証が完了したら、登録した情報でログインしてみる。まだユーザーが一人しかいないので、チャットは開始できない模様。
このユーザーはGoogle Chromeで作成したので、Safariを立ち上げて別のユーザーを作成してみる。
他のユーザーを作成すると、メイン画面の左側のユーザー一覧に現れる。これをクリックするとメッセージルームのようなものが出来上がった。
試しにメッセージを送ってみると、もう片方のアカウントでログインしている画面に即座に反映された。
macのWi-Fiをオフにしてメッセージを送信すると、送信者側の画面にはメッセージがすぐに表示された。メッセージの横にあるチェックマークはグレーになっており、これが実際にはまだ送信されていないことを表しているらしい。
macのWi-Fiをオンにするとすぐにこのチェックマークが緑色になって、実際にDynamoDBにレコードが追加された。
送信側はこのようにオフラインでもユーザー体験が損なわれないようになっているが、受信側はスムーズに同期されるようにはなっていないと感じた。
他のユーザーが送信したメッセージが、オンラインに復帰しても同期されない。画面を再読み込みするとメッセージが表示された。
LineやSlackなどの一般的なチャットアプリでは、オンラインに復帰すると自動的に最新のメッセージを同期してくれるので、そのような実装の方がユーザー体験は良いかもしれない。
AppSyncをJavaScriptから扱う練習として、この動作を実現するような変更に挑戦してみようと思う。
AppSyncのAPIをCloudFormationで作成したときに、DynamoDBのテーブルも一緒に作成されている。AWSのマネジメントコンソールからテーブルを見てみると、以下のようなテーブルができていた。
各テーブルをのぞいてデータ構造を見てみると、以下のようになっていることがわかった。
ユーザーを管理するためのテーブル。Amazon CognitoのユーザーIDをキーとして持つ。パスワードやメールアドレス等の情報は全てCognito側で管理する。
カラム名 | 役割 | その他 |
---|---|---|
cognitoId | Amazon CognitoユーザープールでのユーザーID | DynamoDBのパーティションキー(文字列型) |
id | cognitoIdと同じ値 | |
registered | ? | |
username | ユーザー名 |
registered
カラムはMFA認証を完了していないユーザーはfalseになるのかと思っていたが、実際にユーザーを作成してみてもまだusersTable
にはレコードが追加されなかったためよくわからなかった。
アプリケーションでメールアドレスを利用したい場合はどうするのかがまだ現段階ではわからない。
メッセージルームを管理するためのテーブル。
カラム名 | 役割 | その他 |
---|---|---|
id | メッセージルームのID | DynamoDBのパーティションキー(文字列型) |
createdAt | メッセージルームの作成日時 | |
name | メッセージルーム名 |
メッセージ本文を管理するためのテーブル。
カラム名 | 役割 | その他 |
---|---|---|
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を繋げたような形式で格納されている。
クエリを効率化するためのなんらかの工夫なのだろうか。コードを追って確認したい。
ユーザーとメッセージルームを関連づけるためのテーブル。
カラム名 | 役割 | その他 |
---|---|---|
userId | ユーザーID | DynamoDBのパーティションキー(文字列型) |
conversationId | メッセージルームのID | DynamoDBのソートキー(文字列型) |
DynamoDB アプリケーションではできるだけ少ないテーブルを維持する必要があります。設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。 DynamoDB に合わせた NoSQL 設計 - AWS ドキュメント
DynamoDBのスキーマ設計を学ぶに当たって、AWSの公式ドキュメントを読んだときにこんな記述を発見した。ChatQLではテーブルが4つも作られている...
せっかくNoSQLなのに、こんな風にRDBのようなスキーマ設計をしてしまって良いのだろうか...?
この部分もDynamoDBのスキーマ設計のベストプラクティスに則ってテーブル一つに情報を集約するようなリファクタリングを行ってみたい。
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へつなぐリゾルバーの実装もじっくり見ていきたい。