JavaScript
Angular
Firebase
redux
ngrx

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


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

前記事:Angularのngrxを使って状態管理を行う(理論編)

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



この記事で行うこと

前回の記事ではngrxの概念と基本的な構成を扱いました。

本記事ではngrxの初期設定から、エフェクト設定などの具体的な実装方法について学習します。


実装内容


ngrxの初期設定


ライブラリインストール

まず、コードジェネレーターである@ngrx/schematicsを開発環境に、それ以外のライブラリをプロジェクトにインストールします。

npm install @ngrx/schematics --save-dev

npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools --save


AngularとFirebaseの環境構築は過去の記事から行ってください。

また、この実装はAngular6以上であることが必要ですので注意してください。


続いて、@ngrx/schematicsの初期設定を行います。

ng config cli.defaultCollection @ngrx/schematics

このコマンドを実行すると、ジェネレータコマンドの一部を省略することができます。

(例:ng g @ngrx/schematics:store Stateng g store State


cssの実装をscssで行いたい場合は、angular.jsonに次の記述を追加します。


src/angular.json

"schematics": {

"@ngrx/schematics:component": {
"styleext": "scss"
}
}



@ngrx/schematicsジェネレータのコマンド一覧

コマンド
概要

ng g action ActionName [options]
Actionファイルの作成

ng g effect EffectName [options]
Effectファイルの作成

ng g reducer ReducerName [options]
Reducerファイルの作成

ng g entity EntityName [options]
Entityを扱う場合のファイル群を作成

ng g container ComponentName [options]
コンポーネント(html\css\spec\ts)の作成

ng g feature FeatureName [options]
機能ストアの初期設定、Actionファイル等の同時作成

ng g store State [options]
ストア(ルート、機能)、Stateの初期設定

参考

@ngrx/schematics


ステートツリー設計

アプリーケーション全体で利用するストア(ルートストア)のステートツリーは、次のような構成にします。

state

 └── session
   ├── session(セレクタ)
  └── loading(セレクタ)

ルートストアはアプリケーションが読み込まれると同時に読み込みを開始するので、コアモジュールに登録します。

また、ここで扱うステートにはセッションなどアプリケーション全体で利用するものが適しているので、今回はセッションとローディングを設定します。

それでは早速、Coreモジュールにストアとエフェクトを作成します。

// ルートストアの追加

ng g store State --root --statePath core/store/reducers --module core/core.module.ts
// ルートエフェクトの追加
ng g effect core/store/effects/Session --root --module core/core.module.ts

--root

ルートストアの設定を行います。この指定がない場合、自動的に機能ストア(別記事で詳述)が作成されます。

--statePath

ストアのパスを指定します。※ストア以外はng g effect パスのように指定します。

--module

対象としたいモジュールを指定します。

@ngrx/store-devtoolsが動作するようCoreモジュールにStoreDevtoolsModuleを追加し、reducers/index.tsにaction、stateのログ機能を実装しておきます。


app/core/core.module.ts

@NgModule({

imports: [
CommonModule,
RouterModule,
StoreModule.forRoot(reducers, { metaReducers }),
!environment.production ? StoreDevtoolsModule.instrument() : [],
EffectsModule.forRoot([SessionEffects]),
StoreDevtoolsModule.instrument({ // 追加
maxAge: 25, // stateの上限を設定
logOnly: environment.production, // 開発環境でのみ動作するよう制限
}),
],
exports: [
HeaderComponent,
],
declarations: [
HeaderComponent,
]
})


app/core/store/reducers/index.ts

export function logger(reducer: ActionReducer<State>) { // 追加

return (state, action) => {
const newState = reducer(state, action);
console.log('action', action);
console.log('state', newState);
return newState;
};
}

export const metaReducers: MetaReducer<State>[] = !environment.production ? [logger] : []; // 変更



@ngrx/store-devtools

@ngrx/store-devtoolsはChromeの拡張ツールを使って視覚的にステートツリーの状況確認を行うための開発用ツールです。拡張ツールをダウンロードした後、Chromeの開発者ツールから使用することができます。

どのような挙動をするかは実行結果で確認してください。

次に、Action、Reducerを追加します。

// Actionの追加

ng g action core/store/actions/Session
// Reducerの追加
ng g reducer core/store/reducers/Session --reducers index.ts

--reducers

reducerを加えたいストアを指定します(相対パス)。

これで必要なファイルは整いました。


Action、Reducerの実装

それではまずStateに変更を加える処理をリストアップし、Actionとして定義していきます。

Action名
起動タイミング
役割

LoadSessions
サイト訪問時
ログイン有無の問合せ

LoadSessionsSuccess
LoadSessionsが成功
ユーザーデータの取得

LoadSessionsFail
LoadSessionsが失敗
-

LoginSessions
ログインクリック時
ログイン有無の問合せ

LoginSessionsSuccess
LoginSessionsが成功
ユーザーデータの取得

LoginSessionsFail
LoginSessionsが失敗
-

LogoutSessions
ログアウトクリック時
ログインの無効化

LogoutSessionsSuccess
LogoutSessionsが成功
ユーザーデータの破棄

LogoutSessionsFail
LogoutSessionsが失敗
-

ここではLoad、Update、Logoutという3つの動作と、それぞれにSuccess、Failという非同期の結果(Side Effect)を加えた計9種類のActionを定義しました。

それぞれのActionに対し、クラスとタイプ、初期値を加えていきます。


app/core/store/actions/session.actions.ts

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

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

export enum SessionActionTypes {
LoadSessions = '[Session] Load',
LoadSessionsSuccess = '[Session] Load Success',
LoadSessionsFail = '[Session] Load Fail',
LoginSessions = '[Session] Login',
LoginSessionsSuccess = '[Session] Login Success',
LoginSessionsFail = '[Session] Login Fail',
LogoutSessions = '[Session] Logout',
LogoutSessionsSuccess = '[Session] Logout Success',
LogoutSessionsFail = '[Session] Logout Fail',
}

export class LoadSessions implements Action {
readonly type = SessionActionTypes.LoadSessions;
}

export class LoadSessionsSuccess implements Action {
readonly type = SessionActionTypes.LoadSessionsSuccess;

constructor(public payload: { session: Session }) {
}
}

export class LoadSessionsFail implements Action {
readonly type = SessionActionTypes.LoadSessionsFail;

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

export class LoginSessions implements Action {
readonly type = SessionActionTypes.LoginSessions;

constructor(public payload: { email: string, password: string }) {
}
}

export class LoginSessionsSuccess implements Action {
readonly type = SessionActionTypes.LoginSessionsSuccess;

constructor(public payload: { session: Session }) {
}
}

export class LoginSessionsFail implements Action {
readonly type = SessionActionTypes.LoginSessionsFail;

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

export class LogoutSessions implements Action {
readonly type = SessionActionTypes.LogoutSessions;
}

export class LogoutSessionsSuccess implements Action {
readonly type = SessionActionTypes.LogoutSessionsSuccess;

constructor(public payload: { session: Session }) {
}
}

export class LogoutSessionsFail implements Action {
readonly type = SessionActionTypes.LogoutSessionsFail;

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

export type SessionActions =
| LoadSessions
| LoadSessionsSuccess
| LoadSessionsFail
| LoginSessions
| LoginSessionsSuccess
| LoginSessionsFail
| LogoutSessions
| LogoutSessionsSuccess
| LogoutSessionsFail;


続いてReducerの実装に入ります。session.reducer.tsのState、initialStateに値を入力します。このとき、ローディングの状態も管理できるよう「loading」も追加しておきます。


app/core/store/reducers/session.reducer.ts

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

export interface State {
loading: boolean;
session: Session;
}

export const initialState: State = {
loading: false,
session: new Session()
};


Stateの設定が完了したら、reducerにアクション毎の処理を加えます。

非同期処理のトリガーとなるActionにはloading: trueを加え、その結果が更新され次第loading: falseにstateが上書きされるようにしておきます。


app/core/store/reducers/session.reducer.ts

export function reducer(

state = initialState,
action: SessionActions
): State {
switch (action.type) {
case SessionActionTypes.LoadSessions: {
return { ...state, loading: true };
}
case SessionActionTypes.LoadSessionsSuccess: {
return { ...state, loading: false, session: action.payload.session };
}
case SessionActionTypes.LoadSessionsFail: {
return { ...state, loading: false };
}
case SessionActionTypes.LoginSessions: {
return { ...state, loading: true };
}
case SessionActionTypes.LoginSessionsSuccess: {
return { ...state, loading: false, session: action.payload.session };
}
case SessionActionTypes.LoginSessionsFail: {
return { ...state, loading: false };
}
case SessionActionTypes.LogoutSessions: {
return { ...state, loading: true};
}
case SessionActionTypes.LogoutSessionsSuccess: {
return { ...state, loading: false, session: action.payload.session };
}
case SessionActionTypes.LogoutSessionsFail: {
return { ...state, loading: false };
}
default:
return state;
}
}

最後に特定のstateを取得できるようセレクタの設定を行います。

複数のreducerをストアで扱う場合、個別のreducerをreducers: ActionReducerMap<State>にまとめておく必要があります。

このとき、どのreducerのstateなのかをセレクタで指定し、View(コンポーネント)はそのセレクタを指定することでstateの取得を行います。

今回の実装であれば、まずcore/store/reducers/session.reducer.tsでstateを取得するためのメソッドを作成し、core/store/reducers/index.reducer.tscreateSelector()を使ってセレクタを作成します。


app/core/store/reducers/session.reducer.ts

export const getSessionLoading = (state: State) => state.loading;

export const getSessionData = (state: State) => state.session;


app/core/store/reducers/index.reducer.ts

export const selectSession = (state: State) => state.session;

export const getLoading = createSelector(selectSession, fromSession.getSessionLoading);
export const getSession = createSelector(selectSession, fromSession.getSessionData);

これでAction、Reducerの設定は完了しました。

次はEffectの設定を行います。


EffectでFirebaseのデータをストアに反映

まずはEffectにサイト訪問時のログイン状況確認用メソッドを加え、Sessionクラスのコンストラクタを更新します。


app/core/store/effects/session.effects.ts

import { Session, User } from '../../../class/chat';

import { User as fbUser } from 'firebase';

@Injectable()
export class SessionEffects {

constructor(private actions$: Actions,
private afAuth: AngularFireAuth,
private afs: AngularFirestore,
private router: Router) {}

@Effect()
loadSession$: Observable<Action> =
this.actions$.pipe(
ofType<LoadSessions>(SessionActionTypes.LoadSessions),
// ユーザーの認証状況を取得
switchMap(() => {
return this.afAuth.authState
.pipe(
take(1),
map((result: fbUser | null) => {
if (!result) {
// ユーザーが存在しなかった場合は、空のセッションを返す
return new LoadSessionsSuccess({ session: new Session() });
} else {
return result;
}
}),
catchError(this.handleLoginError<LoadSessionsFail>(
'fetchAuth', new LoadSessionsFail())
)
);
}),
// ユーザーの認証下情報を取得
switchMap((auth: fbUser | LoadSessionsSuccess | LoadSessionsFail) => {
// ユーザーが存在しなかった場合は、認証下情報を取得しない
if (auth instanceof LoadSessionsSuccess || auth instanceof LoadSessionsFail) {
return of(auth);
}
return this.afs
.collection<User>('users')
.doc(auth.uid)
.valueChanges()
.pipe(
take(1),
map((result: User) => {
return new LoadSessionsSuccess({
session: new Session(result)
});
}),
catchError(this.handleLoginError<LoadSessionsFail>(
'fetchUser', new LoadSessionsFail())
)
);
})
);

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

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

// ログアウト処理
this.afAuth.auth.signOut()
.then(() => this.router.navigate([ '/account/login' ]));

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



class/chat.ts

export class Session {

login: boolean;
user: User;

constructor(init?: User) { // 変更
this.login = (!!init);
this.user = (init) ? new User(init.uid, init.name) : new User();
}


Effectのメソッドには、@Effect()デコレータを加えます。

これにより、ofType()オペレータで指定したActionをdispatchした段階で該当メソッドを発火させることができます。

その後、Firebaseに認証状況を問合せ、その結果によって認証下情報(users)の問合せに移行します。

これらの問合せが成功した場合にはLoadSessionsSuccess()、失敗した場合にはLoadSessionsFail()のActionを発生させ、その結果をStateに反映させます。

エラー発生時にはcatchError()オペレータを使用し、handleLoginError()メソッドに渡しています。

エラー発生が確認できた場合は、その処理名、エラー内容を表示し、ログアウト処理を行っています。

同様にログインクリック時、ログアウト時のエフェクトを追加します。


app/core/store/effects/session.effects.ts

  @Effect()

loginSession$: Observable<Action> =
this.actions$.pipe(
ofType<LoginSessions>(SessionActionTypes.LoginSessions),
map(action => action.payload),
switchMap((payload: { email: string, password: string }) => {
return this.afAuth
.auth
.signInWithEmailAndPassword(payload.email, payload.password)
.then(auth => {
// ユーザーが存在しなかった場合は、空のセッションを返す
if (!auth.user.emailVerified) {
alert('メールアドレスが確認できていません。');
this.afAuth.auth.signOut()
.then(() => this.router.navigate([ '/account/login' ]));
return new LoginSessionsSuccess({ session: new Session() });
} else {
return auth.user;
}
})
.catch(err => {
alert('ログインに失敗しました。\n' + err);
return new LoginSessionsFail({ error: err });
}
);
}),
switchMap((auth: fbUser | LoginSessionsSuccess | LoginSessionsFail) => {
// ユーザーが存在しなかった場合は、空のセッションを返す
if (auth instanceof LoginSessionsSuccess || auth instanceof LoginSessionsFail) {
return of(auth);
}
return this.afs
.collection<User>('users')
.doc(auth.uid)
.valueChanges()
.pipe(
take(1),
map((result: User) => {
alert('ログインしました。');
this.router.navigate([ '/' ]);
return new LoginSessionsSuccess({
session: new Session(result)
});
}),
catchError(this.handleLoginError<LoginSessionsFail>(
'loginUser', new LoginSessionsFail(), 'login'
))
);
})
);

@Effect()
logoutSession$: Observable<Action> =
this.actions$.pipe(
ofType<LogoutSessions>(SessionActionTypes.LogoutSessions),
switchMap(() => this.afAuth.auth.signOut()),
switchMap(() => {
return this.router.navigate([ '/account/login' ])
.then(() => {
alert('ログアウトしました。');
return new LogoutSessionsSuccess({
session: new Session()
});
});
}),
catchError(this.handleLoginError<LogoutSessionsFail>(
'logoutUser', new LogoutSessionsFail(), 'logout'
))
);

// エラー発生時の処理
private handleLoginError<T>(operation = 'operation', result: T, dialog?: 'login' | 'logout') { // 変更
return (error: any): Observable<T> => {

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

// アラートダイアログの表示 // 追加
if (dialog === 'login') {
alert('ログインに失敗しました。\n' + error);
}

if (dialog === 'logout') {
alert('ログアウトに失敗しました。\n' + error);
}

// ログアウト処理
this.afAuth.auth.signOut()
.then(() => this.router.navigate([ '/account/login' ]));

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


これでエフェクトの実装は完了です。

これにより、sessionStateからデータを取得する処理が不要になったので、session.service.ts内の記述を変更します。


app/core/service/session.service.ts

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

import { Observable } from 'rxjs'; // 変更
import { Router } from '@angular/router';
import { AngularFireAuth } from '@angular/fire/auth';
import { map } from 'rxjs/operators'; // 変更
import { AngularFirestore } from '@angular/fire/firestore';
import { Store } from '@ngrx/store'; // 追加

import { Password, User } from '../../class/chat'; // 変更
import * as fromCore from '../../core/store/reducers'; // 追加
import { LoadSessions, LogoutSessions, UpdateSessions } from '../store/actions/session.actions'; // 追加

/* 省略 */

// public session = new Session(); // 削除
// public sessionSubject = new Subject<Session>(); // 削除
// public sessionState = this.sessionSubject.asObservable(); // 削除

constructor(private router: Router,
private afAuth: AngularFireAuth,
private afs: AngularFirestore,
private store: Store<fromCore.State>) { // 追加
}

// ログイン状況確認
checkLogin(): void { // 変更
this.store.dispatch(new LoadSessions());
}

// ログイン状況確認(State)
checkLoginState(): Observable<{ login: boolean }> { // 変更
return this.afAuth
.authState
.pipe(
map((auth: any) => {
// ログイン状態を返り値の有無で判断
return { login: !!auth };
})
);
}

login(account: Password): void { // 変更
this.store.dispatch(new LoginSessions({email: account.email, password: account.password}));
}

logout(): void { // 変更
this.store.dispatch(new LogoutSessions());
}

/* 省略 */

// ユーザーを取得 // 削除



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

上記で設定したルートストアのStateを、チャット画面とヘッダーに反映します。

コンポーネントにStateを反映させたいときは、Reducerで設定したセレクタをコンストラクタでDIします。


app/chat/chat.component.ts

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

// import { SessionService } from '../core/service/session.service'; // 削除
import { Store } from '@ngrx/store'; // 追加
import * as fromCore from '../core/store/reducers'; // 追加

/* 省略 */

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


これでルートストアのStateをチャット画面に反映しました。

続いてヘッダーにStateを反映します。


app/core/header/header.component.ts

import { Observable } from 'rxjs'; // 追加

import { Store } from '@ngrx/store'; // 追加

import * as fromCore from '../store/reducers'; // 追加

/* 省略 */

public session$: Observable<Session>; // 追加
// public login = false; // 削除

constructor(private sessionService: SessionService,
private store: Store<fromCore.State>) { // 変更
this.session$ = this.store.select(fromCore.getSession);
}

ngOnInit() {
// 削除
}



app/core/header/header.component.html

<nav class="navbar fixed-top navbar-dark bg-primary">

<a class="navbar-brand" href="#">NgChat</a>
<!-- ログイン状態で分岐 -->
<span class="navbar-text" *ngIf="!(session$ | async)?.login"><!-- 変更 -->
<a routerLink="/account/login">Login</a>
</span>
<span class="navbar-text" *ngIf="(session$ | async)?.login"><!-- 変更 -->
<a routerLink="/account/login" (click)="logout()">Logout</a>
</span>
<!-- 分岐ここまで -->
</nav>

チャット画面と同様にルートストアをDIし、非同期処理に対応できるようhtmlも更新しました。


非同期処理時のローディングをヘッダーに追加

最後に、非同期処理時のローディングバーをヘッダーに追加します。

Reducerで指定したloadingセレクタをヘッダーコンポーネントでDIし、LoadSessions()の起動からLoadSessionsSuccess()、もしくはLoadSessionsFail()が完了するまでの状態を反映できるようにします。

header.component.tsloading$header.component.htmlにBootstrapのプログレスバーを追加します。


app/core/header/header.component.ts

  public loading$: Observable<boolean>; // 追加

public session$: Observable<Session>;

constructor(private sessionService: SessionService,
private store: Store<fromCore.State>) {
this.loading$ = this.store.select(fromCore.getLoading); // 追加
this.session$ = this.store.select(fromCore.getSession);
}



app/core/header/header.component.html

<nav class="navbar fixed-top navbar-dark bg-primary">

<a class="navbar-brand" href="#">NgChat</a>
<!-- ログイン状態で分岐 -->
<span class="navbar-text" *ngIf="!(session$ | async)?.login">
<a routerLink="/account/login">Login</a>
</span>
<span class="navbar-text" *ngIf="(session$ | async)?.login">
<a routerLink="/account/login" (click)="logout()">Logout</a>
</span>
<!-- 分岐ここまで -->
</nav>
<div class="progress" style="height: 3px;" *ngIf="(loading$ | 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>


app/core/header/header.component.css

.progress { /* 追加 */

position: absolute;
top: 0;
width: 100%;
z-index: 1050;
}


実行結果

Oct-26-2018 17-27-23.gif

これでルートストアの設定、およびViewへのStateの反映が完了しました。

次は機能ストアの設定、およびエンティティの設定を行います。


ソースコード

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

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