More refactoring + reusing the server's store on the client

This commit is contained in:
Art Lowel
2017-03-02 13:36:40 +01:00
parent b0f25c4dae
commit 7745938027
27 changed files with 348 additions and 371 deletions

View File

@@ -10,13 +10,6 @@ import { AppComponent } from './app.component';
import { HeaderComponent } from './header/header.component'; import { HeaderComponent } from './header/header.component';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { StoreModule } from "@ngrx/store";
import { RouterStoreModule } from "@ngrx/router-store";
import { StoreDevtoolsModule } from "@ngrx/store-devtools";
import { rootReducer } from './app.reducers';
import { effects } from './app.effects';
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent, AppComponent,
@@ -28,34 +21,6 @@ import { effects } from './app.effects';
HomeModule, HomeModule,
CoreModule.forRoot(), CoreModule.forRoot(),
AppRoutingModule, AppRoutingModule,
/**
* StoreModule.provideStore is imported once in the root module, accepting a reducer
* function or object map of reducer functions. If passed an object of
* reducers, combineReducers will be run creating your application
* meta-reducer. This returns all providers for an @ngrx/store
* based application.
*/
StoreModule.provideStore(rootReducer),
/**
* @ngrx/router-store keeps router state up-to-date in the store and uses
* the store as the single source of truth for the router's state.
*/
RouterStoreModule.connectRouter(),
/**
* Store devtools instrument the store retaining past versions of state
* and recalculating new states. This enables powerful time-travel
* debugging.
*
* To use the debugger, install the Redux Devtools extension for either
* Chrome or Firefox
*
* See: https://github.com/zalmoxisus/redux-devtools-extension
*/
StoreDevtoolsModule.instrumentOnlyWithExtension(),
effects
], ],
providers: [ providers: [
] ]

View File

@@ -3,6 +3,7 @@ import { routerReducer, RouterState } from "@ngrx/router-store";
import { headerReducer, HeaderState } from './header/header.reducer'; import { headerReducer, HeaderState } from './header/header.reducer';
import { hostWindowReducer, HostWindowState } from "./shared/host-window.reducer"; import { hostWindowReducer, HostWindowState } from "./shared/host-window.reducer";
import { CoreState, coreReducer } from "./core/core.reducers"; import { CoreState, coreReducer } from "./core/core.reducers";
import { StoreActionTypes } from "./store.actions";
export interface AppState { export interface AppState {
core: CoreState; core: CoreState;
@@ -19,5 +20,10 @@ export const reducers = {
}; };
export function rootReducer(state: any, action: any) { export function rootReducer(state: any, action: any) {
if (action.type === StoreActionTypes.REHYDRATE) {
state = action.payload;
}
return combineReducers(reducers)(state, action); return combineReducers(reducers)(state, action);
} }
export const NGRX_CACHE_KEY = "NGRX_STORE";

4
src/app/core/cache/cache-entry.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
export interface CacheEntry {
timeAdded: number;
msToLive: number;
}

View File

@@ -11,11 +11,12 @@ export class AddToObjectCacheAction implements Action {
type = ObjectCacheActionTypes.ADD; type = ObjectCacheActionTypes.ADD;
payload: { payload: {
objectToCache: CacheableObject; objectToCache: CacheableObject;
timeAdded: number;
msToLive: number; msToLive: number;
}; };
constructor(objectToCache: CacheableObject, msToLive: number) { constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number) {
this.payload = { objectToCache, msToLive }; this.payload = { objectToCache, timeAdded, msToLive };
} }
} }

View File

@@ -1,11 +1,12 @@
import { ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions"; import { ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions";
import { hasValue } from "../../shared/empty.util"; import { hasValue } from "../../shared/empty.util";
import { CacheEntry } from "./cache-entry";
export interface CacheableObject { export interface CacheableObject {
uuid: string; uuid: string;
} }
export interface ObjectCacheEntry { export class ObjectCacheEntry implements CacheEntry {
data: CacheableObject; data: CacheableObject;
timeAdded: number; timeAdded: number;
msToLive: number; msToLive: number;
@@ -39,7 +40,7 @@ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheActio
return Object.assign({}, state, { return Object.assign({}, state, {
[action.payload.objectToCache.uuid]: { [action.payload.objectToCache.uuid]: {
data: action.payload.objectToCache, data: action.payload.objectToCache,
timeAdded: new Date().getTime(), timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive msToLive: action.payload.msToLive
} }
}); });

View File

@@ -4,6 +4,7 @@ import { ObjectCacheState, ObjectCacheEntry, CacheableObject } from "./object-ca
import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions"; import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from "./object-cache.actions";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { hasNoValue } from "../../shared/empty.util"; import { hasNoValue } from "../../shared/empty.util";
import { GenericConstructor } from "../shared/generic-constructor";
@Injectable() @Injectable()
export class ObjectCacheService { export class ObjectCacheService {
@@ -12,22 +13,23 @@ export class ObjectCacheService {
) {} ) {}
add(objectToCache: CacheableObject, msToLive: number): void { add(objectToCache: CacheableObject, msToLive: number): void {
this.store.dispatch(new AddToObjectCacheAction(objectToCache, msToLive)); this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive));
} }
remove(uuid: string): void { remove(uuid: string): void {
this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); this.store.dispatch(new RemoveFromObjectCacheAction(uuid));
} }
get<T extends CacheableObject>(uuid: string): Observable<T> { get<T extends CacheableObject>(uuid: string, ctor: GenericConstructor<T>): Observable<T> {
return this.store.select<ObjectCacheEntry>('core', 'cache', 'object', uuid) return this.store.select<ObjectCacheEntry>('core', 'cache', 'object', uuid)
.filter(entry => this.isValid(entry)) .filter(entry => this.isValid(entry))
.map((entry: ObjectCacheEntry) => <T> entry.data); .distinctUntilChanged()
.map((entry: ObjectCacheEntry) => <T> Object.assign(new ctor(), entry.data));
} }
getList<T extends CacheableObject>(uuids: Array<string>): Observable<Array<T>> { getList<T extends CacheableObject>(uuids: Array<string>, ctor: GenericConstructor<T>): Observable<Array<T>> {
return Observable.combineLatest( return Observable.combineLatest(
uuids.map((id: string) => this.get<T>(id)) uuids.map((id: string) => this.get<T>(id, ctor))
); );
} }

View File

@@ -5,14 +5,15 @@ import { PaginationOptions } from "../shared/pagination-options.model";
import { SortOptions } from "../shared/sort-options.model"; import { SortOptions } from "../shared/sort-options.model";
export const RequestCacheActionTypes = { export const RequestCacheActionTypes = {
FIND_BY_ID_REQUEST: type('dspace/core/cache/request/FIND_BY_ID_REQUEST'), FIND_BY_ID: type('dspace/core/cache/request/FIND_BY_ID'),
FIND_ALL_REQUEST: type('dspace/core/cache/request/FIND_ALL_REQUEST'), FIND_ALL: type('dspace/core/cache/request/FIND_ALL'),
SUCCESS: type('dspace/core/cache/request/SUCCESS'), SUCCESS: type('dspace/core/cache/request/SUCCESS'),
ERROR: type('dspace/core/cache/request/ERROR') ERROR: type('dspace/core/cache/request/ERROR'),
REMOVE: type('dspace/core/cache/request/REMOVE')
}; };
export class FindAllRequestCacheAction implements Action { export class RequestCacheFindAllAction implements Action {
type = RequestCacheActionTypes.FIND_ALL_REQUEST; type = RequestCacheActionTypes.FIND_ALL;
payload: { payload: {
key: string, key: string,
service: OpaqueToken, service: OpaqueToken,
@@ -38,8 +39,8 @@ export class FindAllRequestCacheAction implements Action {
} }
} }
export class FindByIDRequestCacheAction implements Action { export class RequestCacheFindByIDAction implements Action {
type = RequestCacheActionTypes.FIND_BY_ID_REQUEST; type = RequestCacheActionTypes.FIND_BY_ID;
payload: { payload: {
key: string, key: string,
service: OpaqueToken, service: OpaqueToken,
@@ -64,13 +65,15 @@ export class RequestCacheSuccessAction implements Action {
payload: { payload: {
key: string, key: string,
resourceUUIDs: Array<string>, resourceUUIDs: Array<string>,
timeAdded: number,
msToLive: number msToLive: number
}; };
constructor(key: string, resourceUUIDs: Array<string>, msToLive: number) { constructor(key: string, resourceUUIDs: Array<string>, timeAdded, msToLive: number) {
this.payload = { this.payload = {
key, key,
resourceUUIDs, resourceUUIDs,
timeAdded,
msToLive msToLive
}; };
} }
@@ -91,8 +94,18 @@ export class RequestCacheErrorAction implements Action {
} }
} }
export class RequestCacheRemoveAction implements Action {
type = RequestCacheActionTypes.REMOVE;
payload: string;
constructor(key: string) {
this.payload = key;
}
}
export type RequestCacheAction export type RequestCacheAction
= FindAllRequestCacheAction = RequestCacheFindAllAction
| FindByIDRequestCacheAction | RequestCacheFindByIDAction
| RequestCacheSuccessAction | RequestCacheSuccessAction
| RequestCacheErrorAction; | RequestCacheErrorAction
| RequestCacheRemoveAction;

View File

@@ -1,13 +1,17 @@
import { PaginationOptions } from "../shared/pagination-options.model"; import { PaginationOptions } from "../shared/pagination-options.model";
import { SortOptions } from "../shared/sort-options.model"; import { SortOptions } from "../shared/sort-options.model";
import { import {
RequestCacheAction, RequestCacheActionTypes, FindAllRequestCacheAction, RequestCacheAction, RequestCacheActionTypes, RequestCacheFindAllAction,
RequestCacheSuccessAction, RequestCacheErrorAction, FindByIDRequestCacheAction RequestCacheSuccessAction, RequestCacheErrorAction, RequestCacheFindByIDAction,
RequestCacheRemoveAction
} from "./request-cache.actions"; } from "./request-cache.actions";
import { OpaqueToken } from "@angular/core"; import { OpaqueToken } from "@angular/core";
import { CacheEntry } from "./cache-entry";
import { hasValue } from "../../shared/empty.util";
export interface CachedRequest { export class RequestCacheEntry implements CacheEntry {
service: OpaqueToken service: OpaqueToken;
key: string;
scopeID: string; scopeID: string;
resourceID: string; resourceID: string;
resourceUUIDs: Array<String>; resourceUUIDs: Array<String>;
@@ -21,7 +25,7 @@ export interface CachedRequest {
} }
export interface RequestCacheState { export interface RequestCacheState {
[key: string]: CachedRequest [key: string]: RequestCacheEntry
} }
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) // Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
@@ -30,12 +34,12 @@ const initialState = Object.create(null);
export const requestCacheReducer = (state = initialState, action: RequestCacheAction): RequestCacheState => { export const requestCacheReducer = (state = initialState, action: RequestCacheAction): RequestCacheState => {
switch (action.type) { switch (action.type) {
case RequestCacheActionTypes.FIND_ALL_REQUEST: { case RequestCacheActionTypes.FIND_ALL: {
return findAllRequest(state, <FindAllRequestCacheAction> action); return findAllRequest(state, <RequestCacheFindAllAction> action);
} }
case RequestCacheActionTypes.FIND_BY_ID_REQUEST: { case RequestCacheActionTypes.FIND_BY_ID: {
return findByIDRequest(state, <FindByIDRequestCacheAction> action); return findByIDRequest(state, <RequestCacheFindByIDAction> action);
} }
case RequestCacheActionTypes.SUCCESS: { case RequestCacheActionTypes.SUCCESS: {
@@ -46,15 +50,21 @@ export const requestCacheReducer = (state = initialState, action: RequestCacheAc
return error(state, <RequestCacheErrorAction> action); return error(state, <RequestCacheErrorAction> action);
} }
case RequestCacheActionTypes.REMOVE: {
return removeFromCache(state, <RequestCacheRemoveAction> action);
}
default: { default: {
return state; return state;
} }
} }
}; };
function findAllRequest(state: RequestCacheState, action: FindAllRequestCacheAction): RequestCacheState { function findAllRequest(state: RequestCacheState, action: RequestCacheFindAllAction): RequestCacheState {
console.log('break here', state);
return Object.assign({}, state, { return Object.assign({}, state, {
[action.payload.key]: { [action.payload.key]: {
key: action.payload.key,
service: action.payload.service, service: action.payload.service,
scopeID: action.payload.scopeID, scopeID: action.payload.scopeID,
resourceUUIDs: [], resourceUUIDs: [],
@@ -66,9 +76,10 @@ function findAllRequest(state: RequestCacheState, action: FindAllRequestCacheAct
}); });
} }
function findByIDRequest(state: RequestCacheState, action: FindByIDRequestCacheAction): RequestCacheState { function findByIDRequest(state: RequestCacheState, action: RequestCacheFindByIDAction): RequestCacheState {
return Object.assign({}, state, { return Object.assign({}, state, {
[action.payload.key]: { [action.payload.key]: {
key: action.payload.key,
service: action.payload.service, service: action.payload.service,
resourceID: action.payload.resourceID, resourceID: action.payload.resourceID,
resourceUUIDs: [], resourceUUIDs: [],
@@ -84,7 +95,7 @@ function success(state: RequestCacheState, action: RequestCacheSuccessAction): R
isLoading: false, isLoading: false,
resourceUUIDs: action.payload.resourceUUIDs, resourceUUIDs: action.payload.resourceUUIDs,
errorMessage: undefined, errorMessage: undefined,
timeAdded: new Date().getTime(), timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive msToLive: action.payload.msToLive
}) })
}); });
@@ -99,4 +110,17 @@ function error(state: RequestCacheState, action: RequestCacheErrorAction): Reque
}); });
} }
function removeFromCache(state: RequestCacheState, action: RequestCacheRemoveAction): RequestCacheState {
if (hasValue(state[action.payload])) {
let newCache = Object.assign({}, state);
delete newCache[action.payload];
return newCache;
}
else {
return state;
}
}

View File

@@ -0,0 +1,73 @@
import { Injectable, OpaqueToken } from "@angular/core";
import { Store } from "@ngrx/store";
import { RequestCacheState, RequestCacheEntry } from "./request-cache.reducer";
import { Observable } from "rxjs";
import { hasNoValue } from "../../shared/empty.util";
import {
RequestCacheRemoveAction, RequestCacheFindAllAction,
RequestCacheFindByIDAction
} from "./request-cache.actions";
import { SortOptions } from "../shared/sort-options.model";
import { PaginationOptions } from "../shared/pagination-options.model";
@Injectable()
export class RequestCacheService {
constructor(
private store: Store<RequestCacheState>
) {}
findAll(
key: string,
service: OpaqueToken,
scopeID?: string,
paginationOptions?: PaginationOptions,
sortOptions?: SortOptions
): Observable<RequestCacheEntry> {
if (!this.has(key)) {
this.store.dispatch(new RequestCacheFindAllAction(key, service, scopeID, paginationOptions, sortOptions));
}
return this.get(key);
}
findById(
key: string,
service: OpaqueToken,
resourceID: string
): Observable<RequestCacheEntry> {
if (!this.has(key)) {
this.store.dispatch(new RequestCacheFindByIDAction(key, service, resourceID));
}
return this.get(key);
}
get(key: string): Observable<RequestCacheEntry> {
return this.store.select<RequestCacheEntry>('core', 'cache', 'request', key)
.filter(entry => this.isValid(entry))
.distinctUntilChanged()
}
has(key: string): boolean {
let result: boolean;
this.store.select<RequestCacheEntry>('core', 'cache', 'request', key)
.take(1)
.subscribe(entry => result = this.isValid(entry));
return result;
}
private isValid(entry: RequestCacheEntry): boolean {
if (hasNoValue(entry)) {
return false;
}
else {
const timeOutdated = entry.timeAdded + entry.msToLive;
const isOutDated = new Date().getTime() > timeOutdated;
if (isOutDated) {
this.store.dispatch(new RequestCacheRemoveAction(entry.key));
}
return !isOutDated;
}
}
}

View File

@@ -5,6 +5,7 @@ import { isNotEmpty } from "../shared/empty.util";
import { FooterComponent } from "./footer/footer.component"; import { FooterComponent } from "./footer/footer.component";
import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service"; import { DSpaceRESTv2Service } from "./dspace-rest-v2/dspace-rest-v2.service";
import { ObjectCacheService } from "./cache/object-cache.service"; import { ObjectCacheService } from "./cache/object-cache.service";
import { RequestCacheService } from "./cache/request-cache.service";
import { CollectionDataService } from "./data-services/collection-data.service"; import { CollectionDataService } from "./data-services/collection-data.service";
import { ItemDataService } from "./data-services/item-data.service"; import { ItemDataService } from "./data-services/item-data.service";
@@ -25,7 +26,8 @@ const PROVIDERS = [
CollectionDataService, CollectionDataService,
ItemDataService, ItemDataService,
DSpaceRESTv2Service, DSpaceRESTv2Service,
ObjectCacheService ObjectCacheService,
RequestCacheService
]; ];
@NgModule({ @NgModule({

View File

@@ -6,7 +6,7 @@ import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.seriali
import { ObjectCacheService } from "../cache/object-cache.service"; import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { Actions, Effect } from "@ngrx/effects"; import { Actions, Effect } from "@ngrx/effects";
import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions"; import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions";
import { CollectionDataService } from "./collection-data.service"; import { CollectionDataService } from "./collection-data.service";
@Injectable() @Injectable()
@@ -20,11 +20,11 @@ export class CollectionDataEffects extends DataEffects<Collection> {
super(actions$, restApi, cache, dataService); super(actions$, restApi, cache, dataService);
} }
protected getFindAllEndpoint(action: FindAllRequestCacheAction): string { protected getFindAllEndpoint(action: RequestCacheFindAllAction): string {
return '/collections'; return '/collections';
} }
protected getFindByIdEndpoint(action: FindByIDRequestCacheAction): string { protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string {
return `/collections/${action.payload.resourceID}`; return `/collections/${action.payload.resourceID}`;
} }

View File

@@ -1,19 +1,18 @@
import { Injectable, OpaqueToken } from "@angular/core"; import { Injectable, OpaqueToken } from "@angular/core";
import { Store } from "@ngrx/store";
import { DataService } from "./data.service"; import { DataService } from "./data.service";
import { Collection } from "../shared/collection.model"; import { Collection } from "../shared/collection.model";
import { ObjectCacheService } from "../cache/object-cache.service"; import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheState } from "../cache/request-cache.reducer"; import { RequestCacheService } from "../cache/request-cache.service";
@Injectable() @Injectable()
export class CollectionDataService extends DataService<Collection> { export class CollectionDataService extends DataService<Collection> {
name = new OpaqueToken('CollectionDataService'); serviceName = new OpaqueToken('CollectionDataService');
constructor( constructor(
store: Store<RequestCacheState>, protected objectCache: ObjectCacheService,
cache: ObjectCacheService protected requestCache: RequestCacheService,
) { ) {
super(store, cache); super(Collection);
} }
} }

View File

@@ -1,4 +1,4 @@
import { Actions, Effect } from "@ngrx/effects"; import { Actions } from "@ngrx/effects";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model"; import { DSpaceRESTV2Response } from "../dspace-rest-v2/dspace-rest-v2-response.model";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
@@ -7,14 +7,14 @@ import { GlobalConfig } from "../../../config";
import { CacheableObject } from "../cache/object-cache.reducer"; import { CacheableObject } from "../cache/object-cache.reducer";
import { Serializer } from "../serializer"; import { Serializer } from "../serializer";
import { import {
RequestCacheActionTypes, FindAllRequestCacheAction, RequestCacheSuccessAction, RequestCacheActionTypes, RequestCacheFindAllAction, RequestCacheSuccessAction,
RequestCacheErrorAction, FindByIDRequestCacheAction RequestCacheErrorAction, RequestCacheFindByIDAction
} from "../cache/request-cache.actions"; } from "../cache/request-cache.actions";
import { DataService } from "./data.service"; import { DataService } from "./data.service";
export abstract class DataEffects<T extends CacheableObject> { export abstract class DataEffects<T extends CacheableObject> {
protected abstract getFindAllEndpoint(action: FindAllRequestCacheAction): string; protected abstract getFindAllEndpoint(action: RequestCacheFindAllAction): string;
protected abstract getFindByIdEndpoint(action: FindByIDRequestCacheAction): string; protected abstract getFindByIdEndpoint(action: RequestCacheFindByIDAction): string;
protected abstract getSerializer(): Serializer<T>; protected abstract getSerializer(): Serializer<T>;
constructor( constructor(
@@ -24,13 +24,11 @@ export abstract class DataEffects<T extends CacheableObject> {
private dataService: DataService<T> private dataService: DataService<T>
) {} ) {}
// TODO, results of a findall aren't retrieved from cache for now, // TODO, results of a findall aren't retrieved from cache yet
// because currently the cache is more of an object store. We need to move
// more towards memoization for things like this.
protected findAll = this.actions$ protected findAll = this.actions$
.ofType(RequestCacheActionTypes.FIND_ALL_REQUEST) .ofType(RequestCacheActionTypes.FIND_ALL)
.filter((action: FindAllRequestCacheAction) => action.payload.service === this.dataService.name) .filter((action: RequestCacheFindAllAction) => action.payload.service === this.dataService.serviceName)
.flatMap((action: FindAllRequestCacheAction) => { .flatMap((action: RequestCacheFindAllAction) => {
//TODO scope, pagination, sorting -> when we know how that works in rest //TODO scope, pagination, sorting -> when we know how that works in rest
return this.restApi.get(this.getFindAllEndpoint(action)) return this.restApi.get(this.getFindAllEndpoint(action))
.map((data: DSpaceRESTV2Response) => this.getSerializer().deserializeArray(data)) .map((data: DSpaceRESTV2Response) => this.getSerializer().deserializeArray(data))
@@ -40,20 +38,20 @@ export abstract class DataEffects<T extends CacheableObject> {
}); });
}) })
.map((ts: Array<T>) => ts.map(t => t.uuid)) .map((ts: Array<T>) => ts.map(t => t.uuid))
.map((ids: Array<string>) => new RequestCacheSuccessAction(action.payload.key, ids, GlobalConfig.cache.msToLive)) .map((ids: Array<string>) => new RequestCacheSuccessAction(action.payload.key, ids, new Date().getTime(), GlobalConfig.cache.msToLive))
.catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg)));
}); });
protected findById = this.actions$ protected findById = this.actions$
.ofType(RequestCacheActionTypes.FIND_BY_ID_REQUEST) .ofType(RequestCacheActionTypes.FIND_BY_ID)
.filter((action: FindAllRequestCacheAction) => action.payload.service === this.dataService.name) .filter((action: RequestCacheFindAllAction) => action.payload.service === this.dataService.serviceName)
.flatMap((action: FindByIDRequestCacheAction) => { .flatMap((action: RequestCacheFindByIDAction) => {
return this.restApi.get(this.getFindByIdEndpoint(action)) return this.restApi.get(this.getFindByIdEndpoint(action))
.map((data: DSpaceRESTV2Response) => this.getSerializer().deserialize(data)) .map((data: DSpaceRESTV2Response) => this.getSerializer().deserialize(data))
.do((t: T) => { .do((t: T) => {
this.objectCache.add(t, GlobalConfig.cache.msToLive); this.objectCache.add(t, GlobalConfig.cache.msToLive);
}) })
.map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], GlobalConfig.cache.msToLive)) .map((t: T) => new RequestCacheSuccessAction(action.payload.key, [t.uuid], new Date().getTime(), GlobalConfig.cache.msToLive))
.catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg))); .catch((errorMsg: string) => Observable.of(new RequestCacheErrorAction(action.payload.key, errorMsg)));
}); });

View File

@@ -1,39 +1,39 @@
import { OpaqueToken } from "@angular/core"; import { OpaqueToken } from "@angular/core";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { Store } from "@ngrx/store";
import { ObjectCacheService } from "../cache/object-cache.service"; import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheService } from "../cache/request-cache.service";
import { CacheableObject } from "../cache/object-cache.reducer"; import { CacheableObject } from "../cache/object-cache.reducer";
import { RequestCacheState } from "../cache/request-cache.reducer";
import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions";
import { ParamHash } from "../shared/param-hash"; import { ParamHash } from "../shared/param-hash";
import { isNotEmpty } from "../../shared/empty.util"; import { isNotEmpty } from "../../shared/empty.util";
import { GenericConstructor } from "../shared/generic-constructor";
export abstract class DataService<T extends CacheableObject> { export abstract class DataService<T extends CacheableObject> {
abstract name: OpaqueToken; abstract serviceName: OpaqueToken;
protected abstract objectCache: ObjectCacheService;
protected abstract requestCache: RequestCacheService;
constructor( constructor(private modelType: GenericConstructor<T>) {
private store: Store<RequestCacheState>,
private objectCache: ObjectCacheService }
) { }
findAll(scopeID?: string): Observable<Array<T>> { findAll(scopeID?: string): Observable<Array<T>> {
const key = new ParamHash(this.name, 'findAll', scopeID).toString(); const key = new ParamHash(this.serviceName, 'findAll', scopeID).toString();
this.store.dispatch(new FindAllRequestCacheAction(key, this.name, scopeID)); return this.requestCache.findAll(key, this.serviceName, scopeID)
//get an observable of the IDs from the store //get an observable of the IDs from the RequestCache
return this.store.select<Array<string>>('core', 'cache', 'request', key, 'resourceUUIDs') .map(entry => entry.resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => { .flatMap((resourceUUIDs: Array<string>) => {
// use those IDs to fetch the actual objects from the cache // use those IDs to fetch the actual objects from the ObjectCache
return this.objectCache.getList<T>(resourceUUIDs); return this.objectCache.getList<T>(resourceUUIDs, this.modelType);
}); });
} }
findById(id: string): Observable<T> { findById(id: string): Observable<T> {
const key = new ParamHash(this.name, 'findById', id).toString(); const key = new ParamHash(this.serviceName, 'findById', id).toString();
this.store.dispatch(new FindByIDRequestCacheAction(key, this.name, id)); return this.requestCache.findById(key, this.serviceName, id)
return this.store.select<Array<string>>('core', 'cache', 'request', key, 'resourceUUIDs') .map(entry => entry.resourceUUIDs)
.flatMap((resourceUUIDs: Array<string>) => { .flatMap((resourceUUIDs: Array<string>) => {
if(isNotEmpty(resourceUUIDs)) { if(isNotEmpty(resourceUUIDs)) {
return this.objectCache.get<T>(resourceUUIDs[0]); return this.objectCache.get<T>(resourceUUIDs[0], this.modelType);
} }
else { else {
return Observable.of(undefined); return Observable.of(undefined);

View File

@@ -6,7 +6,7 @@ import { DSpaceRESTv2Serializer } from "../dspace-rest-v2/dspace-rest-v2.seriali
import { ObjectCacheService } from "../cache/object-cache.service"; import { ObjectCacheService } from "../cache/object-cache.service";
import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service"; import { DSpaceRESTv2Service } from "../dspace-rest-v2/dspace-rest-v2.service";
import { Actions, Effect } from "@ngrx/effects"; import { Actions, Effect } from "@ngrx/effects";
import { FindAllRequestCacheAction, FindByIDRequestCacheAction } from "../cache/request-cache.actions"; import { RequestCacheFindAllAction, RequestCacheFindByIDAction } from "../cache/request-cache.actions";
import { ItemDataService } from "./item-data.service"; import { ItemDataService } from "./item-data.service";
@Injectable() @Injectable()
@@ -20,11 +20,11 @@ export class ItemDataEffects extends DataEffects<Item> {
super(actions$, restApi, cache, dataService); super(actions$, restApi, cache, dataService);
} }
protected getFindAllEndpoint(action: FindAllRequestCacheAction): string { protected getFindAllEndpoint(action: RequestCacheFindAllAction): string {
return '/items'; return '/items';
} }
protected getFindByIdEndpoint(action: FindByIDRequestCacheAction): string { protected getFindByIdEndpoint(action: RequestCacheFindByIDAction): string {
return `/items/${action.payload.resourceID}`; return `/items/${action.payload.resourceID}`;
} }

View File

@@ -1,19 +1,18 @@
import { Injectable, OpaqueToken } from "@angular/core"; import { Injectable, OpaqueToken } from "@angular/core";
import { Store } from "@ngrx/store";
import { DataService } from "./data.service"; import { DataService } from "./data.service";
import { Item } from "../shared/item.model"; import { Item } from "../shared/item.model";
import { ObjectCacheService } from "../cache/object-cache.service"; import { ObjectCacheService } from "../cache/object-cache.service";
import { RequestCacheState } from "../cache/request-cache.reducer"; import { RequestCacheService } from "../cache/request-cache.service";
@Injectable() @Injectable()
export class ItemDataService extends DataService<Item> { export class ItemDataService extends DataService<Item> {
name = new OpaqueToken('ItemDataService'); serviceName = new OpaqueToken('ItemDataService');
constructor( constructor(
store: Store<RequestCacheState>, protected objectCache: ObjectCacheService,
cache: ObjectCacheService protected requestCache: RequestCacheService,
) { ) {
super(store, cache); super(Item);
} }
} }

View File

@@ -2,13 +2,7 @@ import { Serialize, Deserialize } from "cerialize";
import { Serializer } from "../serializer"; import { Serializer } from "../serializer";
import { DSpaceRESTV2Response } from "./dspace-rest-v2-response.model"; import { DSpaceRESTV2Response } from "./dspace-rest-v2-response.model";
import { DSpaceRESTv2Validator } from "./dspace-rest-v2.validator"; import { DSpaceRESTv2Validator } from "./dspace-rest-v2.validator";
import { GenericConstructor } from "../shared/generic-constructor";
/**
* ensures we can use 'typeof T' as a type
* more details:
* https://github.com/Microsoft/TypeScript/issues/204#issuecomment-257722306
*/
type Constructor<T> = { new (...args: any[]): T } | ((...args: any[]) => T) | Function;
/** /**
* This Serializer turns responses from v2 of DSpace's REST API * This Serializer turns responses from v2 of DSpace's REST API
@@ -22,7 +16,7 @@ export class DSpaceRESTv2Serializer<T> implements Serializer<T> {
* @param modelType a class or interface to indicate * @param modelType a class or interface to indicate
* the kind of model this serializer should work with * the kind of model this serializer should work with
*/ */
constructor(private modelType: Constructor<T>) { constructor(private modelType: GenericConstructor<T>) {
} }
/** /**

View File

@@ -19,4 +19,5 @@ export class Bundle extends DSpaceObject {
* The Item that owns this Bundle * The Item that owns this Bundle
*/ */
owner: Item; owner: Item;
} }

View File

@@ -0,0 +1,7 @@
/**
* ensures we can use 'typeof T' as a type
* more details:
* https://github.com/Microsoft/TypeScript/issues/204#issuecomment-257722306
*/
export type GenericConstructor<T> = { new (...args: any[]): T };

View File

@@ -35,4 +35,5 @@ export class Item extends DSpaceObject {
* The Collection that owns this Item * The Collection that owns this Item
*/ */
owner: Collection; owner: Collection;
} }

View File

@@ -1,88 +0,0 @@
import { Inject, Injectable, isDevMode } from '@angular/core';
@Injectable()
export class DemoCacheService {
static KEY = 'DemoCacheService';
constructor( @Inject('LRU') public _cache: Map<string, any>) {
}
/**
* check if there is a value in our store
*/
has(key: string | number): boolean {
let _key = this.normalizeKey(key);
return this._cache.has(_key);
}
/**
* store our state
*/
set(key: string | number, value: any): void {
let _key = this.normalizeKey(key);
this._cache.set(_key, value);
}
/**
* get our cached value
*/
get(key: string | number): any {
let _key = this.normalizeKey(key);
return this._cache.get(_key);
}
/**
* release memory refs
*/
clear(): void {
this._cache.clear();
}
/**
* convert to json for the client
*/
dehydrate(): any {
let json = {};
this._cache.forEach((value: any, key: string) => json[key] = value);
return json;
}
/**
* convert server json into out initial state
*/
rehydrate(json: any): void {
Object.keys(json).forEach((key: string) => {
let _key = this.normalizeKey(key);
let value = json[_key];
this._cache.set(_key, value);
});
}
/**
* allow JSON.stringify to work
*/
toJSON(): any {
return this.dehydrate();
}
/**
* convert numbers into strings
*/
normalizeKey(key: string | number): string {
if (isDevMode() && this._isInvalidValue(key)) {
throw new Error('Please provide a valid key to save in the DemoCacheService');
}
return key + '';
}
_isInvalidValue(key): boolean {
return key === null ||
key === undefined ||
key === 0 ||
key === '' ||
typeof key === 'boolean' ||
Number.isNaN(<number>key);
}
}

View File

@@ -1,55 +0,0 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/share';
import { DemoCacheService } from '../demo-cache.service';
import { ApiService } from '../api.service';
export function hashCodeString(str: string): string {
let hash = 0;
if (str.length === 0) {
return hash + '';
}
for (let i = 0; i < str.length; i++) {
let char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash + '';
}
// domain/feature service
@Injectable()
export class ModelService {
// This is only one example of one Model depending on your domain
constructor(public _api: ApiService, public _cache: DemoCacheService) {
}
/**
* whatever domain/feature method name
*/
get(url) {
// you want to return the cache if there is a response in it.
// This would cache the first response so if your API isn't idempotent
// you probably want to remove the item from the cache after you use it. LRU of 10
// you can use also hashCodeString here
let key = url;
if (this._cache.has(key)) {
return Observable.of(this._cache.get(key));
}
// you probably shouldn't .share() and you should write the correct logic
return this._api.get(url)
.do(json => {
this._cache.set(key, json);
})
.share();
}
// don't cache here since we're creating
create() {
// TODO
}
}

View File

@@ -7,7 +7,6 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from 'ng2-translate/ng2-translate'; import { TranslateModule } from 'ng2-translate/ng2-translate';
import { ApiService } from './api.service'; import { ApiService } from './api.service';
import { ModelService } from './model/model.service';
const MODULES = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -28,7 +27,6 @@ const COMPONENTS = [
]; ];
const PROVIDERS = [ const PROVIDERS = [
ModelService,
ApiService ApiService
]; ];

16
src/app/store.actions.ts Normal file
View File

@@ -0,0 +1,16 @@
import { type } from "./shared/ngrx/type";
import { Action } from "@ngrx/store";
import { AppState } from "./app.reducers";
export const StoreActionTypes = {
REHYDRATE: type('dspace/ngrx/rehydrate')
};
export class RehydrateStoreAction implements Action {
type = StoreActionTypes.REHYDRATE;
constructor(public payload: AppState) {}
}
export type StoreAction
= RehydrateStoreAction;

View File

@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
import { Http } from '@angular/http'; import { Http } from '@angular/http';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { UniversalModule, isBrowser, isNode, AUTO_PREBOOT } from 'angular2-universal/browser'; // for AoT we need to manually split universal packages import { UniversalModule, isBrowser, isNode } from 'angular2-universal/browser'; // for AoT we need to manually split universal packages
import { IdlePreload, IdlePreloadModule } from '@angularclass/idle-preload'; import { IdlePreload, IdlePreloadModule } from '@angularclass/idle-preload';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
@@ -10,12 +10,18 @@ import { TranslateLoader, TranslateModule, TranslateStaticLoader } from 'ng2-tra
import { AppModule, AppComponent } from './app/app.module'; import { AppModule, AppComponent } from './app/app.module';
import { SharedModule } from './app/shared/shared.module'; import { SharedModule } from './app/shared/shared.module';
import { DemoCacheService } from './app/shared/demo-cache.service';
import { CoreModule } from "./app/core/core.module"; import { CoreModule } from "./app/core/core.module";
import { StoreModule, Store } from "@ngrx/store";
import { RouterStoreModule } from "@ngrx/router-store";
import { StoreDevtoolsModule } from "@ngrx/store-devtools";
import { rootReducer, NGRX_CACHE_KEY, AppState } from './app/app.reducers';
import { effects } from './app/app.effects';
// Will be merged into @angular/platform-browser in a later release // Will be merged into @angular/platform-browser in a later release
// see https://github.com/angular/angular/pull/12322 // see https://github.com/angular/angular/pull/12322
import { Meta } from './angular2-meta'; import { Meta } from './angular2-meta';
import { RehydrateStoreAction } from "./app/store.actions";
// import * as LRU from 'modern-lru'; // import * as LRU from 'modern-lru';
@@ -38,7 +44,6 @@ export function getResponse() {
} }
// TODO(gdi2290): refactor into Universal
export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
@NgModule({ @NgModule({
@@ -60,6 +65,10 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
CoreModule.forRoot(), CoreModule.forRoot(),
SharedModule, SharedModule,
AppModule, AppModule,
StoreModule.provideStore(rootReducer),
RouterStoreModule.connectRouter(),
StoreDevtoolsModule.instrumentOnlyWithExtension(),
effects
], ],
providers: [ providers: [
{ provide: 'isBrowser', useValue: isBrowser }, { provide: 'isBrowser', useValue: isBrowser },
@@ -70,23 +79,21 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
{ provide: 'LRU', useFactory: getLRU, deps: [] }, { provide: 'LRU', useFactory: getLRU, deps: [] },
DemoCacheService,
Meta, Meta,
// { provide: AUTO_PREBOOT, useValue: false } // turn off auto preboot complete // { provide: AUTO_PREBOOT, useValue: false } // turn off auto preboot complete
] ]
}) })
export class MainModule { export class MainModule {
constructor(public cache: DemoCacheService) { constructor(public store: Store<AppState>) {
// TODO(gdi2290): refactor into a lifecycle hook // TODO(gdi2290): refactor into a lifecycle hook
this.doRehydrate(); this.doRehydrate();
} }
doRehydrate() { doRehydrate() {
let defaultValue = {}; let defaultValue = {};
let serverCache = this._getCacheValue(DemoCacheService.KEY, defaultValue); let serverCache = this._getCacheValue(NGRX_CACHE_KEY, defaultValue);
this.cache.rehydrate(serverCache); this.store.dispatch(new RehydrateStoreAction(serverCache));
} }
_getCacheValue(key: string, defaultValue: any): any { _getCacheValue(key: string, defaultValue: any): any {
@@ -95,7 +102,7 @@ export class MainModule {
if (win[UNIVERSAL_KEY] && win[UNIVERSAL_KEY][key]) { if (win[UNIVERSAL_KEY] && win[UNIVERSAL_KEY][key]) {
let serverCache = defaultValue; let serverCache = defaultValue;
try { try {
serverCache = JSON.parse(win[UNIVERSAL_KEY][key]); serverCache = win[UNIVERSAL_KEY][key];
if (typeof serverCache !== typeof defaultValue) { if (typeof serverCache !== typeof defaultValue) {
console.log('Angular Universal: The type of data from the server is different from the default value type'); console.log('Angular Universal: The type of data from the server is different from the default value type');
serverCache = defaultValue; serverCache = defaultValue;

View File

@@ -9,9 +9,14 @@ import { TranslateLoader, TranslateModule, TranslateStaticLoader } from 'ng2-tra
import { AppModule, AppComponent } from './app/app.module'; import { AppModule, AppComponent } from './app/app.module';
import { SharedModule } from './app/shared/shared.module'; import { SharedModule } from './app/shared/shared.module';
import { DemoCacheService } from './app/shared/demo-cache.service';
import { CoreModule } from "./app/core/core.module"; import { CoreModule } from "./app/core/core.module";
import { StoreModule, Store } from "@ngrx/store";
import { RouterStoreModule } from "@ngrx/router-store";
import { StoreDevtoolsModule } from "@ngrx/store-devtools";
import { rootReducer, AppState, NGRX_CACHE_KEY } from './app/app.reducers';
import { effects } from './app/app.effects';
// Will be merged into @angular/platform-browser in a later release // Will be merged into @angular/platform-browser in a later release
// see https://github.com/angular/angular/pull/12322 // see https://github.com/angular/angular/pull/12322
import { Meta } from './angular2-meta'; import { Meta } from './angular2-meta';
@@ -30,7 +35,6 @@ export function getResponse() {
return Zone.current.get('res') || {}; return Zone.current.get('res') || {};
} }
// TODO(gdi2290): refactor into Universal
export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE'; export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
@NgModule({ @NgModule({
@@ -51,6 +55,10 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
CoreModule.forRoot(), CoreModule.forRoot(),
SharedModule, SharedModule,
AppModule, AppModule,
StoreModule.provideStore(rootReducer),
RouterStoreModule.connectRouter(),
StoreDevtoolsModule.instrumentOnlyWithExtension(),
effects
], ],
providers: [ providers: [
{ provide: 'isBrowser', useValue: isBrowser }, { provide: 'isBrowser', useValue: isBrowser },
@@ -61,13 +69,11 @@ export const UNIVERSAL_KEY = 'UNIVERSAL_CACHE';
{ provide: 'LRU', useFactory: getLRU, deps: [] }, { provide: 'LRU', useFactory: getLRU, deps: [] },
DemoCacheService,
Meta, Meta,
] ]
}) })
export class MainModule { export class MainModule {
constructor(public cache: DemoCacheService) { constructor(public store: Store<AppState>) {
} }
@@ -76,14 +82,17 @@ export class MainModule {
* in Universal for now until it's fixed * in Universal for now until it's fixed
*/ */
universalDoDehydrate = (universalCache) => { universalDoDehydrate = (universalCache) => {
universalCache[DemoCacheService.KEY] = JSON.stringify(this.cache.dehydrate()); this.store.take(1).subscribe(state => {
} universalCache[NGRX_CACHE_KEY] = state;
});
};
/** /**
* Clear the cache after it's rendered * Clear the cache after it's rendered
*/ */
universalAfterDehydrate = () => { universalAfterDehydrate = () => {
// comment out if LRU provided at platform level to be shared between each user // comment out if LRU provided at platform level to be shared between each user
this.cache.clear(); // this.cache.clear();
//TODO is this necessary in dspace's case?
} }
} }