Sekrab Garage

Storage and Caching

A cache decorator in Angular

AngularDesign June 23, 23
Subscribe to Sekrab Parts newsletter.

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

anchorThe 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

anchorMethod 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.

anchorTidy 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.

anchorSetting 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

// example 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.

anchorIntellisense, 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(...) {
  
}

anchorExplicitly 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?