@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,396