②Angular ngrx/entityでよくあるCRUD操作を簡単に書いてみる

この記事は アイソルートAdventCalendar2019 20日目の記事です。
こんにちは。
クラウドソリューショングループのokawa.mです。
今回はNGRXで便利なライブラリのngrx/entityについて解説していこうと思います。
①Angular ngrx/storeを分かった気になってみる ← 昨日の記事
②Angular ngrx/entityでよくあるCRUD操作を簡単に書いてみる ← 今日の記事
ngrx/entityとは
タイトルにもある通り、StoreのよくあるCRUD操作を簡単にしてくれます。
指定された型(EntityState)で定義することで使えます。
EntityState型の紹介
突然ですが、ユーザコレクションをStoreに保存したいとします。
どのように保存すればよいでしょうか?
一つはusersプロパティの下に配列で格納することです。
{
  "users": [
    {
      "id": 1,
      "name": "ISO太郎",
    },
    {
      "id": 2,
      "name": "ISO太郎2",
    }
  ]
}
この方法は何点か問題があります。
特定のIDに基づいてユーザを検索する場合、コレクション全体をループする必要があります。
これは、非常に大きなコレクションには非効率です。
配列を使用することで、同じユーザの異なるバージョン(同じID)を配列に格納することも可能です。
よくないですね。
Storeの役割の一つは、データベース全体のインメモリクライアント側データベースとして機能することです。このデータベースから、Selectorを介してクライアント側のビューモデルを出力します。
ストアはインメモリデータベースであるため、インメモリデータベースに保存し、それらにプライマリキーに似た一意の識別子を与えるのがいいと思います。
次に思いつくのが、オブジェクト形式でコレクションを保存することです。
{
  "users": {
    "1": {
      "id": 1,
      "name": "ISO太郎"
    },
    "2": {
      "id": 2,
      "name": "ISO太郎2"
    }
  }
}
この形式により、ユーザをIDで簡単に検索できます。
無駄なループも必要なく、ユーザのidが被ることはありません。
ただし、問題が1つだけあります。
コレクションの順序に関する情報が失われました。
JavaScriptオブジェクトには順序保証がないからです。
コレクションの順序に関する情報を保存するために、配列を使います。
{
  "users": {
    "ids": ["1", "2"],
    "entities": {
      "1": {
        "id": 1,
        "name": "iso太郎"
      },
      "2": {
        "id": 2,
        "name": "iso太郎2"
      }
    }
  }
}
この形式で保存すれば、全ての問題が解決します。
長らく解説してきましたが、最後の形式をngrx/entityではEntityStateという型で定義してあります。
本当によく考えられた型です。
ngrx/entityではこの型情報を元にCRUD操作を行います。では実際のコードを見てみましょう。
サンプルコード
ほぼ公式のサンプルです。
また、今回は解説するのが多すぎるため、コンポーネント側のコードは解説しません。
ご了承くださいませ。
userModel
export interface User {
  id: string;
  name: string;
}
actions
import { createAction, props } from '@ngrx/store';
import { Update, EntityMap, Predicate } from '@ngrx/entity';
import { User } from './user';
enum ACTIONS {
  LOAD_USERS = '[User/API] Load Users',
  ADD_USER = '[User/API] Add User',
  UPSERT_USER = '[User/API] Upsert User',
  ADD_USERS = '[User/API] Add Users',
  UPSERT_USERS = '[User/API] Upsert Users',
  UPDATE_USER = '[User/API] Update User',
  UPDATE_USERS = '[User/API] Update Users',
  MAP_USERS = '[User/API] Map Users',
  DELETE_USER = '[User/API] Delete User',
  DELETE_USERS = '[User/API] Delete Users',
  DELETE_USERS_BY_PREDICATE = '[User/API] Delete Users By Predicate',
  CLEAR_USERS = '[User/API] Clear Users',
}
export const loadUsers = createAction(ACTIONS.LOAD_USERS, props<{ users: User[] }>());
export const addUser = createAction(ACTIONS.ADD_USER, props<{ user: User }>());
export const upsertUser = createAction(ACTIONS.ADD_USERS, props<{ user: User }>());
export const addUsers = createAction(ACTIONS.ADD_USERS, props<{ users: User[] }>());
export const upsertUsers = createAction(ACTIONS.UPSERT_USERS, props<{ users: User[] }>());
export const updateUser = createAction(ACTIONS.UPDATE_USER, props<{ user: Update<User> }>());
export const updateUsers = createAction(ACTIONS.UPDATE_USERS, props<{ users: Update<User>[] }>());
export const mapUsers = createAction(ACTIONS.MAP_USERS, props<{ entityMap: EntityMap<User> }>());
export const deleteUser = createAction(ACTIONS.DELETE_USER, props<{ id: string }>());
export const deleteUsers = createAction(ACTIONS.DELETE_USERS, props<{ ids: string[] }>());
export const deleteUsersByPredicate = createAction(ACTIONS.DELETE_USERS_BY_PREDICATE, props<{ predicate: Predicate<User> }>());
export const clearUsers = createAction(ACTIONS.CLEAR_USERS);
Actionは特段難しいことはありません(列挙するだけですので)。
ngrx/entityではupdateではUpdate, EntityMap, 削除の一部ではPredicateという型を使用します。
EntityMapとPredicateは関数を引数として渡す特殊な型ですので、初めての方は一旦他のActionをマスターしてから触れるのをお勧めします。
reducer
import { Action, createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { User } from './user';
import * as UserActions from './user.action';
export interface State extends EntityState<User> {
  // additional entities state properties
  selectedUserId: number | null;
}
export const adapter: EntityAdapter<User> = createEntityAdapter<User>();
export const initialState: State = adapter.getInitialState({
  // additional entity state properties
  selectedUserId: null,
});
const userReducer = createReducer(
  initialState,
  on(UserActions.addUser, (state, { user }) => {
    return adapter.addOne(user, state);
  }),
  on(UserActions.upsertUser, (state, { user }) => {
    return adapter.upsertOne(user, state);
  }),
  on(UserActions.addUsers, (state, { users }) => {
    return adapter.addMany(users, state);
  }),
  on(UserActions.upsertUsers, (state, { users }) => {
    return adapter.upsertMany(users, state);
  }),
  on(UserActions.updateUser, (state, { user }) => {
    return adapter.updateOne(user, state);
  }),
  on(UserActions.updateUsers, (state, { users }) => {
    return adapter.updateMany(users, state);
  }),
  on(UserActions.mapUsers, (state, { entityMap }) => {
    return adapter.map(entityMap, state);
  }),
  on(UserActions.deleteUser, (state, { id }) => {
    return adapter.removeOne(id, state);
  }),
  on(UserActions.deleteUsers, (state, { ids }) => {
    return adapter.removeMany(ids, state);
  }),
  on(UserActions.deleteUsersByPredicate, (state, { predicate }) => {
    return adapter.removeMany(predicate, state);
  }),
  on(UserActions.loadUsers, (state, { users }) => {
    return adapter.addAll(users, state);
  }),
  on(UserActions.clearUsers, state => {
    return adapter.removeAll({ ...state, selectedUserId: null });
  })
);
export function reducer(state: State | undefined, action: Action) {
  return userReducer(state, action);
}
export const getSelectedUserId = (state: State) => state.selectedUserId;
// get the selectors
const {
  selectIds,
  selectEntities,
  selectAll,
  selectTotal,
} = adapter.getSelectors();
// select the array of user ids
export const selectUserIds = selectIds;
// select the dictionary of user entities
export const selectUserEntities = selectEntities;
// select the array of users
export const selectAllUsers = selectAll;
// select the total user count
export const selectUserTotal = selectTotal;
reducerはngrx/storeでは登場しなかった、adapterが追加されています。
adapterはエンティティに対する操作のメソッドを提供します(Create, Write, Delete)。
addOne:コレクションに1つのエンティティを追加 addMany:複数のエンティティをコレクションに追加 addAll:現在のコレクションを新たなコレクションで置換 removeOne:コレクションから1つのエンティティを削除 removeMany:コレクションから複数のエンティティを削除 removeAll:コレクションをクリア updateOne:コレクション内の1つのエンティティを更新 updateMany:コレクション内の複数のエンティティを更新 upsertOne:コレクション内の1つのエンティティを追加または更新 upsertMany:コレクション内の複数のエンティティを追加または更新 map:Array.mapと同様に、マップ関数を定義してコレクション内の複数のエンティティを更新
また、adapterは便利なSelectorを提供します(Read)。
selectIds: idsをセレクト selectEntities: entitiesをセレクト selectAll: entitiesの配列をセレクト selectTotal: entitiesの大きさ(例えばユーザの人数)をセレクト
特にselectAllは便利で、EntityState型から配列の形に変換してくれるので、コンポーネント側では重宝します。
selector
import {
  createSelector,
  createFeatureSelector,
  ActionReducerMap,
} from '@ngrx/store';
import * as fromUser from './user.reducer';
export interface State {
  users: fromUser.State;
}
export const reducers: ActionReducerMap<State> = {
  users: fromUser.reducer,
};
export const selectUserState = createFeatureSelector<fromUser.State>('users');
export const selectUserIds = createSelector(
  selectUserState,
  fromUser.selectUserIds
);
export const selectUserEntities = createSelector(
  selectUserState,
  fromUser.selectUserEntities
);
export const selectAllUsers = createSelector(
  selectUserState,
  fromUser.selectAllUsers
);
export const selectUserTotal = createSelector(
  selectUserState,
  fromUser.selectUserTotal
);
export const selectCurrentUserId = createSelector(
  selectUserState,
  fromUser.getSelectedUserId
);
export const selectCurrentUser = createSelector(
  selectUserEntities,
  selectCurrentUserId,
  (userEntities, userId) => userEntities[userId]
);
Selectorはngrx/storeと変わっていません。
終わりに
ngrx/entityを使うと簡単にCRUD操作が実現可能です。
既存の型をEntityState型に合わせないといけないのが若干面倒なところではありますが、
その分のreturnはあると思います!!
是非NGRXを使っていて、ngrx/entityを使ったことがない方は試してみてください!!
明日22日目はyamazaki.hの「Realmの基礎知識 〜特徴と強みの再認識〜」です。よろしくお願いいたします。
参考
https://ngrx.io/guide/entity
https://blog.angular-university.io/ngrx-entity/
  







