AWS Amplify Advent Calendar 17日目です。
こちらは前回記事の続編です。
AWS AmplifyとIonicを組み合わせてモバイルアプリまで爆速で開発する
今回のゴール
前回に引き続きIonic + AWS Amplifyの構成で開発を進めます。
今回はAWS AmplifyにAPIカテゴリを追加して、GraphQLサーバー(AWS AppSync)を立てます。
メッセージグループ一覧画面とメッセージグループ個別画面を持ったチャットアプリを作ることが目標です。
AWS Amplify CLIでAPIカテゴリをセットアップする
Amplify CLIを使って対話的にAPIの構築を行います。
$ amplify api add
# APIのタイプを指定
? Please select from one of the below mentioned services: GraphQL
# API名を指定
? Provide API name: amplifyionicchat
# 認証モードを指定
? Choose the default authorization type for the API Amazon Cognito User Pool
# GraphQLの設定を上書きするか設定
? Do you want to configure advanced settings for the GraphQL API No, I am done.
# スキーマはまだない
? Do you have an annotated GraphQL schema? No
# スキーマ作成のガイドを行う
? Do you want a guided schema creation? Yes
# テンプレートは一対多の関連を使う
? What best describes your project: One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”)
# いますぐスキーマを編集する
? Do you want to edit the schema now? Yes
エディタが自動的に開くので、自動生成されたスキーマを参考にしつつ以下のような拡張GraphQLスキーマを書きました。
type Group @model {
id: ID!
name: String!
messages: [Message] @connection(name: "GroupMessages")
}
type Message
@model
@auth(rules: [{ allow: owner, operations: [create, update, delete] }]) {
id: ID!
owner: String
content: String!
group: Group @connection(name: "GroupMessages")
}
変更をバックエンドにプッシュする
$ npx amplify push
これだけでAWS AppSyncを使ったGraphQLサーバーの構築が完了しました。 aws-exports.js
も自動的に更新されているので、AWS AppSyncのエンドポイントの情報等は既にフロントエンドのプロジェクトに準備されていることになります。
これは楽...!
モデルを作成する
メッセージグループとメッセージを扱う型を定義します。
export interface Message {
id: string;
owner: string;
content: string;
}
import { Message } from './message.model';
export interface MessageGroup {
id: string;
name: string;
messages: Message[];
}
ngrxをインストールする
@ngrx/store
本体と、副作用を扱う @ngrx/entity
をインストールします。
$ npm install @ngrx/store @ngrx/effects
状態管理に必要なコードを作成する
@ngrx
関連のファイルを生成します。今回はメッセージ関連を扱うストアだけしか作成しないので、シンプルなディレクトリ構成にします。
$ tree src/app/store/
src/app/store/
├── app-store.module.ts
└── message
├── message-store.module.ts
├── message.action.ts
├── message.effect.ts
├── message.facade.ts
├── message.reducer.ts
├── message.selector.ts
└── message.state.ts
各ファイルは以下のように実装しました。
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { MessageStoreModule } from './message/message-store.module';
const storeModules = [MessageStoreModule];
@NgModule({
imports: [
StoreModule.forRoot([], {
runtimeChecks: {
strictActionImmutability: true, // Actionのイミュータブルチェック
strictStateImmutability: true, // 状態のイミュータブルチェック
},
}),
EffectsModule.forRoot([]),
...storeModules,
],
})
export class AppStoreModule {}
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { featureName } from './message.state';
import { reducer } from './message.reducer';
import { MessageEffects } from './message.effect';
@NgModule({
imports: [
StoreModule.forFeature(featureName, reducer),
EffectsModule.forFeature([MessageEffects]),
],
})
export class MessageStoreModule {}
import { MessageGroup } from '@/app/models/message-group.model';
export const featureName = 'message';
export interface State {
groups: { [key: string]: MessageGroup };
isGroupListLoaded: boolean;
error?: any;
}
export const initialState: State = {
groups: {},
isGroupListLoaded: false,
};
import { createAction, props } from '@ngrx/store';
import { MessageGroup } from '@/app/models/message-group.model';
import { Message } from '@/app/models/message.model';
export const listMessageGroups = createAction(
'[Message] listMessageGroups',
props(),
);
export const listMessageGroupsSuccess = createAction(
'[Message] listMessageGroupsSuccess',
props<{ groups: MessageGroup[] }>(),
);
export const listMessageGroupsFailure = createAction(
'[Message] listMessageGroupsFailure',
props<{ error: any }>(),
);
export const getMessageGroup = createAction(
'[Message] getMessageGroup',
props<{ groupId: string }>(),
);
export const getMessageGroupSuccess = createAction(
'[Message] getMessageGroupSuccess',
props<{ group: MessageGroup }>(),
);
export const getMessageGroupFailure = createAction(
'[Message] getMessageGroupFailure',
props<{ error: any }>(),
);
export const sendMessage = createAction(
'[Message] sendMessage',
props<{ groupId: string; content: string }>(),
);
export const sendMessageSuccess = createAction(
'[Message] sendMessageSuccess',
props<{ groupId: string; message: Message }>(),
);
export const sendMessageFailure = createAction(
'[Message] sendMessageFailure',
props<{ error: any }>(),
);
import { Action, createReducer, on } from '@ngrx/store';
import * as MessageActions from './message.action';
import { State, initialState } from './message.state';
import { MessageGroup } from '@/app/models/message-group.model';
const arrayToHash = (groups: MessageGroup[]) => {
const g = {};
for (const group of groups) {
g[group.id] = group;
}
return g;
};
const messageReducer = createReducer(
initialState,
on(
MessageActions.listMessageGroupsSuccess,
(state, { groups }): State => ({
...state,
groups: arrayToHash(groups),
isGroupListLoaded: true,
}),
),
on(
MessageActions.getMessageGroupSuccess,
(state, { group }): State => ({
...state,
groups: {
...state.groups,
[group.id]: group,
},
}),
),
on(
MessageActions.sendMessageSuccess,
(state, { groupId, message }): State => {
const group = {
...state.groups[groupId],
messages: [
...state.groups[groupId].messages.filter(m => m.id !== message.id),
message,
],
};
return {
...state,
groups: { ...state.groups, [groupId]: group },
};
},
),
);
export function reducer(state: State, action: Action) {
return messageReducer(state, action); // AOTコンパイル用
}
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { State, featureName } from './message.state';
const getState = createFeatureSelector<State>(featureName);
export const getMessageGroups = createSelector(getState, state => state.groups);
export const getIsMessageGroupListLoaded = createSelector(
getState,
state => state.isGroupListLoaded,
);
import { Injectable } from '@angular/core';
import { Actions, ofType, createEffect } from '@ngrx/effects';
import { of, from } from 'rxjs';
import { map, switchMap, catchError } from 'rxjs/operators';
import { APIService } from '@/app/API.service';
import * as MessageActions from './message.action';
import { AmplifyService } from 'aws-amplify-angular';
import { graphqlOperation } from 'aws-amplify';
@Injectable()
export class MessageEffects {
constructor(
private actions$: Actions,
private apiService: APIService,
private amplifyService: AmplifyService,
) {}
listMessageGroups$ = createEffect(() =>
this.actions$.pipe(
ofType(MessageActions.listMessageGroups),
switchMap(() => this.apiService.ListGroups()),
map(res =>
MessageActions.listMessageGroupsSuccess({
groups: res.items.map(item => ({ ...item, messages: [] })),
}),
),
catchError(error =>
of(MessageActions.listMessageGroupsFailure({ error })),
),
),
);
getMessageGroup$ = createEffect(() =>
this.actions$.pipe(
ofType(MessageActions.getMessageGroup),
switchMap(({ groupId }) => this.apiService.GetGroup(groupId)),
map(group =>
MessageActions.getMessageGroupSuccess({
group: { ...group, messages: group.messages.items },
}),
),
catchError(error => of(MessageActions.getMessageGroupFailure({ error }))),
),
);
sendMessage$ = createEffect(() =>
this.actions$.pipe(
ofType(MessageActions.sendMessage),
switchMap(({ groupId, content }) =>
from(
this.apiService.CreateMessage({ messageGroupId: groupId, content }),
).pipe(
map(message =>
MessageActions.sendMessageSuccess({ groupId, message }),
),
catchError(error => of(MessageActions.sendMessageFailure({ error }))),
),
),
),
);
}
最後にFacadeクラスです。これがあることにより、ビューコンポーネントと @ngrx
の間を疎結合にすることができます。
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { MessageStoreModule } from './message-store.module';
import { State } from './message.state';
import * as Actions from './message.action';
import * as Selectors from './message.selector';
import { map } from 'rxjs/operators';
@Injectable({
providedIn: MessageStoreModule,
})
export class MessageFacade {
groups$ = this.store.pipe(
select(Selectors.getMessageGroups),
map(groups => Object.values(groups)),
);
isGroupListLoaded$ = this.store.pipe(
select(Selectors.getIsMessageGroupListLoaded),
);
constructor(private store: Store<State>) {}
// グループが読み込まれているか
isGroupLoaded$(groupId: string) {
return this.store.pipe(
select(Selectors.getMessageGroups),
map(groups => groupId in groups),
);
}
listMessageGroups() {
this.store.dispatch(Actions.listMessageGroups({}));
}
getMessageGroup(groupId: string) {
this.store.dispatch(Actions.getMessageGroup({ groupId }));
}
sendMessage(groupId: string, content: string) {
this.store.dispatch(Actions.sendMessage({ groupId, content }));
}
subscribeOnCreateMessage() {
this.store.dispatch(Actions.subscribeOnSendMessage({}));
}
}
メッセージグループ一覧画面を作成する
Ruby on Rails風のルーティングで組んでいて今の所いい感じなので、それにならってページコンポーネントを生成します。
$ ionic g page pages/closed/message-group/index
コンポーネントに関するファイル一式が出来上がりました。
$ tree src/app/pages/closed/message-group/index
src/app/pages/closed/message-group/index
├── index-routing.module.ts
├── index.module.ts
├── index.page.html
├── index.page.scss
├── index.page.spec.ts
└── index.page.ts
それぞれは以下のように実装しました。
シンプルにするため、 index-routing.module.ts
の内容は index.module.ts
に統合しています。
import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
import { SharedModule } from '@/app/shared/shared.module';
import { IndexPage } from './index.page';
@NgModule({
imports: [
SharedModule,
RouterModule.forChild([{ path: '', component: IndexPage }]),
],
declarations: [IndexPage],
})
export class IndexPageModule {}
<ion-header>
<ion-toolbar>
<ion-title>メッセージ</ion-title>
<ion-buttons slot="end">
<ion-button slot="icon-only" (click)="onSignOutButtonClicked()">
<ion-icon name="log-out"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ng-container *ngIf="state$ | async as state">
<ng-container *ngIf="state.isGroupListLoaded;else notLoadedContent">
<ion-list>
<ng-container *ngFor="let group of state.groups">
<ion-item [routerLink]="['/message', group.id]">
<ion-avatar slot="start">
<img src="https://placekitten.com/150/150" />
</ion-avatar>
<ion-label>
<h2>{{ group.name }}</h2>
</ion-label>
</ion-item>
</ng-container>
</ion-list>
</ng-container>
<ng-template #notLoadedContent>
<div class="ion-padding">
読み込み中...
</div>
</ng-template>
</ng-container>
</ion-content>
import { Component } from '@angular/core';
import { AmplifyService } from 'aws-amplify-angular';
import { MessageFacade } from '@/app/store/message/message.facade';
import { MessageGroup } from '@/app/models/message-group.model';
import { Observable, combineLatest, from } from 'rxjs';
import { map, tap } from 'rxjs/operators';
interface State {
groups: MessageGroup[];
isGroupListLoaded: boolean;
username: string;
}
@Component({
selector: 'app-index',
templateUrl: './index.page.html',
styleUrls: ['./index.page.scss'],
})
export class IndexPage {
state$: Observable<State>;
constructor(
private amplifyService: AmplifyService,
private messageFacade: MessageFacade,
) {
const groups$ = this.messageFacade.groups$;
const isGroupListLoaded$ = this.messageFacade.isGroupListLoaded$;
const userInfo$: Observable<any> = from(
this.amplifyService.auth().currentUserInfo(),
);
this.state$ = combineLatest([groups$, isGroupListLoaded$, userInfo$]).pipe(
map(([groups, isGroupListLoaded, userInfo]) => ({
groups,
isGroupListLoaded,
username: userInfo.username,
})),
);
}
ionViewWillEnter() {
this.messageFacade.listMessageGroups();
}
onSignOutButtonClicked() {
this.amplifyService.auth().signOut();
}
}
Ionicを使っているので、用意されているコンポーネントを使えばCSSの記述ゼロでこれくらいのビューはさくっと作れます。
メッセージグループ個別画面を作成する
同様の手順でメッセージグループ個別画面も作成します。
$ ionic g page pages/closed/message-group/show
コンポーネントに関するファイル一式が出来上がりました。
$ tree src/app/pages/closed/message-group/show
src/app/pages/closed/message-group/show
├── show-routing.module.ts
├── show.module.ts
├── show.page.html
├── show.page.scss
├── show.page.spec.ts
└── show.page.ts
こちらも show-routing.module.ts
は使わないので削除しています。
ビューについてはチャットルームっぽいUIを作るために多少CSSを書きました。
import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
import { SharedModule } from '@/app/shared/shared.module';
import { ShowPage } from './show.page';
@NgModule({
imports: [
SharedModule,
RouterModule.forChild([{ path: '', component: ShowPage }]),
],
declarations: [ShowPage],
})
export class ShowPageModule {}
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button text="" defaultHref="/message"></ion-back-button>
</ion-buttons>
<ng-container *ngIf="state$ | async as state">
<ion-title>{{state.group.name}}</ion-title>
</ng-container>
</ion-toolbar>
</ion-header>
<ion-content>
<ng-container *ngIf="state$ | async as state">
<ng-container *ngIf="state.isGroupLoaded; else notLoadedContent">
<div class="message">
<ng-container *ngFor="let message of state.group.messages">
<div
class="message__wrapper"
[class.message__wrapper--me]="message.owner === state.username"
>
<div class="message__meta">
{{message.owner}}
</div>
<div class="message__balloon">
{{message.content}}
</div>
</div>
</ng-container>
</div>
</ng-container>
<ng-template #notLoadedContent>
<div class="ion-padding">
読み込み中
</div>
</ng-template>
</ng-container>
</ion-content>
<ion-footer>
<ng-container *ngIf="state$ | async as state">
<ion-toolbar>
<div class="form">
<div class="form__input-wrapper">
<input
type="text"
class="form__input-field"
placeholder="メッセージを入力..."
[(ngModel)]="content"
/>
</div>
<div
class="form__button"
(click)="onSendMessageButtonClicked(state.group.id)"
>
送信
</div>
</div>
</ion-toolbar>
</ng-container>
</ion-footer>
CSSはBEM記法を使って書きました。SCSSを使うことで効率的に書けて気に入っています。
.message {
padding: 12px;
&__wrapper {
align-items: flex-start;
display: flex;
flex-direction: column;
margin: 24px 0;
&--me {
align-items: flex-end;
.message__meta {
text-align: right;
}
}
}
&__meta {
color: #666;
font-size: 12px;
line-height: 1;
margin-bottom: 4px;
}
&__balloon {
border: 1px solid #dedede;
border-radius: 10px;
color: #333;
background: #fff;
font-size: 16px;
margin-bottom: 4px;
padding: 10px;
word-break: break-all;
}
}
.form {
align-items: center;
display: flex;
&__input-wrapper {
flex: 1;
}
&__input-field {
border: 0;
color: #333;
font-size: 14px;
display: block;
line-height: 1.5;
outline: 0;
padding: 5px;
width: 100%;
-webkit-appearance: none;
}
&__button {
color: #0b55c5;
font-size: 14px;
font-weight: bold;
line-height: 1;
padding: 10px 10px;
}
}
import { Component, OnInit, OnDestroy } from '@angular/core';
import { MessageFacade } from '@/app/store/message/message.facade';
import { combineLatest, Observable, Subject, from } from 'rxjs';
import { map, takeUntil, switchMap } from 'rxjs/operators';
import { MessageGroup } from '@/app/models/message-group.model';
import { ActivatedRoute } from '@angular/router';
import { AmplifyService } from 'aws-amplify-angular';
interface State {
group: MessageGroup | null;
isGroupLoaded: boolean;
username: string;
}
@Component({
selector: 'app-show',
templateUrl: './show.page.html',
styleUrls: ['./show.page.scss'],
})
export class ShowPage implements OnInit, OnDestroy {
onDestroy$$ = new Subject();
state$: Observable<State>;
content = '';
constructor(
private route: ActivatedRoute,
private messageFacade: MessageFacade,
private amplifyService: AmplifyService,
) {
const groups$ = this.messageFacade.groups$;
const groupId$ = this.route.paramMap.pipe(
map(paramMap => paramMap.get('id')),
);
const isGroupLoaded$ = groupId$.pipe(
switchMap(groupId => this.messageFacade.isGroupLoaded$(groupId)),
);
const userInfo$: Observable<any> = from(
this.amplifyService.auth().currentUserInfo(),
);
this.state$ = combineLatest([
groups$,
groupId$,
isGroupLoaded$,
userInfo$,
]).pipe(
map(([groups, groupId, isGroupLoaded, userInfo]) => ({
groups,
groupId,
isGroupLoaded,
userInfo,
})),
map(({ groups, groupId, isGroupLoaded, userInfo }) => ({
group: groups.find(g => g.id === groupId),
isGroupLoaded,
username: userInfo.username,
})),
);
}
ngOnInit() {
this.route.paramMap
.pipe(
map(paramMap => paramMap.get('id')),
takeUntil(this.onDestroy$$),
)
.subscribe(groupId => this.messageFacade.getMessageGroup(groupId));
this.messageFacade.subscribeOnCreateMessage();
}
ngOnDestroy() {
this.onDestroy$$.next();
}
onSendMessageButtonClicked(groupId: string) {
this.messageFacade.sendMessage(groupId, this.content);
}
}
メッセージグループ個別画面は以下のように仕上がりました。
ルーティングを追加する
最後に、作成した
- メッセージグループ一覧画面
- メッセージグループ個別画面
が参照できるようルーティングを追加します。これらの画面はログイン中のみ見られる画面なので、 src/app/pages/closed
以下に配置します。
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
{
path: 'message',
loadChildren: () =>
import('./message-group/message-group.module').then(
m => m.MessageGroupModule,
),
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
})
export class ClosedModule {}
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
const routes: Routes = [
{
path: '',
loadChildren: () =>
import('./index/index.module').then(m => m.IndexPageModule),
},
{
path: ':id',
loadChildren: () =>
import('./show/show.module').then(m => m.ShowPageModule),
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
})
export class MessageGroupModule {}
動作確認
ここまでで、
- メッセージグループ一覧の読み込みと表示
- URLのパラメタでID指定されたメッセージグループの読み込みと表示
- メッセージの送信
が実現できました。
GraphQL Subscriptionを使ったメッセージのリアルタイム購読が辛いと気づいた
グループにメッセージが送られてきたらサーバー側から情報をプッシュしてリアルタイムにビューを更新するよう実装しようと思いましたが、改めてGraphQLスキーマを見るとSubscriptionの定義が以下のようになっていることに気づきました。
type Subscription {
onCreateGroup: Group @aws_subscribe(mutations: ["createGroup"])
onUpdateGroup: Group @aws_subscribe(mutations: ["updateGroup"])
onDeleteGroup: Group @aws_subscribe(mutations: ["deleteGroup"])
onCreateMessage(owner: String!): Message @aws_subscribe(mutations: ["createMessage"])
onUpdateMessage(owner: String!): Message @aws_subscribe(mutations: ["updateMessage"])
onDeleteMessage(owner: String!): Message @aws_subscribe(mutations: ["deleteMessage"])
}
これでは、 onCreateMessage
は指定したユーザーIDのものしか購読することができません。先にグループのユーザーのリストを手に入れておいてからそれを使って複数のSubscriptionを購読すればなんとかいけそうですが、できれば引数に groupId
を指定できるようなスキーマにしたいところです。
残りは引き続き取り組もうと思います...!
思ったよりAWS AmplifyのGraphQL Transformの調査に時間を取られてしまいました。今後の展望として、
- メッセージグループが増減した場合に自動的に一覧を同期
- メッセージが送信されたときに自動的にメッセージグループ個別を同期
といったリアルタイム処理の部分を追加したいと思います。