@ngrx/data 进阶之路
JiaLiPassion @ngChina2019
自我介绍
// 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
filter
服务器状态
持久状态
客户端状态
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
Starred
Service
DownloadCountService
Description
Service
Version/Tag ChildrenService
A Big
ContainerBridgeService
RecentUpdated
Component
MostStarred
Component
Store
Selector
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[]) {}
}
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;
}
}
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(); })
))
));
}
class ContainerDataService {
getAll() {
return this.httpClient.post(...);
}
}
export const selectAllContainers =
(state: ContainerState) => state.containers;
10 个业务对象
5~10种Actions
class ContainerComponent {
ngOnInit() {
this.store.dispatch(new GetAll());
this.containers$ = this.store.pipe(getAllSelector);
}
}
你不需要再去实现Actions, Reduers, Effects, DataServices, Selectors.
export class ContainerService
extends EntityCollectionServiceBase {
getAll();
add();
update();
delete();
upsert();
entities$;
filteredEntities$;
loading$;
loaded$;
...
}
// define metadata
export const entityMetadata: EntityMetadataMap = {
Container: {}
};
// provide a facade service
export class ContainerService extends EntityCollectionServiceBase<Container> {
constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory) {
super('Container', serviceElementsFactory);
}
}
// call facade from component
export class ContainerComponent {
ngOnInit() {
// dispatch action
this.containerService.getAll();
// selector to get all entities
this.containers$ = this.containerService.entities$;
}
}
// dispatch action
this.containerService.getAll();
// selector to get all entities
this.containers$ = this.containerService.entities$;
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);
}
}
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 |
{
entityCache: {
Container: {
ids: [
1,
2,
3,
4
],
entities: {
'1': {
id: 1,
name: 'ubuntu'
},
'2': {
id: 2,
name: 'alpine'
}
}
}
}
}
Containers: [
{
id: 1,
name: 'ubuntu'
},
{
id: 2,
name: 'alpine'
}
]
Containers: {
ids: [1, 2],
entities: {
1: {
id: 1,
name: 'ubuntu'
},
2: {
id: 2,
name: 'alpine'
}
}
}
// 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$;
}
// 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$;
}
// define a idSelector function and register it to entityMetadata
export const entityMetadata: EntityMetadataMap = {
Container: {
selectId: (c: Container) => c1.fingerprint
}
}
// Containers Component
export class ContainersComponent {
this.loading$ = this.containerService.loading$;
}
// Containers Component template
<ng-container *ngIf="loading$ | async; else containers_tpl">
Loading...
</ng-container>
export const entityMetadata: EntityMetadataMap = {
Container: {
entityDispatcherOptions: {
optimisticAdd: false,
optimisticUpdate: true,
optimisticDelete: true
}
}
};
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);
}
});
}
}
// 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
}
// 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: {//... }
},
}
};
// 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;
}
}
// 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(...);
}
});
}
}
User
Container
Version
// 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) => {}
}
};