@ngrx/data 进阶之路

JiaLiPassion @ngChina2019

自我介绍

  • 李嘉
  • 公司: ThisDot
  • Zone.js: Code Owner
  • Angular: Collaborator
  • @JiaLiPassion

我经历的一个真实的项目

Step1:  传统的Angular

代码示例

// Container Service
@Injectable()
export class ContainerService {
  constructor(private http: HttpClient) {}

  getAllContainers() {
    return this.http.get('http://service/containers');
  }
}

// Container Component
export class ContainerComponent implements OnInit {
  containers$: Observable<Container[]>;

  constructor(private containerService: ContainerService) {}

  ngOnInit() {
    this.containers$ = this.containerService.getAll();
  }
}

新的需求

Text

Text

  • 可以编辑Image状态

新的需求

  • Filter可以联动URL

filter

新的需求

  • 支持乐观更新

状态管理

状态

  • 服务器端状态
  • 客户端状态
    • 持久状态
    • URL/Routing 状态
    • 客户端状态
    • 临时状态
    • UI状态

状态之间关系

服务器状态

持久状态

客户端状态

URL状态

临时状态

UI状态

持久状态同步

class DashboardComponent {
  starChangedInRecentUpdatedComp(starredContainer) {
    updateStarInMostStarredComp(starredContainer);
  } 
  
  starChangedInMostStarredComp(starredContainer) {
    updateStarInRecentUpdatedComp(starredContainer);
  }
}

class MostStarredComponent {
  @Output() starredComponentUpdated = new EventEmitter<Container>();
  
  starChanged(container) { this.starredComponentUpdated.emit(container); }
  
  updateStarInMostStarredComp(starredContainer) { // update local data }
}

class RecentUpdatedComponent {
  ...
}

持久状态同步+

// use a starred service as a bridge
export class StarredService {
  starredContainer$ = new BehaviorSubject(null);
  starContainer(container: Container) {
    this.starredContainer$.next(container);
  }
}

export class RecentUpdatedComponent {
  ngOnInit() {
    this.containers$ = combineLatest(
      this.starredService.starredContainer$,
      this.containerService.getLatestContainers()).
        pipe([starredContainer, latestContainers] => {...})
  }
} 

// MostStarredComponent
export class ContainerComponent {
  ngOnInit() { this.containers$ = combineLatest(...)}
}

越来越多的同步需求

Starred

Download Count

Description

Version/Tag Children

Service成了瓶颈

Starred

Service

DownloadCountService

Description

Service

Version/Tag ChildrenService

A Big

ContainerBridgeService

问题

  • 业务逻辑和状态管理混在一起
  • Command和Query混在一起
  • 各个状态之间的同步没有很好地机制,解决方案治标不治本
    • 持久状态和服务器的状态
    • 持久状态之间
    • 客户端状态和服务器状态和URL状态
    • ...
  • 数据是Mutable 可变的
  • 各个Component同时更新的处理变得非常复杂

状态管理库ngrx?

NgRx

  • 唯一数据来源Store
  • 数据只读更好的性能
  • Pure Function便于测试
  • Command和Query分开,程序便于维护

持久状态同步

RecentUpdated

Component

MostStarred

Component

Store

Selector

好多层....

试着实现一个getAll

Actions

export const GET_ALL = '[Container] query-all';
export const GET_ALL_SUCCESS = '[Container] query-all/success';
export const GET_ALL_FAILED = '[Container] query-all/failed';

export class GetAll implements Action {
  readonly type = GET_ALL;
}

export class GetAllSuccess implements Action {
  readonly type = GET_ALL_SUCCESS;
  constructor(public payload: string[]) {}
}

export class GetAllFailed implements Action {
  readonly type = GET_ALL_FAILED;
  constructor(public payload: string[]) {}
}

Reducer

export interface ContainerState {
  containers: Container[];
  filter: string;
}

export const initialState = {
  containers: [],
  filter: null
}

export function containerReducer(state: ContainerState = initialState,
                                 action: ContainerActionTypes) {
  switch(action.type) {
    case GET_ALL_SUCCESS:
      return { ...state, containers: [...action.payload] };
    default:
      return state;
  }
}

Effects

export class ContainerEffects {
    constructor(
        private store: Store<any>,
        private actions$: Actions,
        private containerDataService: ContainerDataService
    ) {}

    createEffect(() => this.actions$.pipe(
      ofType(GET_ALL),
      mergeMap(() => this.containerDataService.getAll()
        .pipe(
          map(containers => {return new GetAllSuccess(containers); }),
          catchError(() => { return new GetAllFailed(); })
        ))
     ));
  }

DataService

class ContainerDataService {
  getAll() {
    return this.httpClient.post(...);
  }
}

Selector

export const selectAllContainers = 
    (state: ContainerState) => state.containers;

ngrx 构成

10 个业务对象

5~10种Actions

好多"Boilerplate"代码

Debug对于初学者不友好

class ContainerComponent {
  ngOnInit() {
    this.store.dispatch(new GetAll());
    this.containers$ = this.store.pipe(getAllSelector);
  }
}

@ngrx/data

Zero Ngrx Boilerplate

 

你不需要再去实现Actions, Reduers, Effects, DataServices, Selectors.

 

@ngrx/data

  • 实现了对于CRUD 操作
  • 支持检索和排序
  • 支持加载中和已经加载
  • 内置错误处理
  • 支持乐观更新(支持回滚/重试)
  • 支持所有@ngrx/entity的功能
  • 支持所有ngrx的功能

意识不到在使用ngrx

EntityCollectionService

export class ContainerService 
       extends EntityCollectionServiceBase {
  getAll();
  add();
  update();
  delete();
  upsert();
  
  entities$;
  filteredEntities$;
  loading$;
  loaded$;
  ...
}

entity metadata

// define metadata
export const entityMetadata: EntityMetadataMap = {
  Container: {}
};

// provide a facade service
export class ContainerService extends EntityCollectionServiceBase<Container> {
  constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory) {
    super('Container', serviceElementsFactory);
  }
}

getAll

// call facade from component
export class ContainerComponent {
  ngOnInit() {
    // dispatch action
    this.containerService.getAll();
    // selector to get all entities
    this.containers$ = this.containerService.entities$;
  }
}

getAll

// dispatch action
this.containerService.getAll();
// selector to get all entities
this.containers$ = this.containerService.entities$;

ngrx/data architecture

CRUD

export class ContainersComponent implements OnInit {
  add() {
    this.containerService.add({name: 'centos'});
  }

  delete() {
    this.containerService.delete(1);
  }

  update() {
    this.containerService.update({id: 1, name: 'ubuntu:18.03'});
  }


  getById(id: number) {
    this.containerService.getByKey(id);
  }

}

CRUD

Flow: getAll

Action/HttpRequest的默认规则

Operation action.type http request url
getAll [Container] @ngrx/data/query-all GET
api/containers
add [Container] @ngrx/data/save/add-one POST
api/containers
update [Container] @ngrx/data/save/update-one PUT
api/containers
delete [Container] @ngrx/data/save/delete-one DELETE
api/containers
getByKey [Container] @ngrx/data/query-by-key GET
api/containers/${key}
getWithQuery [Container] @ngrx/data/query-many GET
api/containers?query

@ngrx/entity

{ 
  entityCache: {   
    Container: {
      ids: [
        1,
        2,
        3,
        4
      ],
      entities: {
        '1': {
          id: 1,
          name: 'ubuntu'
        },
        '2': {
          id: 2,
          name: 'alpine'
        }
      }
    }
  }
}

@ngrx/entity的好处

Containers: [
  {
    id: 1,
    name: 'ubuntu'
  },
  {
    id: 2,
    name: 'alpine'
  }
]
Containers: {
  ids: [1, 2],
  entities: {
    1: {
      id: 1,
      name: 'ubuntu' 
    },
    2: {
      id: 2,
      name: 'alpine'
    }
  }
}

@ngrx/entity提供的EntityAdpter

  • 内建CRUD方法支持数据转换成需要的格式
  • 提供了基础的Selectors
  • 提供了可供定制的idSelector和sortComparer

ngrx/data -> ngrx/entity

Filter

// define a filter function and register it to entityMetadata

export function containerNameFilterFn(
  containers: ContainerUIModel[],
  pattern: string) {
  return PropsFilterFnFactory(['name'])(containers, pattern);
}

export const entityMetadata: EntityMetadataMap = {
  Container: {
    filterFn: containerNameFilterFn
  }
}

// Containers Component
export class ContainersComponent {
  this.filteredContainers$ = this.containerService.filteredEntities$;
} 

Sort

// define a compare function and register it to entityMetadata

export const entityMetadata: EntityMetadataMap = {
  Container: {
    sortComparer: (c1: Container, c2: Container) => c1.id - c2.id
  }
}

// Containers Component
export class ContainersComponent {
  this.containers$ = this.containerService.entities$;
} 

idSelector

// define a idSelector function and register it to entityMetadata

export const entityMetadata: EntityMetadataMap = {
  Container: {
    selectId: (c: Container) => c1.fingerprint
  }
}

Loading

// Containers Component
export class ContainersComponent {
  this.loading$ = this.containerService.loading$;
} 

// Containers Component template
<ng-container *ngIf="loading$ | async; else containers_tpl">
  Loading...
</ng-container>

Demo: Filter/Sort/Loading

乐观更新

  • 单独的流程处理先更新客户端持久状态,再更新服务器状态
  • 要做错误处理,当有错误的时候能够支持回滚或者重试
  • 为了支持回滚和重试,本地还需要存储变更的内容(ChangeSet)
  • 回滚时候也要考虑其他操作是否已经更改了数据

使用@ngrx/data实现乐观更新

export const entityMetadata: EntityMetadataMap = {
  Container: {
    entityDispatcherOptions: {
      optimisticAdd: false,
      optimisticUpdate: true,
      optimisticDelete: true
    }
  }
};

ChangeState

changeState: {
  '2': {
    changeType: 3,
    originalValue: {
      id: 2, 
      name: 'alpine'
    }
  }
}

错误处理

@Injectable({ providedIn: 'root' })
export class ErrorService {
  constructor(private actions$: Actions) {
    actions$
      .pipe(
        ofEntityOp(),
        filter(
          (ea: EntityAction) =>
          ea.payload.entityOp.endsWith(OP_ERROR)
        )
      )
      // this service never dies so no need to unsubscribe
      .subscribe(action => {
        if (action.payload.entityOp.endsWith(OP_ERROR)) {
          this.containerService.createAndDispatch(EntityOp.UNDO_ALL);
        }
      });
  }
}

Demo: Update

完美!!!

看来不需要学习太多ngrx的知识也不用写各种ngrx的bolierplate就可以得到所有ngrx的好处

但是.....

定制Http Request的规则

// create a customize http url generator
@Injectable({providedIn: 'root'})
export class CustomizeHttpUrlGenerator extends DefaultHttpUrlGenerator {
  protected getResourceUrls(
    entityName: string,
    root: string
  ): HttpResourceUrls {
    const urls = super.getResourceUrls(entityName, root);
    urls.entityResourceUrl = urls.collectionResourceUrl;
    return urls;
  }
}

// app.module.ts, then register to providers
{
  provide: HttpUrlGenerator,
  useClass: CustomizeHttpUrlGenerator
}

服务器和客户端的数据结构不一致,需要Mapping

// customize
@Injectable({ providedIn: 'root' })
export class CustomizeDataService extends DefaultDataService<Container> {
  constructor(http: HttpClient, httpUrlGenerator: HttpUrlGenerator, logger: Logger) {
    super('Container', http, httpUrlGenerator);
  }
  getAll(): Observable<Container[]> {
    return super.getAll().pipe(
      map(containers =>
        containers.map(c => ({...c, fullName: c.name + c.tag)}))
      )
    );
  }
}

分页处理

entityCache: {
  Container: {
    ids: [],
    entities: {},
    entityName: 'Container',
    filter: '',
    loaded: true,
    loading: false,
    changeState: {}
  }
}

// entity metadata.ts

export const entityMetadata: EntityMetadataMap = {
  Container: {
    additionalCollectionState: {
      page: {//...      }
    },
  }
};

不知道该如何用@ngrx/data来实现

最终

  • 重头系统学习ngrx
  • 学习@ngrx/data的文档和源代码

@ngrx/data扩展点 

  • 定制@ngrx/data默认提供的行为
    • 定制 EntityCollectionService
    • 定制 EntityAction/Dispatcher
    • 增加属性到 EntityCollection
    • 定制 DataService
    • 定制 存储时候合并的策略
    • 定制 http url 规则
    • 定制 复数名称
    • 定制 存储行为
  • 完全和普通的ngrx可以并存
    • 可以脱离@ngrx/data写自己的
    • Action/Reducer/Effects/Selector

分页

具体实现代码

// entity metadata
EntityMetadataMap = {
  Container: {
    additionalCollectionState: { page: {} }
  }
};
// Custom Data Service to save page from backend to data
export class CustomizeDataService extends DefaultDataService<Container> {
  getWithQuery(params: string | QueryParams): Observable<Container[]> {
    const pageIndex = params['pageIndex'] || '1';
    return this.http.get(`api/containers?pageIndex=${pageIndex}`).
      pipe(map((data: any) => {
        const containers = data.data;
        containers.page = data.page;
        return containers as any;
    }));
  }
}
// create a page$ in service
export class ContainerService extends EntityCollectionServiceBase<Container> {
  page$: Observable<{pageIndex: number, pageCount: number}>;
  constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory,
    private http: HttpClient) {
    super('Container', serviceElementsFactory);

    this.page$ = this.selectors$['page$'];
  }

}

具体实现代码

// persistent handler
@Injectable({ providedIn: 'root' })
export class PagePersistenceResultHandler extends DefaultPersistenceResultHandler {
  handleSuccess(originalAction: EntityAction): (data: any) => Action {
    const actionHandler = super.handleSuccess(originalAction);
    return (data: any) => {
      const action = actionHandler(data);
      if (action && data && data.page) {
         (action as any).payload.page = data.page;
      }
      return action;
    };
   
  }
}
// reducers methods.
export class EntityCollectionPageReducerMethods<T> extends EntityCollectionReducerMethods<T> {
  constructor(public entityName: string, public definition: EntityDefinition<T>) {
    super(entityName, definition);
  }

  protected queryManySuccess(collection: EntityCollection<T>,action: EntityAction<T[]>
  ): EntityCollection<T> {
    const ec = super.queryManySuccess(collection, action);
    if ((action.payload as any).page) {
      (ec as any).page = (action.payload as any).page;
    }
    return ec;
  }
}

Demo: 分页

根据条件的错误处理

// ComponentA, want to show error dialog
this.containers$ = this.containerService.getAll().pipe(
  catchError(err => {showError(err); return of(err);})
);
// ComponentB, do not want to show error dialog
this.containers$ = this.containerService.getAll().pipe(
  catchError(err => {log.error(err); return of([])})
);
// common error service?
export class ErrorService {
  constructor(private actions$: Actions) {
    actions$
      .pipe(
        ofEntityOp(),
        filter((ea: EntityAction) =>
          ea.payload.entityOp.endsWith(OP_ERROR)
        )
      )
      .subscribe(action => {
        showError(...);
      });
  }
}

使用标签

// ComponentA, want to show error dialog
this.containers$ = this.containerService.getAll({tag: SHOW_ERROR});
// ComponentB, do not want to show error dialog
this.containers$ = this.containerService.getAll({tag: NO_ERROR});
// common error service?
export class ErrorService {
  constructor(private actions$: Actions) {
    actions$
      .pipe(
        ofEntityOp(),
        filter(
          (ea: EntityAction) =>
          ea.payload.entityOp.endsWith(OP_ERROR)
        )
      )
      .subscribe(action => {
        if (action.payload.tag === SHOW_ERROR) {
          showError(...);
        }
      });
  }
}

Demo: Tag

处理有关联关系的实体

User

Container

Version

@ngrx/data 目前还不支持自动处理有关联关系的实体

// service
export class ContainerService {
  // action to get containers with versions
  getContainerWithVersions(containerId) {
    this.getByKey(containerId).switchMap(container => {
      this.versionService.getWithQuery({containers: containers.map(con => con.id)})
        .pipe(map(versions => ({...container, versions})))
    });
  }

  // selectors
  getContainerWithVersionsSelector(containerId) {
    return this.store.pipe(
      this.selectors.selectEntities,
      this.versionService.selectors.selectEntities,
      (containers, versions) => {
         const con = containers.find(c => c.id === containerId);
         const versions = versions.filter(v => vi.cid === containerId);
         return con ? {...con, versions} : undefined;
      }
    );
  }
}

校验?

export const entityMetadata: EntityMetadataMap = {
  Container: {
    validations: {
      props: {
        name: ValidatorOptions.required
      },
      validator: (model: T) => ValidationResult
    }
  }
};

服务器和客户端数据结构的转换

// server data structure
interface Container {
  name: string;
  tag: string;
  updatedTimeStamp?: string;
}

// frontend data structure
interface Container {
  name: string;
  tag: string;
  fullName: string;
}

// entity metadata
export const entityMetadata: EntityMetadataMap = {
  Container: {
    mapTo: (backendModel) => {}
  }
};

在我深入学习并解决分页定制问题之前@ngrx/data是一个

  • 一个有效减少ngrx boilerplate代码的库
  • 提供了很多功能处理了应用中出现的常用场景

经历了学习之后!

  • 一个使用ngrx并运用了ngrx的最佳实践的库
    • ​命名规则
    • 模块划分
    • 扩展设计
    • 对于RxJs的运用
  • 我们仍然需要掌握ngrx才能彻底掌握@ngrx/data

如果开发团队是ngrx的经验者

  • 可以读一遍@ngrx/data的代码,可以从中学到很多设计的思路!!!
  • 可以在某些管理画面使用@ngrx/data

如果开发团队是ngrx的初学者

  • 可以把@ngrx/data作为入门库,学习使用ngrx,然后进行简单定制,最后可以完整使用ngrx

我现在的想法

 

  • @ngrx/data不光可以做简单应用,如果对于ngrx有比较深入了解后,@ngrx/data非常适合作为一个独立的解决方案

谢谢大家!

 我的@ngrx/data进阶之路

By jiali

 我的@ngrx/data进阶之路

  • 1,372