Розробка

Angular-ngrx-data — state management і CRUD за п’ять хвилин

На сьогоднішній день жодна велика SPA програма не обходиться без state management (управління). Для Angular за даним напрямом є кілька рішень. Найпопулярнішим з них є NgRx. Він реалізує Redux патерн з використанням бібліотеки RxJs і володіє хорошим інструментарієм.
У даній статті ми коротко пройдемося по основним модулям NgRx і більш детально зосередимося на бібліотеці angular-ngrx-data, яка дозволяє зробити повноцінний CRUD зі state management за п’ять хвилин.

Огляд NgRx

Детально про NgRx можна почитати в наступних статтях:
— Реактивні програми на Angular/NGRX. Частина 1. Введення
— Реактивні програми на Angular/NGRX. Частина 2. Store
— Реактивні програми на Angular/NGRX. Частина 3. Effects

Коротко розглянемо основні модулі NgRx, його плюси і мінуси.

NgRx/store — реалізує Redux патерн.
Проста реалізація storecounter.actions.ts

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';

counter.reducer.ts

import { Action } from '@ngrx/store';
const initialState = 0;

export function counterReducer(state: number = initialState, action: Action) {
 switch (action.type) {
 case INCREMENT:
 return state + 1;

 case DECREMENT:
 return state - 1;

 case RESET:
 return 0;

default:
 return state;
}
}

.
Підключення до модуля

import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { counterReducer } from './counter';

@NgModule({
 imports: [StoreModule.forRoot({ count: counterReducer })],
})
export class AppModule {}

Використання в компоненті

import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Вами } from 'rxjs';
import { INCREMENT, DECREMENT, RESET } from './counter';

interface AppState {
 count: number;
}

@Component({
 selector: 'app-my-counter',
 template: `
 <button (click)="increment()">Increment</button>
 <div>Current Count: {{ count$ | async }}</div>
 <button (click)="decrement()">Decrement</button>

 <button (click)="reset()">Reset Counter</button>
`,
})
export class MyCounterComponent {
 count$: Вами<number>;

 constructor(private store: Store<AppState>) {
 this.count$ = store.pipe(select('count'));
}

 increment() {
 this.store.dispatch({ type: INCREMENT });
}

 decrement() {
 this.store.dispatch({ type: DECREMENT });
}

 reset() {
 this.store.dispatch({ type: RESET });
}
}

NgRx/store-devtools — дозволяє відстежувати зміни в додатку через redux-devtools.
Приклад підключення

import { StoreDevtoolsModule } from '@ngrx/store-devtools';

@NgModule({
 imports: [
StoreModule.forRoot(reducers),
 // Модуль повинен бути підключений після StoreModule
StoreDevtoolsModule.instrument({
 maxAge: 25, // Зберігаються останні 25 станів
}),
],
})
export class AppModule {}

NgRx/effects дозволяє додавати в сховище дані, що приходять в додаток, такі як http запити.
Приклад./effects/auth.effects.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Action } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Вами, of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';

@Injectable()
export class AuthEffects {
 // Listen for the 'LOGIN' action
@Effect()
 login$: Вами<Action> = this.actions$.pipe(
ofType('LOGIN'),
 mergeMap(action =>
 this.http.post('/auth', action.payload).pipe(
 // If successful, dispatch success action with result
 map(data => ({ type: 'LOGIN_SUCCESS', payload: data })),
 // If request fails, dispatch failed action
 catchError(() => of({ type: 'LOGIN_FAILED' }))
)
)
);

 constructor(private http: HttpClient, private actions$: Actions) {}
}

Підключення ефекту в модуль

import { EffectsModule } from '@ngrx/effects';
import { AuthEffects } from './effects/auth.effects';

@NgModule({
 imports: [EffectsModule.forRoot([AuthEffects])],
})
export class AppModule {}

NgRx/entity — надає можливість працювати з масивами даних.
Прикладuser.model.ts

export User interface {
 id: string;
 name: string;
}

user.actions.ts

import { Action } from '@ngrx/store';
import { Оновити } from '@ngrx/entity';

import { User } from './user.model';

export enum UserActionTypes {
 LOAD_USERS = '[User] Load Users',
 ADD_USER = '[User] Add User',
 UPSERT_USER = '[User] Upsert User',
 ADD_USERS = '[User] Add Users',
 UPSERT_USERS = '[User] Upsert Users',
 UPDATE_USER = '[User] Update User',
 UPDATE_USERS = '[User] Update Users',
 DELETE_USER = '[User] Delete User',
 DELETE_USERS = '[User] Delete Users',
 CLEAR_USERS = '[User] Clear Users',
}

export class LoadUsers implements Action {
 readonly type = UserActionTypes.LOAD_USERS;

 constructor(public payload: { users: User[] }) {}
}

export class AddUser implements Action {
 readonly type = UserActionTypes.ADD_USER;

 constructor(public payload: { user: User }) {}
}

export class UpsertUser implements Action {
 readonly type = UserActionTypes.UPSERT_USER;

 constructor(public payload: { user: User }) {}
}

export class AddUsers implements Action {
 readonly type = UserActionTypes.ADD_USERS;

 constructor(public payload: { users: User[] }) {}
}

export class UpsertUsers implements Action {
 readonly type = UserActionTypes.UPSERT_USERS;

 constructor(public payload: { users: User[] }) {}
}

export class UpdateUser implements Action {
 readonly type = UserActionTypes.UPDATE_USER;

 constructor(public payload: { user: Update<User> }) {}
}

export class UpdateUsers implements Action {
 readonly type = UserActionTypes.UPDATE_USERS;

 constructor(public payload: { users: Update<User>[] }) {}
}

export class DeleteUser implements Action {
 readonly type = UserActionTypes.DELETE_USER;

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

export class DeleteUsers implements Action {
 readonly type = UserActionTypes.DELETE_USERS;

 constructor(public payload: { ids: string[] }) {}
}

export class ClearUsers implements Action {
 readonly type = UserActionTypes.CLEAR_USERS;
}

export type UserActionsUnion =
 | LoadUsers
 | AddUser
 | UpsertUser
 | AddUsers
 | UpsertUsers
 | UpdateUser
 | UpdateUsers
 | DeleteUser
 | DeleteUsers
 | ClearUsers;

user.reducer.ts

import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { User } from './user.model';
import { UserActionsUnion, UserActionTypes } from './user.actions';

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,
});

export function reducer(state = initialState, action: UserActionsUnion): State {
 switch (action.type) {
 case UserActionTypes.ADD_USER: {
 return adapter.addOne(action.payload.user, state);
}

 case UserActionTypes.UPSERT_USER: {
 return adapter.upsertOne(action.payload.user, state);
}

 case UserActionTypes.ADD_USERS: {
 return adapter.addMany(action.payload.users, state);
}

 case UserActionTypes.UPSERT_USERS: {
 return adapter.upsertMany(action.payload.users, state);
}

 case UserActionTypes.UPDATE_USER: {
 return adapter.updateOne(action.payload.user, state);
}

 case UserActionTypes.UPDATE_USERS: {
 return adapter.updateMany(action.payload.users, state);
}

 case UserActionTypes.DELETE_USER: {
 return adapter.removeOne(action.payload.id state);
}

 case UserActionTypes.DELETE_USERS: {
 return adapter.removeMany(action.payload.ids, state);
}

 case UserActionTypes.LOAD_USERS: {
 return adapter.addAll(action.payload.users, state);
}

 case UserActionTypes.CLEAR_USERS: {
 return adapter.removeAll({ ...state, selectedUserId: null });
}

 default: {
 return state;
}
}
}

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;

reducers/index.ts

import {
createSelector,
createFeatureSelector,
ActionReducerMap,
} from '@ngrx/store';
import * as fromUser from './user.reducer';

export interface State {
 користувачі: fromUser.State;
}

export const reducers: ActionReducerMap<State> = {
 користувачі: 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]
);

 

Що в підсумку?

Ми отримуємо повноцінний state management з купою плюсів:
— єдиний джерело даних для програми,
— стан зберігається окремо від програми,
— єдиний стиль написання для всіх розробників у проекті,
changeDetectionStrategy.OnPush у всіх компонентах програми,
— зручне налагодження через redux-devtools,
— легкість тестування, так як reducers є “чистими” функціями.

Але є і мінуси:
— велика кількість незрозумілих на перший погляд модулів,
— багато однотипного коду, на який без смутку не глянеш,
— складність в освоєнні через все вище перераховане.

CRUD

Як правило, значну частину програми займає робота з об’єктами (створення, читання, оновлення, видалення), тому для зручності роботи була придумана концепція CRUD (Create, Read, Update, Delete). Таким чином, базові операції для роботи з усіма типами об’єктів стандартизовані. На бэкенді це уже давно процвітає. Багато бібліотеки допомагають реалізувати дану функціональність і позбутися від рутинної роботи.

У NgRx за CRUD відповідає модуль entity, і якщо подивитися приклад його реалізації, відразу видно, що це найбільша і найскладніша частина NgRx. Саме тому Papa John Ward і Bell створили angular-ngrx-data.

angular-ngrx-data

angular-ngrx-data — це бібліотека-надбудова над NgRx, яка дозволяє працювати з масивами даних без написання зайвого коду.
Крім створення повноцінного state management, вона бере на себе створення сервісів з http для взаємодії з сервером.

Розглянемо на прикладі

Установка

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

Модуль angular-ngrx-data

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
EntityMetadataMap,
NgrxDataModule,
DefaultDataServiceConfig
} from 'ngrx-data';

const defaultDataServiceConfig: DefaultDataServiceConfig = {
 root: 'crud'
};

export const entityMetadata: EntityMetadataMap = {
 Hero: {},
User:{}
};

export const pluralNames = { Hero: 'heroes' };

@NgModule({
 imports: [
CommonModule,
 NgrxDataModule.forRoot({ entityMetadata, pluralNames })
],
 declarations: [],
 providers: [
 { provide: DefaultDataServiceConfig, useValue: defaultDataServiceConfig }
]
})
export class EntityStoreModule {}

Підключення в додаток

@NgModule({
 imports: [
BrowserModule,
HttpClientModule,
StoreModule.forRoot({}),
EffectsModule.forRoot([]),
EntityStoreModule,
StoreDevtoolsModule.instrument({
 maxAge: 25, 
}),
],
 declarations: [
AppComponent
],
 providers: [],
 bootstrap: [AppComponent]
})
export class AppModule {}

Тільки що ми отримали згенероване API для роботи з беком та інтеграцію API з NgRx, не написавши при цьому жодного effect, reducer і action і selector.

Розберемо більш докладно те, що тут відбувається

Константа defaultDataServiceConfig задає конфігурацію для нашого API і підключається в providers модуля. Властивість root вказує, куди звертатися за запитами. Якщо його не поставити, то за замовчуванням буде «api».

const defaultDataServiceConfig: DefaultDataServiceConfig = {
 root: 'crud'
};

Константа entityMetadata визначає назви сторів, які будуть створені при підключенні NgrxDataModule.forRoot.

export const entityMetadata: EntityMetadataMap = {
 Hero: {},
User:{}
};
...
NgrxDataModule.forRoot({ entityMetadata, pluralNames })

Шлях до API складається з базового шляху (у нашому випадку «crud») та імені стора.
Наприклад, для отримання користувача з певним номером шлях буде такий — «crud/user/{userId}».
Для одержання повного списку користувачів в кінці імені стора за замовчуванням додається літера «s» — «crud/users».
Якщо для одержання повного списку потрібен (наприклад, «heroes», а не «heros»), його можна змінити, задавши pluralNames і підключивши їх в NgrxDataModule.forRoot.

export const pluralNames = { Hero: 'heroes' };
...
NgrxDataModule.forRoot({ entityMetadata, pluralNames })

 

Підключення в компоненті

Для підключення в компоненті необхідно передати в конструктор entityServices і через метод getEntityCollectionService вибрати сервіс потрібного сховища

import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { Вами } from 'rxjs';
import { Hero } from '@appModels/hero';
import { EntityServices, EntityCollectionService } from 'ngrx-data';

@Component({
 selector: 'app-heroes',
 templateUrl: './heroes.component.html',
 styleUrls: ['./heroes.component.css'],
 changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeroesComponent implements OnInit {
 heroes$: Вами<Hero[]>;
 heroesService: EntityCollectionService<Hero>;

 constructor(entityServices: EntityServices) {
 this.heroesService = entityServices.getEntityCollectionService('Hero');
}

...
}

Для прив’язки до списку компоненту достатньо взяти з сервісу властивість entities$, а для отримання даних з сервера викликати метод getAll().

ngOnInit() {
 this.heroes$ = this.heroesService.entities$;
this.heroesService.getAll();
}

Також, крім основних даних, можна отримати:
loaded$, loading$ — отримання статусу завантаження даних,
errors$ — помилки при роботі сервісу,
count$ — загальна кількість записів у сховище.

Основні методи взаємодії з сервером:
getAll() — отримання списку даних,
getWithQuery(query) — отримання списку, відфільтрованого з допомогою query-параметрів,
getByKey(id) — отримання запису за ідентифікатором,
add(entity) — додавання нової сутності із запитом на бек,
delete(entity) — видалення сутності із запитом на бек,
update(entity) — оновлення сутності із запитом на бек.

Методи локальної роботи зі сховищем:
addManyToCache(entity) — додавання масиву нових сутностей в сховище,
addOneToCache(entity) — додавання нової сутності тільки в сховище,
removeOneFromCache(id) — видалення однієї сутності з сховища,
updateOneInCache(entity) — оновлення сутності в сховищі,
upsertOneInCache(entity) — якщо сутність із зазначеним id існує, вона оновлюється, якщо немає — створюється нова,
— та ін

Приклад використання в компоненті

import { EntityCollectionService, EntityServices } from 'ngrx-data';
import { Hero } from '../../core';

@Component({
 selector: 'app-heroes',
 templateUrl: './heroes.component.html',
 changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeroesComponent implements OnInit {
 heroes$: Вами<Hero[]>;
 heroesService: EntityCollectionService<Hero>;

 constructor(entityServices: EntityServices) {
 this.heroesService = entityServices.getEntityCollectionService('Hero');
}

 ngOnInit() {
 this.heroes$ = this.heroesService.entities$;
this.getHeroes();
}

 getHeroes() {
this.heroesService.getAll();
}

 addHero(hero: Hero) {
this.heroesService.add(hero);
}

 deleteHero(hero: Hero) {
this.heroesService.delete(hero.id);
}

 updateHero(hero: Hero) {
this.heroesService.update(hero);
}
}

Всі методи angular-ngrx-data поділяються на працюючі локально і взаємодіють з сервером. Це дозволяє використовувати бібліотеку при маніпуляціях з даними як на клієнті, так і з використанням сервера.

Логування

Для логування необхідно заінжектити EntityServices в компонент або сервіс і використовувати властивості:
reducedActions$ — для логування дій,
entityActionErrors$ — для логування помилок.

import { Component, OnInit } from '@angular/core';
import { MessageService } from '@appServices/message.service';
import { EntityServices } from 'ngrx-data';

@Component({
 selector: 'app-messages',
 templateUrl: './messages.component.html',
 styleUrls: ['./messages.component.css']
})
export class MessagesComponent implements OnInit {
constructor(
 public messageService: MessageService,
 private entityServices: EntityServices
 ) {}
 ngOnInit() {
 this.entityServices.reducedActions$.subscribe(res => {
 if (res && res.type) {
this.messageService.add(res.type);
}
});
}
}

 

Переїзд в основний репозиторій NgRx

Як було оголошено на ng-conf 2018, angular-ngrx-data найближчим часом буде перенесений в основний репозиторій NgRx.
Відео з доповіддю Reducing the Boilerplate with NgRx – Brandon Roberts & Mike Ryan

 

Джерела

Творці anguar-ngrx-data:
— Papa John twitter.com/John_Papa
— Ward Bell twitter.com/wardbell

Російськомовне Angular співтовариство в Telegram

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close