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

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

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

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

次記事:準備中


この記事で行うこと

前回の記事では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を入れてください。

コメント

このブログの人気の投稿

投稿時間:2021-06-17 05:05:34 RSSフィード2021-06-17 05:00 分まとめ(1274件)

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2020-12-01 09:41:49 RSSフィード2020-12-01 09:00 分まとめ(69件)