この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angularのngrxを使って状態管理を行う(理論編)
次記事:Angularのngrxを使って状態管理を行う(実装編:エンティティ設定)
この記事で行うこと
前回の記事ではngrxの概念と基本的な構成を扱いました。
本記事ではngrxの初期設定から、エフェクト設定などの具体的な実装方法について学習します。
(追記:2020/6)現時点(2020年6月)での最新の内容に書き換えています。
実装内容
ngrxの初期設定
ライブラリインストール
まず、コードジェネレーターである@ngrx/schematics
を開発環境に、それ以外のライブラリをプロジェクトにインストールします。
ng add @ngrx/schematics
? Do you want to use @ngrx/schematics as the default collection? y
npm install @ngrx/store @ngrx/effects @ngrx/store-devtools --save
(注)
AngularとFirebaseの環境構築は過去の記事から行ってください。
また、この実装はAngular6以上であることが必要ですので注意してください。
(2020/06追記)
NgRx v7.4から Action Creator機能が追加され、記述の省略化ができるようになりました。本記事でもその機能を使って、コードを全面的に修正しています。
参考: NgRx v7.4で導入されるAction Creatorの使い方
@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の初期設定 |
ステートツリー設計
アプリーケーション全体で利用するストア(ルートストア)のステートツリーは、次のような構成にします。
state
└── session
├── session(セレクタ)
└── loading(セレクタ)
ルートストアはアプリケーションが読み込まれると同時に読み込みを開始するので、コアモジュールに登録します。
また、ここで扱うステートにはセッションなどアプリケーション全体で利用するものが適しているので、今回はセッションとローディングを設定します。
それでは早速、Coreモジュールにストアとエフェクトを作成します。
// ルートストアの追加
ng g module app-store --module app.module.ts
ng g store State --root --statePath app-store/reducers --module app-store/app-store.module.ts
// ルートエフェクトの追加
ng g effect app-store/effects/Session --root --module app-store/app-store.module.ts
? Should we wire up success and failure actions? Yes
? Do you want to use the create function? Yes
--root
ルートストアの設定を行います。この指定がない場合、自動的に機能ストア(別記事で詳述)が作成されます。
--statePath
ストアのパスを指定します。※ストア以外はng g effect パス
のように指定します。
--module
対象としたいモジュールを指定します。
@ngrx/store-devtools
が動作するようreducers/index.ts
にaction、stateのログ機能を実装しておきます。
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 app-store/actions/Session
? Should we generate success and failure actions? Yes
? Do you want to use the create function? Yes
// Reducerの追加
ng g reducer app-store/reducers/Session --reducers index.ts
? Should we add success and failure actions to the reducer? Yes
? Do you want to use the create function? Yes
--reducers
reducerを加えたいストアを指定します(相対パス)。
これで必要なファイルは整いました。
Action、Reducerの実装
それではまずStateに変更を加える処理をリストアップし、Actionとして定義していきます。
Action名 | 起動タイミング | 役割 |
---|---|---|
loadSessions | サイト訪問時 | ログイン有無の問合せ |
loadSessionsSuccess | loadSessionsが成功 | ユーザーデータの取得 |
loadSessionsFailure | loadSessionsが失敗 | - |
loginSessions | ログインクリック時 | ログイン有無の問合せ |
loginSessionsSuccess | loginSessionsが成功 | ユーザーデータの取得 |
loginSessionsFailure | loginSessionsが失敗 | - |
logoutSessions | ログアウトクリック時 | ログインの無効化 |
logoutSessionsSuccess | logoutSessionsが成功 | ユーザーデータの破棄 |
logoutSessionsFailure | logoutSessionsが失敗 | - |
ここではLoad、Login、Logoutという3つの動作と、それぞれにSuccess、Failureという非同期の結果(Side Effect)を加えた計9種類のActionを定義しました。
それぞれのActionに対し、クラスとタイプ、初期値を加えていきます。
import { createAction, props, union } from '@ngrx/store';
import { Session } from '../../class/chat';
export const loadSessions = createAction(
'[Session] Load Sessions'
);
export const loadSessionsSuccess = createAction(
'[Session] Load Sessions Success',
(payload: { session: Session }) => ({ payload }),
);
export const loadSessionsFailure = createAction(
'[Session] Load Sessions Failure',
(payload: { error: any }) => ({ payload }),
);
export const loginSessions = createAction(
'[Session] Login Sessions',
(payload: { email: string, password: string }) => ({ payload }),
);
export const loginSessionsSuccess = createAction(
'[Session] Login Sessions Success',
(payload: { session: Session }) => ({ payload }),
);
export const loginSessionsFailure = createAction(
'[Session] Login Sessions Failure',
(payload: { error: any }) => ({ payload }),
);
export const logoutSessions = createAction(
'[Session] Logout Sessions'
);
export const logoutSessionsSuccess = createAction(
'[Session] Logout Sessions Success',
(payload: { session: Session }) => ({ payload }),
);
export const logoutSessionsFailure = createAction(
'[Session] Logout Sessions Failure',
(payload: { error: any }) => ({ payload }),
);
const actions = union({
loadSessions,
loadSessionsSuccess,
loadSessionsFailure,
loginSessions,
loginSessionsSuccess,
loginSessionsFailure,
logoutSessions,
logoutSessionsSuccess,
logoutSessionsFailure,
});
export type SessionUnionActions = typeof actions;
続いてReducerの実装に入ります。session.reducer.ts
のState、initialStateに値を入力します。このとき、ローディングの状態も管理できるよう「loading」も追加しておきます。
import { createReducer, on } from '@ngrx/store';
import { Session } from '../../class/chat';
import * as SessionActions from '../actions/session.actions';
export const sessionFeatureKey = 'session';
export interface State {
loading: boolean;
session: Session;
}
export const initialState: State = {
loading: false,
session: new Session(),
};
Stateの設定が完了したら、reducerにアクション毎の処理を加えます。
非同期処理のトリガーとなるActionにはloading: true
を加え、その結果が更新され次第loading: false
にstateが上書きされるようにしておきます。
export const reducer = createReducer(
initialState,
on(SessionActions.loadSessions, state => ({ ...state, loading: true })),
on(SessionActions.loadSessionsSuccess, (state, action) => ({ ...state, loading: false, session: action.payload.session })),
on(SessionActions.loadSessionsFailure, state => ({ ...state, loading: false })),
on(SessionActions.loginSessions, state => ({ ...state, loading: true })),
on(SessionActions.loginSessionsSuccess, (state, action) => ({ ...state, loading: false, session: action.payload.session })),
on(SessionActions.loginSessionsFailure, state => ({ ...state, loading: false })),
on(SessionActions.logoutSessions, state => ({ ...state, loading: true })),
on(SessionActions.logoutSessionsSuccess, () => initialState),
on(SessionActions.logoutSessionsFailure, state => ({ ...state, loading: false })),
);
最後に特定のstateを取得できるようセレクタの設定を行います。
どのreducerのstateなのかをセレクタで指定し、View(コンポーネント)はそのセレクタを指定することでstateの取得を行います。
今回の実装であれば、まずapp-store/reducers/session.reducer.ts
でstateを取得するためのメソッドを作成し、app-store/reducers/index.reducer.ts
でcreateSelector()
を使ってセレクタを作成します。
export const getSessionLoading = (state: State) => state.loading;
export const getSessionData = (state: State) => state.session;
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クラスのコンストラクタを更新します。
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Session, User } from '../../class/chat';
import { User as fbUser } from 'firebase';
import { AngularFireAuth } from '@angular/fire/auth';
import { AngularFirestore } from '@angular/fire/firestore';
import { Router } from '@angular/router';
import * as SessionActions from '../actions/session.actions';
import { Observable, of } from 'rxjs';
import { switchMap, take, map, catchError } from 'rxjs/operators';
@Injectable()
export class SessionEffects {
constructor(private actions$: Actions<SessionActions.SessionUnionActions>,
private afAuth: AngularFireAuth,
private afs: AngularFirestore,
private router: Router) {
}
loadSession$ = createEffect(() =>
this.actions$.pipe(
ofType(SessionActions.loadSessions),
switchMap(() => {
return this.afAuth.authState
.pipe(
take(1),
map((result: fbUser | null) => {
if (!result) {
// ユーザーが存在しなかった場合は、空のセッションを返す
return SessionActions.loadSessionsSuccess({ session: new Session() });
} else {
return result;
}
}),
catchError(this.handleLoginError(
'fetchAuth', SessionActions.loadSessionsFailure)
)
);
}),
// ユーザーの認証下情報を取得
switchMap((auth: fbUser | any) => {
// ユーザーが存在しなかった場合は、認証下情報を取得しない
if (!auth.uid) {
return of(auth);
}
return this.afs
.collection<User>('users')
.doc(auth.uid)
.valueChanges()
.pipe(
take(1),
map((result: User) => {
return SessionActions.loadSessionsSuccess({
session: new Session(result)
});
}),
catchError(this.handleLoginError(
'fetchUser', SessionActions.loadSessionsFailure)
)
);
})
)
);
// エラー発生時の処理
private handleLoginError(operation = 'operation', result: any) { // 変更
return (error: any): Observable<any> => {
// 失敗した操作の名前、エラーログをconsoleに出力
console.error(`${operation} failed: ${error.message}`);
// ログアウト処理
this.afAuth.signOut()
.then(() => this.router.navigate(['/account/login']));
// 結果を返して、アプリを持続可能にする
return of(result);
};
}
}
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した段階で該当メソッドを発火させることができます。
(2020/06追記)
createEffect()
メソッドを使うことによって、@Effect()
デコレータを使わなくともエフェクトの設定が可能になりました。
その後、Firebaseに認証状況を問合せ、その結果によって認証下情報(users)の問合せに移行します。
これらの問合せが成功した場合にはloadSessionsSuccess()
、失敗した場合にはloadSessionsFailure()
のActionを発生させ、その結果をStateに反映させます。
エラー発生時にはcatchError()
オペレータを使用し、handleLoginError()
メソッドに渡しています。
エラー発生が確認できた場合は、その処理名、エラー内容を表示し、ログアウト処理を行っています。
同様にログインクリック時、ログアウト時のエフェクトを追加します。
loginSession$ = createEffect(() =>
this.actions$.pipe(
ofType(SessionActions.loginSessions),
map(action => action.payload),
switchMap((payload: { email: string, password: string }) => {
return this.afAuth
.signInWithEmailAndPassword(payload.email, payload.password)
.then(auth => {
// ユーザーが存在しなかった場合は、空のセッションを返す
if (!auth.user.emailVerified) {
alert('メールアドレスが確認できていません。');
this.afAuth.signOut()
.then(() => this.router.navigate(['/account/login']));
return SessionActions.loginSessionsSuccess({ session: new Session() });
} else {
return auth.user;
}
})
.catch(err => {
alert('ログインに失敗しました。\n' + err);
return SessionActions.loginSessionsFailure({ error: err });
}
);
}),
switchMap((auth: fbUser | any) => {
// ユーザーが存在しなかった場合は、空のセッションを返す
if (!auth.uid) {
return of(auth);
}
return this.afs
.collection<User>('users')
.doc(auth.uid)
.valueChanges()
.pipe(
take(1),
map((result: User) => {
alert('ログインしました。');
this.router.navigate(['/']);
return SessionActions.loginSessionsSuccess({
session: new Session(result)
});
}),
catchError(this.handleLoginError(
'loginUser', SessionActions.loginSessionsFailure, 'login'
))
);
})
)
);
logoutSession$ = createEffect(() =>
this.actions$.pipe(
ofType(SessionActions.logoutSessions),
switchMap(() => this.afAuth.signOut()),
switchMap(() => {
return this.router.navigate(['/account/login'])
.then(() => {
alert('ログアウトしました。');
return SessionActions.logoutSessionsSuccess({
session: new Session()
});
});
}),
catchError(this.handleLoginError(
'logoutUser', SessionActions.logoutSessionsFailure, 'logout'
))
)
);
// エラー発生時の処理
private handleLoginError(operation = 'operation', result: any, dialog?: 'login' | 'logout') { // 変更
return (error: any): Observable<any> => {
// 失敗した操作の名前、エラーログをconsoleに出力
console.error(`${operation} failed: ${error.message}`);
// アラートダイアログの表示 // 追加
if (dialog === 'login') {
alert('ログインに失敗しました。\n' + error);
}
if (dialog === 'logout') {
alert('ログアウトに失敗しました。\n' + error);
}
// ログアウト処理
this.afAuth.signOut()
.then(() => this.router.navigate(['/account/login']));
// 結果を返して、アプリを持続可能にする
return of(result);
};
}
これでエフェクトの実装は完了です。
これにより、sessionState
からデータを取得する処理が不要になったので、session.service.ts
内の記述を変更します。
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs'; // 変更
import { map, switchMap, take } from 'rxjs/operators';
// import { User as fbUser } from 'firebase/app'; // 削除
import { Router } from '@angular/router';
import { AngularFireAuth } from '@angular/fire/auth';
import { AngularFirestore } from '@angular/fire/firestore';
import { Store } from '@ngrx/store'; // 追加
import { Password, User } from '../class/chat'; // 変更
import * as fromSession from '../app-store/reducers'; // 追加
import * as SessionActions from '../app-store/actions/session.actions'; // 追加
@Injectable({
providedIn: 'root'
})
export class SessionService {
// 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<fromSession.State>) { // 追加
}
// ログイン状況確認
checkLogin(): void { // 変更
this.store.dispatch(SessionActions.loadSessions());
}
// ログイン状況確認(State)
checkLoginState(): Observable<{ login: boolean }> { // 変更
return this.afAuth
.authState
.pipe(
map((auth: any) => {
// ログイン状態を返り値の有無で判断
return { login: !!auth };
})
);
}
login(account: Password): void { // 変更
this.store.dispatch(SessionActions.loginSessions({ email: account.email, password: account.password }));
}
logout(): void { // 変更
this.store.dispatch(SessionActions.logoutSessions());
}
/* 省略 */
// ユーザーを取得 // 削除
View(コンポーネント)にStateを反映
上記で設定したルートストアのStateを、チャット画面とヘッダーに反映します。
コンポーネントにStateを反映させたいときは、Reducerで設定したセレクタをコンストラクタでDIします。
import { Comment, User } from '../class/chat';
import { AngularFirestore } from '@angular/fire/firestore';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
// import { SessionService } from '../service/session.service'; // 削除
import { Store } from '@ngrx/store'; // 追加
import * as fromSession from '../app-store/reducers'; // 追加
/* 省略 */
// DI(依存性注入する機能を指定)
constructor(private db: AngularFirestore,
private store: Store<fromSession.State>) { // 追加
this.store.select(fromSession.getSession) // 変更
.subscribe(data => {
this.currentUser = data.user;
});
}
これでルートストアのStateをチャット画面に反映しました。
続いてヘッダーにStateを反映します。
import { Component, OnInit } from '@angular/core';
import { SessionService } from '../service/session.service';
import { Session } from '../class/chat';
import { Observable } from 'rxjs'; // 追加
import { Store } from '@ngrx/store'; // 追加
import * as fromSession from '../app-store/reducers'; // 追加
/* 省略 */
public session$: Observable<Session>; // 追加
// public login = false; // 削除
constructor(private sessionService: SessionService,
private store: Store<fromSession.State>) { // 変更
this.session$ = this.store.select(fromSession.getSession);
}
ngOnInit() {
// 削除
}
<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.ts
にloading$
、header.component.html
にBootstrapのプログレスバーを追加します。
public loading$: Observable<boolean>; // 追加
public session$: Observable<Session>; // 追加
constructor(private sessionService: SessionService,
private store: Store<fromSession.State>) { // 変更
this.loading$ = this.store.select(fromSession.getLoading); // 追加
this.session$ = this.store.select(fromSession.getSession);
}
<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>
.progress { /* 追加 */
position: absolute;
top: 0;
width: 100%;
z-index: 1050;
}
実行結果
これでルートストアの設定、およびViewへのStateの反映が完了しました。
次は機能ストアの設定、およびエンティティの設定を行います。
ソースコード
この時点でのソースコード
※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。