Introduction
In client-side applications, certain server requests remain the same no matter which view the user happens to be on. A cache removes duplicate HTTP calls and, when augmented with logic that inspects server responses, can also cut down on the number of future requests. Since the cache's inner workings shouldn't depend on the shape of the data it handles, we turn to generics.
Related Approaches
One way to handle caching is with RxJS's shareReplay operator. Multiple subscribers attach to the same ReplaySubject, and server responses are replayed to each of them rather than fetching the same data again from the server:
For applications that only need to retrieve straightforward data, that solution works fine. But what happens when the data coming from the server is more elaborate, or when it has dependencies on other pieces of data? And wouldn't it be nice to keep the caching logic decoupled from the specific response type you're working with? If those questions resonate, this article might be worth your time.
Caching using shareReplay
When a request comes in, a fresh entry is placed in the cache if one doesn't exist; otherwise, the cached entry is returned right away. In the example below, we use RxJS's shareReplay operator to convert the source Observable produced by requestItemFromServer into a ReplaySubject that multiple consumers can subscribe to without triggering another call 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];
}
}
Generic Cache with the shareReplay Operator
Using shareReplay is a short-hand way of multicasting through a ReplaySubject. The details of this are explored in depth in the article Understanding RxJS Multicast Operators.
This pattern also holds up when the cache receives multiple requests for the same item while the fetch is still underway. Late subscribers will still receive the ReplaySubject's most recent (and only) value. Given that we're dealing with HTTP requests, the buffer size is set to 1 since there are no further emissions after completion. For that same reason, reference counting isn't necessary; we don't need automatic subscription and unsubscription from the source Observable based on how many subscribers the Subject currently has.
Notice that the cache stores an Observable of type T, keeping the logic generic. GenericCache is an abstract class that can't be instantiated directly. For a particular response type, you can create a subclass of GenericCache. More on that in the section titled "Separating the Caching Logic from the Type of Response".
Resolving Dependencies
Consider a scenario where we need to fetch a project's data model. Every project data model relies on a shared base data model, and project data models themselves can reference other project data models. Interdependencies between data models are permitted as well—A can point to B and B can point back to A.
Once a data model arrives via requestItemFromServer, we inspect its references to other data models. The method getDependenciesOfItem takes a data model and returns an array of keys that identify the models it depends on. For instance, project data model A might depend on the base data model and on data model B, while the base data model stands alone and data model B depends only on the base model with no references to other project data models.
Project Data Model A with Dependencies
Since the consumer who asked for data model A will quite likely also want the base data model and data model B, we fetch those elements as well and store them in the cache.
/**
* 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];
}
Resolving Dependencies of Cached Items
To steer clear of needless getItem calls, we check whether the item is already in the cache before requesting it. (It doesn't matter whether the data has already been retrieved or if the request is still pending.) If there's no entry for a key yet, the item gets requested. These calls aren't synchronized, and that's intentional—interdependencies would cause blocking if they were.
Given that a requested item's dependencies are already handled, they'll very likely be cached by the time the client asks for them. Note that subscribe is invoked on getItem; otherwise, the cache entry would be created but no actual data fetching would begin.
The logic inside tap executes only once, no matter how many times the associated element is pulled from the cache. That's because subscribers attach to the ReplaySubject returned by sharedReplay, not to the underlying Observable—see the docs.
Optimising Possible Future Requests
We also need to handle hierarchical lists. The server exposes two endpoints for working with such lists: one returns a single list node, and the other returns the entire list. A list is identified by its root node.
Hierarchical List
So when the client requests a single list node, the whole list can be pulled in as a dependency, and every node within it gets written to the cache. After that, any node from that list can be served straight from the cache without making further HTTP calls.
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];
}
Optimising Possible Future Requests
Notice that requestItemFromServer now hands back an array of T. This allows us to fetch all nodes of a list with a single request. The process unfolds in three stages:
- The requested list node is fetched from the server;
requestItemFromServerreturns an array with a single element—the list node itself. requestDependenciesexamines the list node's dependencies and asks for the list's root node to obtain the entire list. Note thatgetItem's second argument,isDependency, is set totrueand gets passed along torequestItemFromServer. Using that flag,requestItemFromServerknows to return an array containing every node in the list. (Essentially,isDependencydistinguishes between a request for a single node and a request for the root node that represents the full list.) All nodes apart from the root node (seeslice) are stored in the cache viasaveAdditionalItems. By convention, the root node sits at the first position in the array.- As the final step in the
pipebefore theshareReplayoperator, the array of nodes is mapped to its first element—the requested list node in step 1 or the root node in step 2—which ends up inside theReplaySubject.
Retrying Failed Requests
If a request to the server fails, the subscriber gets notified through the error callback. In that scenario, the source Observable will be retried the next time that same element is requested.
Separating the Caching Logic from the Type of Response
The caching logic outlined above doesn't depend on any particular server response type. Still, it's reasonable to expect the data to be an object: abstract class GenericCache<T extends object>. Several methods within GenericCache are abstract, and so is the class itself. That means to actually use GenericCache, you need a concrete implementation that provides all of its abstract methods:
protected abstract requestItemFromServer(key: string, isDependency: boolean): Observable<T[]>: retrieves an item identified bykeyfrom the server. It returns an array with one element—the requested item—or, when the query can be optimized, an array containing several items of the same type, where the first element is the item associated withkey.protected abstract getDependenciesOfItem(item: T): string[]: returns an array of ids (keys) that the given item depends on.protected abstract getKeyOfItem(item: T): string: returns the id (key) for the given item.
For DataModel, the GenericCache implementation looks like this:
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;
}
}
Implementation of GenericCache for DataModel
And for ListNode, it can be put together like so:
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] : [];
}
}
Implementation of GenericCache for ListNode
Summing Up
RxJS's shareReplay operator is the tool we use to cache server responses, which are then replayed to every subscriber. For each item that arrives from the server, we analyze its dependencies on other items. Any dependency that isn't already cached gets fetched automatically. Where it's feasible, dependency requests are optimized by switching to a different server endpoint. The approach presented here leans on generics so the caching logic stays independent of the response type. To cache a specific kind of response, you simply extend the abstract GenericCache class with your own implementation.
