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:
- The requested list node is retrieved from the server,
requestItemFromServer
returns an array of length one containing the list node. requestDependencies
analyses the list node’s dependencies and requests the list’s root node to get the whole list. Note thatgetItem
‘s second argumentisDependency
is set totrue
and is passed to the methodrequestItemFromServer
. 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 (noteslice
) are written to the cache insaveAdditionalItems
. By convention, the root node is the first element in the array.- As a last step in the
pipe
before theshareReplay
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 theReplaySubject
.
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 thekey
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 thekey
.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
.