JavaScript
Angular
Firebase
redux
ngrx

Angularのngrxを使って状態管理を行う(実装編:エンティティ設定)


この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。

前記事:Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定)

次記事:WEBアプリでFirebaseのデプロイ環境を構築する



この記事で行うこと

前回の記事ではngrxの状態管理の実装(初期設定~エフェクト設定)を扱いました。

本記事ではngrxの実装方法(機能ストア~エンティティ設定)について学習します。


機能ストアとは

公式ドキュメント内で機能ストアはStoreModule.forFeatureとして記載されています。

ルートストア(StoreModule.forRoot)がアプリケーション全体で使用できる一方、機能ストアは機能モジュール単位で利用できるストアになります。

Angularでは機能モジュール単位でLazy Loadingを実行しているため、この設定が抜けるとデータの過剰および不足が発生し、エラーの温床となります。

機能ストアで管理するステートは、ルートストアで作成したステートツリーにぶら下がる形で構成され、『アプリケーション全体で1つのオブジェクト』というReduxの理念を維持しています。

Redux図 (6).png


コアモジュールで管理しているコンポーネントから機能ストアのステートにアクセスすることもできますが、その機能ストアが登録されている機能モジュールが起動するまでundefindedになるので注意してください。



実装内容


機能ストアの設置


機能モジュールを設定する

まず、機能ストアを登録するための機能モジュールを追加します。

以前の記事と同様、moduleコマンドを使って初期配置をし、諸々の設定を行います。

ng g module chat --routing


app/chat/chat.module.ts

import { NgModule } from '@angular/core';

// import { CommonModule } from '@angular/common'; // 削除

import { ChatRoutingModule } from './chat-routing.module';
import { SharedModule } from '../shared/shared.module'; // 追加
import { ChatComponent } from './chat.component'; // 追加

@NgModule({
imports: [
// CommonModule, // 削除
SharedModule, // 追加
ChatRoutingModule,
],
declarations: [
ChatComponent, // 追加
]
})
export class ChatModule { }



app/app.module.ts

const appRoutes: Routes = [

{
path: 'account',
loadChildren: './account/account.module#AccountModule',
canActivate: [LoginGuard],
},
{
path: '',
loadChildren: './chat/chat.module#ChatModule', // 変更
canActivate: [AuthGuard],
},
{
path: '**',
component: PageNotFoundComponent
},
];

@NgModule({
declarations: [
AppComponent,
// ChatComponent, // 削除
PageNotFoundComponent
],
imports: [
NgbModule.forRoot(),
BrowserModule,
RouterModule.forRoot(appRoutes),
CoreModule,
SharedModule,
AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule,
AngularFireAuthModule,
],
providers: [],
bootstrap: [AppComponent]
})



app/chat/chat-routing.module.ts

import { ChatComponent } from './chat.component'; // 追加

const routes: Routes = [
{ path: '', component: ChatComponent }, // 追加
];



機能ストアを設置する

機能ストアを設置する前に、どのようなステートツリーになるかを確認します。

state

├── session
│   ├── session
│   └── loading
└── chat
   ├── ids
   ├── entities
   └── loading

sessionstateはアプリケーション起動時に取得される一方、chatstateはChatModuleダウンロード時に取得が開始されます。この前提をもとに、ジェネレータを使って機能ストアと機能エフェクトを設置します。

ng g entitiy EntityNameを使うと、ID付きのオブジェクトを扱うAction、Reducerのテンプレートを自動生成し、指定したモジュールにそのStateを登録してくれます。

// 機能ストアの追加

ng g entity chat/store/Chat --module ../chat.module.ts
// 機能エフェクトの追加
ng g effect chat/store/Chat --module chat/chat.module.ts


app/chat/chat.module.ts

import { NgModule } from '@angular/core';

import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';

import { ChatRoutingModule } from './chat-routing.module';
import { SharedModule } from '../shared/shared.module';
import { ChatComponent } from './chat.component';
import { ChatEffects } from './store/chat.effects';
import * as fromChat from './store/chat.reducer';

@NgModule({
imports: [
SharedModule,
ChatRoutingModule,
StoreModule.forFeature('chat', fromChat.reducer),
EffectsModule.forFeature([ChatEffects]),
],


これで機能ストアの設置は完了です。

次は自動作成されたAction、Reducer、Effectの内容を確認し、実装を行います。


Entityを用いたストア設計


Actionの設計

まずは自動作成されたActionの内容を確認します。

Action名
概要

LoadChats
データ(複数)の読み込み

AddChat
データ(個別)の追加

UpsertChat
データ(個別)があれば更新、なければ追加

AddChats
データ(複数)の追加

UpsertChats
データ(複数)があれば更新、なければ追加

UpdateChat
データ(個別)の更新

UpdateChats
データ(複数)の更新

DeleteChat
データ(個別)の削除

DeleteChats
データ(複数)の削除

ClearChats
全データのクリア

本アプリケーションに必要なAction以外を削除し、また非同期処理成功、失敗時のActionを追加します。

Action名
起動時
概要

LoadChats
チャット画面訪問時
データ(複数)の読み込み

LoadChatsSuccess
LoadChatsが成功
-

LoadChatsFail
LoadChatsが失敗
-

AddChat
Sendボタンクリック時
データ(個別)の追加

UpdateChat
編集で保存ボタンクリック時
データ(個別)の更新

DeleteChat
削除ボタンクリック時
データ(個別)の削除

WriteChatSuccess
書き込み処理が成功
-

WriteChatChatFail
書き込み処理が失敗
-

これをもとに、Actionファイルを更新します。

なお、自動でchat.model.tsというファイルが作成され、Chatをインポートしていますが、ここではすでに作成済のCommentクラスに差し替えます。


class/chat.ts

export class Comment {

user: User;
initial: string;
content: string;
date: number;
id?: string; // 変更
edit_flag?: boolean;

constructor(user: User, content: string) {
this.user = user;
this.initial = user.name.slice(0, 1);
this.content = content;
this.date = +moment();
}

deserialize() {
this.user = this.user.deserialize();
return Object.assign({}, this);
}

// 取得した日付を反映し、更新フラグをつける
setData(date: number, key: string): Comment {
this.date = date;
this.id = key; // 変更
this.edit_flag = false;
return this;
}
}



chat/store/chat.actions.ts

import { Action } from '@ngrx/store';

import { Update } from '@ngrx/entity';

import { Comment } from '../../class/chat';

export enum ChatActionTypes {
LoadChats = '[Chat] Load Chats',
LoadChatSuccess = '[Chat] Load Chats Success',
LoadChatsFail = '[Chat] Load Chats Fail',
AddChat = '[Chat] Add Chat',
UpdateChat = '[Chat] Update Chat',
DeleteChat = '[Chat] Delete Chat',
WriteChatSuccess = '[Chat] Write Chat Success',
WriteChatChatFail = '[Chat] Write Chat Fail'
}

export class LoadChats implements Action {
readonly type = ChatActionTypes.LoadChats;

constructor(public payload: { chats: Comment[] }) {}
}

export class LoadChatsSuccess implements Action {
readonly type = ChatActionTypes.LoadChatSuccess;

constructor(public payload: { chats: Comment[] }) {}
}

export class LoadChatsFail implements Action {
readonly type = ChatActionTypes.LoadChatsFail;

constructor(public payload?: { error: any }) {}
}

export class AddChat implements Action {
readonly type = ChatActionTypes.AddChat;

constructor(public payload: { chat: Comment }) {}
}

export class UpdateChat implements Action {
readonly type = ChatActionTypes.UpdateChat;

constructor(public payload: { chat: Update<Comment> }) {}
}

export class DeleteChat implements Action {
readonly type = ChatActionTypes.DeleteChat;

constructor(public payload: { id: string }) {}
}

export class WriteChatSuccess implements Action {
readonly type = ChatActionTypes.WriteChatSuccess;

constructor(public payload?: { chats: Comment[] }) {}
}

export class WriteChatChatFail implements Action {
readonly type = ChatActionTypes.WriteChatChatFail;

constructor(public payload?: { error: any }) {}
}

export type ChatActions =
LoadChats
| LoadChatsSuccess
| LoadChatsFail
| AddChat
| UpdateChat
| DeleteChat
| WriteChatSuccess
| WriteChatChatFail;


LoadとWriteの非同期処理の結果を分けていますが、これはLoadでObservableを使用しているためです。Add、Update、Delete時にはその結果がLoadからも流れてくることになるので、処理をわけるようにしています。

また、UpdateChatアクションではUpdate<Comment>というクラスを使用しています。このクラスはidと変更後のデータ(changes)をプロパティとして持っているので、Update時にはそれらを指定する必要があります。

続いてReducerの実装を行います。


chat/store/chat.reducer.ts

import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';

import { createFeatureSelector, createSelector } from '@ngrx/store';

import { ChatActions, ChatActionTypes } from './chat.actions';
import { Comment } from '../../class/chat';

export interface State extends EntityState<Comment> {
loading: boolean;
}

export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>();

export const initialState: State = adapter.getInitialState({
loading: false,
});

export function reducer(
state = initialState,
action: ChatActions
): State {
switch (action.type) {
case ChatActionTypes.AddChat: {
return { ...state, loading: true };
}

case ChatActionTypes.UpdateChat: {
return { ...adapter.updateOne(action.payload.chat, state), loading: true };
}

case ChatActionTypes.DeleteChat: {
return { ...adapter.removeOne(action.payload.id, state), loading: true };
}

case ChatActionTypes.LoadChats: {
return { ...state, loading: true };
}

case ChatActionTypes.LoadChatSuccess: {
return { ...adapter.upsertMany(action.payload.chats, state), loading: false };
}

case ChatActionTypes.LoadChatsFail: {
return { ...state, loading: false };
}

case ChatActionTypes.WriteChatSuccess: {
return { ...state, loading: false };
}

case ChatActionTypes.WriteChatChatFail: {
return { ...state, loading: false };
}

default: {
return state;
}
}
}

const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
export const selectChat = createFeatureSelector<State>('chat');
export const getChatLoading = createSelector(selectChat, state => state.loading);
export const selectAllChats = createSelector(selectChat, selectAll);


少し分解しながら説明します。

export interface State extends EntityState<Comment> {

loading: boolean;
}

このState はEntityState<Comment>ids: string[] | number[]entities: {[id: string]: Comment}というプロパティを継承しています。ここではAPI接続時のローディング用ステート(loading)を追加しています。

    case ChatActionTypes.AddChat: {

return { ...state, loading: true };
}

case ChatActionTypes.UpdateChat: {
return { ...adapter.updateOne(action.payload.chat, state), loading: true };
}

case ChatActionTypes.DeleteChat: {
return { ...adapter.removeOne(action.payload.id, state), loading: true };
}

case ChatActionTypes.LoadChats: {
return { ...state, loading: true };
}

CLUD処理の開始時に、ローディングが始まるようにしています(loading: true)。adapter.updateOneは指定されたidのエンティティを更新し、chatAdapter.removeOneは指定されたidのエンティティを削除します。

adapter: EntityAdapter<Comment>はこれ以外にもaddAll(すべて追加)やremoveAll(すべて削除)といったメソッドを持っています。

    case ChatActionTypes.LoadChatSuccess: {

return { ...adapter.upsertMany(action.payload.chats, state), loading: false };
}

case ChatActionTypes.LoadChatsFail: {
return { ...state, loading: false };
}

case ChatActionTypes.WriteChatSuccess: {
return { ...state, loading: false };
}

case ChatActionTypes.WriteChatChatFail: {
return { ...state, loading: false };
}

LoadChatSuccessはLoad成功時に取得したデータをStateに反映させます。取得したエンティティがすでにある場合は更新、ない場合は追加をします。また、非同期処置が完了したので、ローディングを終了させます(loading: false)。

const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();

export const selectChat = createFeatureSelector<State>('chat');
export const getChatLoading = createSelector(selectChat, state => state.loading);
export const selectAllChats = createSelector(selectChat, selectAll);

adapter: EntityAdapter<Comment>から基本となるセレクタを取得しています。それぞれのセレクタからは、下記のデータが取得できます。

メソッド名
取得データ
概要

selectIds
string[] or number[]
idの一覧を取得

selectEntities
{[id: string]: Comment}
エンティティ(オブジェクト)の一覧を取得

selectAll
Comment[]
エンティティ(配列)の一覧を取得

selectTotal
number
エンティティの総数を取得

これでReducerの実装は完了です。最後にEffectの実装を行います。


app/chat/effects.ts

import { Injectable } from '@angular/core';

import { AngularFirestore } from '@angular/fire/firestore';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { Action } from '@ngrx/store';
import { catchError, map, switchMap } from 'rxjs/operators';
import { Update } from '@ngrx/entity';

import { Comment } from '../../class/chat';
import {
AddChat,
ChatActionTypes,
DeleteChat,
LoadChats,
LoadChatsFail,
LoadChatsSuccess,
UpdateChat,
WriteChatChatFail,
WriteChatSuccess,
} from './chat.actions';

@Injectable()
export class ChatEffects {

constructor(private actions$: Actions,
private db: AngularFirestore) {
}

@Effect()
addChat$: Observable<Action> =
this.actions$.pipe(
ofType<AddChat>(ChatActionTypes.AddChat),
map(action => action.payload.chat),
switchMap((comment: Comment) => {
return this.db
.collection('comments')
.add(comment.deserialize())
.then(() => new WriteChatSuccess())
.catch(() => new WriteChatChatFail({ error: 'failed to add' }));
})
);

@Effect()
updateChat$: Observable<Action> =
this.actions$.pipe(
ofType<UpdateChat>(ChatActionTypes.UpdateChat),
map(action => action.payload.chat),
switchMap((comment: Update<Comment>) => {
return this.db
.collection('comments')
.doc(comment.id.toString())
.update({ content: comment.changes.content, date: comment.changes.date })
.then(() => {
alert('コメントを更新しました');
return new WriteChatSuccess();
})
.catch(() => new WriteChatChatFail({ error: 'failed to update' }));
})
);

@Effect()
deleteChat$: Observable<Action> =
this.actions$.pipe(
ofType<DeleteChat>(ChatActionTypes.DeleteChat),
map(action => action.payload.id),
switchMap((id: string) => {
return this.db
.collection('comments')
.doc(id)
.delete()
.then(() => {
alert('コメントを削除しました');
return new WriteChatSuccess();
})
.catch(() => new WriteChatChatFail({ error: 'failed to delete' }));
})
);

@Effect()
loadChats$: Observable<Action> =
this.actions$.pipe(
ofType<LoadChats>(ChatActionTypes.LoadChats),
map(action => action.payload.chats),
switchMap(() => {
return this.db.collection<Comment>('comments', ref => {
return ref.orderBy('date', 'asc');
}).snapshotChanges()
.pipe(
map(actions => actions.map(action => {
// 日付をセットしたコメントを返す
const data = action.payload.doc.data() as Comment;
const key = action.payload.doc.id;
const comment_data = new Comment(data.user, data.content);
comment_data.setData(data.date, key);
return comment_data;
})),
map((result: Comment[]) => {
return new LoadChatsSuccess({
chats: result
});
}),
catchError(this.handleChatsError(
'fetchChats', new LoadChatsFail()
))
);
})
);

// エラー発生時の処理
private handleChatsError<T>(operation = 'operation', result: T) {
return (error: any): Observable<T> => {

// 失敗した操作の名前、エラーログをconsoleに出力
console.error(`${operation} failed: ${error.message}`);

// 結果を返して、アプリを持続可能にする
return of(result as T);
};
}
}


chat.component.ts内で行っていた処理を、こちらに移行しています。SessionEffectと同様、ofTypeでトリガーとなるActionを指定し、処理結果を新しいActionに渡しています。


View(コンポーネント)にStateを反映

Reducerで設定したStateを、Viewに反映させます。


chat/chat.component.ts

import { Component, OnInit } from '@angular/core';

import { Observable } from 'rxjs';
// import { AngularFirestore } from '@angular/fire/firestore'; // 削除
// import { map } from 'rxjs/operators'; // 削除

import { Comment, User } from '../class/chat';
import { Store } from '@ngrx/store';
import * as fromCore from '../core/store/reducers';
import * as fromChat from './store/chat.reducer'; // 追加
import { AddChat, DeleteChat, LoadChats, UpdateChat } from './store/chat.actions'; // 追加

@Component({
selector: 'app-chat',
templateUrl: './chat.component.html',
styleUrls: ['./chat.component.css']
})
export class ChatComponent implements OnInit {

public content = '';
public comments: Observable<Comment[]>;
public current_user: User;

// DI(依存性注入する機能を指定)
constructor(private chat: Store<fromChat.State>, // 追加
// private db: AngularFirestore, // 削除
private store: Store<fromCore.State>) {
this.store.select(fromCore.getSession)
.subscribe(data => {
this.current_user = data.user;
});
this.comments = this.chat.select(fromChat.selectAllChats); // 追加
}

ngOnInit() { // 変更
this.store.dispatch(new LoadChats({ chats: [] }));
}

// 新しいコメントを追加
addComment(e: Event, comment: string) { // 変更
e.preventDefault();
if (comment) {
this.chat.dispatch(new AddChat({chat: new Comment(this.current_user, comment)}));
this.content = '';
}
}

// 編集フィールドの切り替え
toggleEditComment(comment: Comment) {
comment.edit_flag = (!comment.edit_flag);
}

// コメントを更新する
saveEditComment(comment: Comment) { // 変更
comment.edit_flag = false;
this.chat.dispatch(new UpdateChat({chat: {id: comment.id, changes: comment}}));
}

// コメントをリセットする
resetEditComment(comment: Comment) {
comment.content = '';
}

// コメントを削除する
deleteComment(key: string) { // 変更
this.chat.dispatch(new DeleteChat({id: key}));
}

}


機能ストアにdispatchを行い、Effectに移行した分を削除しました。

commentsにはthis.chat.select(fromChat.selectAllChats)を指定し、返り値であるObservable<Comment[]>を取得できるようにしています。

自分以外のコメントは編集・削除ができないようにHTMLも修正します。


chat/chat.component.html

<!-- 自分のuidのときのみ、編集領域を表示 -->

<ng-container *ngIf="comment.user.uid === current_user.uid"><!-- 追加 -->
<button class="btn btn-primary btn-sm" (click)="toggleEditComment(comment)">編集</button>
<button class="btn btn-danger btn-sm" (click)="deleteComment(comment.id)">削除</button>
</ng-container>

次に、ヘッダーコンポーネントからも機能ストアをDIして、ローディングを反映させます。

なお、コアモジュールで管理しているコンポーネントに読み込ませる場合、ChatModuleにアクセスするまで機能ストアはundefinedになるので注意が必要です。


core/header/header.component.ts

import { SessionService } from '../service/session.service';

import { Session } from '../../class/chat';
import * as fromCore from '../store/reducers';
import * as fromChat from '../../chat/store/chat.reducer'; // 追加

@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.css']
})
export class HeaderComponent implements OnInit {

public loadingSession$: Observable<boolean>; // 変更
public loadingChat$: Observable<boolean>; // 追加
public session$: Observable<Session>;

constructor(private sessionService: SessionService,
private store: Store<fromCore.State>,
private chat: Store<fromChat.State>) { // 追加
this.loadingSession$ = this.store.select(fromCore.getLoading); // 変更
this.loadingChat$ = this.chat.select(fromChat.getChatLoading); // 追加
this.session$ = this.store.select(fromCore.getSession);
}



core/header/header.component.html

<div class="progress" style="height: 3px;" *ngIf="(loadingSession$ | async) || (loadingChat$ | async)"><!-- 変更 -->

<div class="progress-bar bg-info progress-bar-striped progress-bar-animated"
role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%"></div>
</div>


実行結果

Oct-26-2018 17-46-31.gif

これでngrxの実装は完了です。次回からはAngularとFirebaseの本番環境構築について書いていきます。


ソースコード

この時点でのソースコード

※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。