How to Build a Type Agnostic Cache Using Generics in TypeScript - Windows ASP.NET Core Hosting 2024 | Review and ComparisonWindows ASP.NET Core Hosting 2024 | Review and Comparison

How to Build a Type Agnostic Cache Using Generics in TypeScript

Introduction

When building a client application, some requests to the server are identical regardless of the specific view the user is in. A cache eliminates redundant HTTP calls and may also help reduce the number of potential future HTTP calls if it implements some additional logic for analysing the response received from the server. Because the implementation of the cache should be independent of the response format, we apply generics.

Related Approaches

If your application needs to fetch some simple pieces of data, this approach is totally sufficient. But what if the data fetched from the server is more complex and might even have dependencies on other pieces of data? And wouldn’t it be desirable to separate the logic needed for caching from the actual type of response you deal with? If this is the case, then this article may be of interest to you.

Caching using shareReplay

Upon a request, a new entry is created in the cache if it does not exist yet, otherwise it is directly returned from the cache. In the example shown below, we are applying RxJs’s shareReplay operator to turn the source Observable returned by the method requestItemFromServer into a ReplaySubject that multiple consumers can then subscribe to, without re-executing the request to the server.

export abstract class GenericCache<T> {

	/**
     * Retrieves an item from the server.
     *
     * @param key the id of the item to be returned.
     */
    protected abstract requestItemFromServer(key: string): Observable<T>;

	// key -> value
    private cache: { [key: string]: Observable<T> } = {};

	/**
     * Gets a specific item from the cache.
     * If not cached yet, the information will be retrieved from the server.
     *
     * @param key the id of the item to be returned.
     */
    getItem(key: string): Observable<T> {
    
    	// If the key already exists,
        // return the associated Observable (ReplaySubject).
        if (this.cache[key] !== undefined) {
            return this.cache[key];
        }
		
        // writes the new ReplaySubject to the cache
        this.cache[key] = this.requestItemFromServer(key, isDependency).pipe(
        	take(1),
        	// set buffer size to 1
            // deactivate reference counting
        	shareReplay({refCount: false, bufferSize: 1})
        );
        
        // return the new ReplaySubject
        return this.cache[key];
        
    }
}

Applying the operator shareReplay is a compact form for performing multicasting using a ReplaySubject.

This approach also works if the cache gets several requests for the same element while the fetching of data is still in progress. Also late subscribers will get the ReplaySubject‘s latest (and only) value. Since we are dealing with HTTP requests, the buffer size is set to 1 because there won’t be any other emission after completion. For the same reason, we do not need reference counting which would automatically unsubscribe from the source Observable and resubscribe to it, depending on the active subscriptions to the Subject.

As you can see, we cache an Observable of type T to make the logic generic. GenericCache is an abstract class that cannot be instantiated. For a specific type of response, a subclass of GenericCache can be made. This approach is covered in the section “Separating the Caching Logic from the Type of Response”.

Resolving Dependencies

We have a use case where we request a project’s data model. There is a base data model all project data models depend on, and project data models may refer to other project data models. In fact, also interdependencies of data models are allowed. (A refers to B and vice versa.)

Upon receipt of a data model via requestItemFromServer, we analyse its references to other data models. Given a data model, the method getDependenciesOfItem returns an array of keys identifying data models it depends on. For example, project data model A depends on the base data model and on data model B, whereas the base data model is self-contained and data model B depends on the base data model and contains no references to other project data models.

Since it is very probable that the consumer that requested data model A will also ask for both the base data model and data model B, we also request those elements and cache them.

/**
 * Given an item, determines its dependencies on other items (their ids).
 *
 * @param item the item whose dependencies have to be determined.
 */
protected abstract getDependenciesOfItem(item: T): string[];

/**
* Requests dependencies of the item retrieved from the server.
*
* @param item item returned from the server to a request.
*/
private requestDependencies(item: T) {
	this.getDependenciesOfItem(item)
       	.filter((depKey: string) => {
        	// ignore dependencies already taken care of
    	    return !Object.keys(this.cache).includes(depKey);
        })
        .forEach((depKey: string) => {
    		// request each dependency from the cache
            // dependencies will be fetched asynchronously.
            this.getItem(depKey).subscribe();
        });
}

protected abstract requestItemFromServer(key: string): Observable<T>;

getItem(key: string): Observable<T> {

	...

	this.cache[key] = this.requestItemFromServer(key)
		.pipe(
    		take(1),
        	tap(
        		(item: T) => this.requestDependencies(item)
        	),
        	shareReplay({refCount: false, bufferSize: 1})
     	);
        
	return this.cache[key];        
}

To avoid unnecessary calls of getItem, we first check if the item already exists in the cache. (It is not relevant whether the data has already been fetched from the server or the request is still in progress.) If there is no entry yet for a key, the item is requested. Note that the calls are not synchronised. This is because interdependencies would lead to blocking calls.

Since the requested item’s dependencies are already taken care of, it is likely that they are present in the cache once the client asks for them. Note that subscribe is called on getItem because otherwise the entry would just be created in the cache but not start fetching the data.

Everything contained in tap will only be done once regardless of how many times the related element is retrieved from the cache. This is because subscribers subscribe to a ReplaySubject returned by sharedReplay and not the underlying Observable, see docs.

Optimising Possible Future Requests

Another use case we have to cover are hierarchical lists. The server offers two methods to get information about a hierarchical list: one to get a single list node and one to get the list as a whole. The list is identified by its root node.

Now when the client asks for a single list node, the whole list can be retrieved as a dependency and all of its nodes are written to the cache. After that, any node from the list can be requested from the cache without performing additional HTTP requests to the server.

protected abstract getDependenciesOfItem(item: T): string[];

/**
* Given an item, determines its key.
*
* @param item The item whose key has to be determined.
*/
protected abstract getKeyOfItem(item: T): string;

/**
 * Retrieves an item from the server.
 *
 * @param key the id of the item to be returned.
 * @param isDependency true if the requested key is a dependency of another item.
 */
protected abstract requestItemFromServer(key: string, isDependency: boolean): Observable<T[]>;

/**
 * Requests dependencies of the items retrieved from the server.
 *
 * @param items items returned from the server to a request.
 */
private requestDependencies(items: T[]) {
	...
    // flag as dependency
	this.getItem(depKey, true).subscribe();
    ...   
}

/**
 * Handle additional items that were resolved with a request.
 *
 * @param items dependencies that have been retrieved.
 */
private saveAdditionalItems(items: T[]) {

	// Write all available items to the cache (only for non existing keys)
    // Analyze dependencies of available items.
    items.forEach(
    	(item: T) => {
            // Get key of item
            const itemKey = this.getKeyOfItem(item);

    		// Only write an additional item to the cache
    		// if there is no entry for it yet
    		if (this.cache[itemKey] === undefined) {
    			this.cache[itemKey] = of(item);
    		}
    	}
    );

}

getItem(key: string, isDependency = false): Observable<T> {

	...

	this.cache[key] = this.requestItemFromServer(key, isDependency)
		.pipe(
    		take(1),
        	tap(
        		(items: T[]) => {
                	// save all additional items returned for this request
                	this.saveAdditionalItems(items.slice(1));
                
                	// request dependencies of all items
                	this.requestDependencies(items);
                }
        	),
            map((res: T[]) => res[0]),
        	shareReplay({refCount: false, bufferSize: 1})
     	);
        
	return this.cache[key];        
}

Note that requestItemFromServer now returns an array of T. This means that now it is possible to get all nodes of a list in one request. This works in three steps:

  1. The requested list node is retrieved from the server, requestItemFromServer returns an array of length one containing the list node.
  2. requestDependencies analyses the list node’s dependencies and requests the list’s root node to get the whole list. Note that  getItem‘s second argument isDependency is set to true and is passed to the method requestItemFromServer. With that information, requestItemFromServer returns an array containing all nodes of the list. (In fact, isDependency can be used to distinguish between the request of a single list node and the root node which represents the whole list.)  All the nodes except the root node (note slice) are written to the cache in saveAdditionalItems. By convention, the root node is the first element in the array.
  3. As a last step in the pipe before the shareReplay operator, the array of nodes is mapped to the first node (the requested list node in case of step 1 and the root node in case of step 2), that is then contained in the ReplaySubject.

Retrying Failed Requests

If a request to the servers fails, the subscriber will get notified in the error callback. In that case, the source Observable will be retried upon the next request for the same element.

Separating the Caching Logic from the Type of Response

The logic described above is implemented independently of a certain type of response from the server. However, it makes sense to assume it is an object type: abstract class GenericCache<T extends object>. Some of the methods in the class GenericCache are abstract and so is the class itself. This means in order to use GenericCache, an implementation has to be provided implementing all of its abstract methods:

  • protected abstract requestItemFromServer(key: string, isDependency: boolean): Observable<T[]>: fetches an item identified by the key from the server. This returns an array of length one containing the requested item or an array containing more items of the same type if the query can be optimised, the first element being the item identified by the key.
  • protected abstract getDependenciesOfItem(item: T): string[]: returns an array of ids (keys) the given item depends on.
  • protected abstract getKeyOfItem(item: T): string: returns the id (key) of the given item.

For DataModel, the GenericCache can now be implemented as follows:

export class DataModel {

	id: string;
	label: string;
    dependencies: string[]; // ids of data models this data model depends on
    
    ...

}

export class DataModelCache extends GenericCache<DataModel> {

	protected requestItemFromServer(key: string, isDependency: boolean): Observable<DataModel[]> {
        return ajax.get('https://www.mybackend.com/dataModel/' + encodeURIComponent(key)).pipe(
            map((ajaxResponse: AjaxResponse) => [ajaxResponse.response])
        );
    }

    protected getKeyOfItem(item: DataModel): string {
        return item.id;
    }

    protected getDependenciesOfItem(item: DataModel): string[] {
        return item.dependencies;
    }	

}

For ListNode, it can be implemented like this:

export class ListNode {

	id: string;
	label: string;
    hasRootNode?: string; // each list node holds the id of its root node
    
    ...

}

export class ListNodeCache extends GenericCache<ListNode> {

	protected requestItemFromServer(key: string, isDependency: boolean): Observable<ListNode[]> {
    	if(!isDependency) {
        	// a single list node was requested
        	return ajax.get('https://www.mybackend.com/node/' + encodeURIComponent(key)).pipe(
            	map((ajaxResponse: AjaxResponse) => [ajaxResponse.response])
        );
        } else {
        	// root node was requested as a dependency of a list node
            // all list nodes are returned as an array
        	return ajax.get('https://www.mybackend.com/list/' + encodeURIComponent(key)).pipe(
            	map((ajaxResponse: AjaxResponse) => ajaxResponse.response)
        }
    }

    protected getKeyOfItem(item: ListNode): string {
        return item.id;
    }

    protected getDependenciesOfItem(item: ListNode): string[] {
        return item.hasRootNode ? [item.hasRootNode] : [];
    }	

}

Summing Up

RxJS’s shareReplay operator is used to cache responses received from the server. These are replayed to each subscriber. For each item received from the server, its dependencies on other items are analysed. If not cached yet, dependencies are requested from the server automatically. If possible, requests for dependencies are optimised by using a different server route. The approach shown here applies generics so that the caching logic is independent of the type of response. For a specific type of response to be cached, an implementation can be provided extending the abstract class GenericCache.