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.
Caching can be implemented by using RxJS's
shareReplay operator. Different consumers subscribe to the same
ReplaySubject, and server responses are replayed to them instead of requesting the same information from the server repeatedly:
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.
Applying the operator
shareReplay is a compact form for performing multicasting using a
ReplaySubject. This topic is covered extensively in the article Understanding RxJS Multicast Operators.
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
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".
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.
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.
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,
requestItemFromServerreturns an array of length one containing the list node.
requestDependenciesanalyses the list node's dependencies and requests the list's root node to get the whole list. Note that
getItem's second argument
isDependencyis set to
trueand is passed to the method
requestItemFromServer. With that information,
requestItemFromServerreturns an array containing all nodes of the list. (In fact,
isDependencycan 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.
- As a last step in the
shareReplayoperator, 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
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
keyfrom 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
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.
GenericCache can now be implemented as follows:
ListNode, it can be implemented like this:
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