Having a decorator that caches the result of a service call is not a new quest on the web, but today I want to write my own decorator, and save the results of a service all in local storage, with set expiration time, easily invalidated.
We previously created a local storage wrapper service that qualifies for this job and we shall use it today.
LocalStorage wrapper service in Angular
Follow along on the same blitz project: StackBlitz LocalStorage Project
The end in sight
Working backwards, we want to end up with something like this:
const x: Observable<something> = someService.GetItems({});
Then the service should know where to get its stuff.
class SomeService {
// lets decorate it
@DataCache()
GetItems(options) {
return http.get(...);
}
}
The function of the decorator needs to know where to get result from, and whether the http
request should carry on. We need to pass few parameters, and a pointer to our local storage service. Let's dig in to see how to do
Method decorator
Let's implement the DataCache
according to documentation. This following snippet does nothing, it returns the method as it is.
function DataCache() {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value; // save a reference to the original method
// NOTE: Do not use arrow syntax here. Use a function expression in
// order to use the correct value of `this` in this method (see notes below)
descriptor.value = function (...args: any[]) {
return originalMethod.apply(this, args);
};
return descriptor;
};
}
What we need is a way to change direction of the method call. The following is the service method without the decorator, notice how we access the class instance of storageService
, which we will not have access to in the decorator.
// without caching:
class SomeService {
GetItems(options) {
// this storage service is supposed to take care of a lot of things
// like finding the item by correct key, parse json, and invalidate if expired
// note, we will not have access to the storage service in the decorator
const _data: ISomething[] = this.storageService.getCache(`SOMEKEY`);
if (_data) {
// if localStroage exist, return
return of(_data);
} else {
// get from server
return this._http.get(_url).pipe(
map(response => {
// again, the service is supposed to set data by correct key
// and corret epxiration in hours
this.storageService.setCache(`SOMEKEY`, response, 24);
// return it
return response;
})
);
}
}
}
Taking all of that into our decorator function, following are the first two inputs we need from our service: key, and expiration (in hours). So let's pass that in the decorator caller:
// decorator function
export function DataCache(options: {
key?: string;
expiresin: number;
}) {
return function (...) {
// ... to implement
return descriptor;
};
}
In addition to options
, I need to pass back the storage service instance, because the decorator function has no context of its own. To gain access to that instance, it is available in the this
keyword inside the descriptor
value function.
Note: I am not trying to pass a different storage service every time, I am simply relying on the current project storage service.
// decorator function
export function DataCache(options: {
key?: string;
expiresin: number;
}) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// ... to implement
// this keyword belongs to the instantiated constructor along with its members
this.storageService.doSomething();
return descriptor;
};
}
The last bit is to know how to build on top of a return function. In our example we are returning an observable
. To build on it, a map
function may work, a tap
is more benign.
// the decorator
export function DataCache(options: {
key?: string;
expiresin: number;
}) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// check service, we will remove typing later, and enhance key
const _data: ISomething[] = this.storageService.getCache(options.key);
if (_data) {
// if localStroage exist, return
return of(_data);
} else {
// call original, this we know is an observable, let's pipe
return originalMethod.apply(this, args).pipe(
tap(response => {
this.storageService.setCache(options.key, response, options.expiresin);
})
);
}
};
return descriptor;
};
}
And to use it, we need to inject the storage service in the class that has this method:
// how to use it:
class SomeService {
// inject httpClient and storageservice
constructor(private _http: HttpClient, private storageService: StorageService){
}
// decorate and keep simple
@DataCache({key: 'SOMEKEY', expiresin: 24})
GetItems(options) {
// optionally map to some internal model
return this._http.get(_url);
}
}
}
So tap
was good enough in this example.
Tidy up
First, let's enhance our typing, let's make the decorator of a generic, and let's model out the options. We can also make the options
a partial interface, to allow optional properties.
// the decorator
// these options partially share the interface of our storage service
export interface ICacheOptions {
key: string;
expiresin: number;
};
// make it partial
export function DataCache<T>(options: Partial<ICacheOptions>) {
return function (...) {
// we shall make use of T in a bit
return descriptor;
};
}
// how to use it:
class SomeService {
// ...
@DataCache<WhatGenericDoINeed>({key: 'SOMEKEY', expiresin: 24})
GetItems(options): Observable<ISomething[]> {
// ... http call
}
}
But I still see no use of the generic. You might think that the following is neat, but it is an overkill.
@DataCache<ISomething[]>()
Nevertheless, let's keep the generic, because we shall make use of it later. Take my word for it and keep reading.
Setting the right key
One enhancement to the above is constructing a key that is unique to the method, and its parameters. So GetSomething({page: 1})
will be saved in a different key than GetSomething({page: 2})
. We can also use target.constructor.name
to use the class name as part of the key. the method name is saved in propertyKey
. Let us also make that optional.
// the decorator
export interface ICacheOptions {
key: string;
expiresin: number;
withArgs: boolean; // to derive key from args
};
export function DataCache<T>(options: Partial<ICacheOptions>) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
// use options key or construct from constructor and method name
const cacheKey = options.key || `${target.constructor.name}.${propertyKey}`;
descriptor.value = function (...args: any[]) {
// append arguments if key is not passed
const key = options?.withArgs
? `${cacheKey}_${JSON.stringify(args)}`
: cacheKey;
// ...
};
return descriptor;
};
}
So we can call it on specific services like the following
// examplte service
@Injectable({ providedIn: 'root' })
export class RateService {
constructor(
private _http: HttpService,
// this is a must
private storageService: StorageService
) {}
// use data decorator to retreive from storage if exists
@DataCache<any>({ withArgs: true, expiresin: 2 })
GetRates(params: IParams): Observable<IData[]> {
// get from http
return this._http.get(this._ratesUrl).pipe(
map((response) => {
// map response to internal model
let _retdata = DataClass.NewInstances(<any>response);
return _retdata;
})
);
}
}
In a component, if this service is called with params:
this.rates$ = this.rateService.GetRates({ something: 1 });
This will generate the following key in local storage (given that our storage key is garage
, and we use multilingual prefixes):
garage.en.RateService.GetRates_[{"something":1}]
Production build
In production, the key looks slightly different as the RateService
name will be obfuscated, it would probably have a letter in place:
garage.en.C.GetRates...
The key would be regenerated upon new build deployments. I am okay with it. If you are, high five. Else, you probably need to do some extra work to pass the method name as an explicit key.
Intellisense, kind of.
If you have noticed, in VSCode, the injected storageService
private property in RateService
, was dimmed. It isn't easy to make decorators type friendly, since they are still under experimental features. But one way to make sure our services inject the right storage service, we can do the following: make the storageService
a public property, and tie up the generic of the decorator, then use that generic as the target
type like this:
// add an interface that enforces a public property for the storage service
interface IStorageService {
storageService: StorageService;
}
// add a generic that extends our new interface
export function DataCache<T extends IStorageService>(options?: Partial<ICachedStorage>) {
// the target must have the service storageService as a public property
return function (target: T,...) {
//... the rest
}
}
Our service needs to declare the storageService
as a public member, but there is no need to add the generic to the DataCache
decorator, since it is the target
value by definition. So this is how it looks:
// an example service
constructor(public storageService: StorageService, private _http: HttpClient) { }
// no need to be more indicative
@DataCache()
GetItems(...) {
}
Explicitly passing the storage serviec
In the above example, if we have a controlled environment we know exactly which service to use. If however we are in an environment that has multiple storage services, we can pass back the service explicitly.
// the decorator
export interface ICacheOptions {
key: string;
expiresin: number;
withArgs: boolean;
service: () => StorageService // where this is an existing service
};
function DataCache<T extends IStorageService>(options: partial<ICacheOptions>) {
// ...
return function (target: T...) {
descriptor.value = function (...args: any[]) {
// call function with "this" scope
const storageService = options.service.call(this);
// then use it
// ...
}
}
}
In the service, now it is a must to pass the service back
// some service
class SomeService {
// inject storageService to pass
constructor(public storageService: StorageService) {
}
@DataCache({
// assign and pass back service explicitly
service: function () {
return this.storageService;
}
})
GetItems(options): Observable<ISomething[]> {
// ...
}
}
Needless to say, the storage service must implement an interface the decorator understands, specifically setCache
, getCache
, expiresin
, ... etc. I cannot think of another storage service besides local storage. So I won't go down this track.
Thank you for reading this far. Did you high five me back?
Top comments (0)