Edit: The decorator hereby discussed is now available from npm. Install with
npm i @microphi/cache
oryarn add @microphi/cache
I have been chasing this one from few months now.
The following should be pretty familiar to you:
@Injectable()
export class UserService {
constructor(private _client: HttpClient) {}
public findAll(id: number) {
return this._client.get(`https://reqres.in/api/users?page=${id}`);
}
}
For some reason you want to cache the response of this request.
If you look online you may find some tips on how to do it and you may end yourself doing the same thing for all the requests that you want to cache.
It happens though that I was a java developer and remember the old good days when a @Cache
annotation would leverage me from a lot of repeated code.
Well in Typescript we have decorator so there must be a way to do some caching with a simple @Cache
, right?
My gut feeling was: of course!
But after several attempts with no success I gave up.
Until some days ago when I found this article about caching and refreshing observable in angular by Preston Lamb which re-ignited my curiosity.
Starting from his stackbliz example I did some experiments
but again without any luck.
Until I've got an intuition: let's make a race.
@Injectable()
export class UserService {
private cached$: ReplaySubject<any> = new ReplaySubject(1, 2500);
constructor(private _client: HttpClient) {}
public findAll(id): Observable<any> {
const req = this._client.get(`https://reqres.in/api/users?page=${id}`).pipe(
tap((data) => {
this.cached$.next(data);
})
);
return race(this.cached$, req);
}
}
Et voila'. It worked! Just how I like it: simple and neat. So simple that I don't even need to explain it, right?
Now the thing is that if in your service you've got many of those methods that you need to cache then you'll need to repeat a lot of code. REM: decorator!
Let's move everything into a decorator
export interface CacheOptions {
ttl: number;
}
export function Cache(options: CacheOptions) {
return (target: any, propertyKey: string, descriptor) => {
const originalFunction = descriptor.value;
target[`${propertyKey}_cached`] = new ReplaySubject(1, options.ttl);
descriptor.value = function(...args) {
const req = originalFunction.apply(this, args).pipe(
tap((response) => {
this[`${propertyKey}_cached`].next(response);
})
);
return race(this[`${propertyKey}_cached`], req);
};
return descriptor;
};
}
What I do here is to initialize a variable named, for example, findAll_cached
with a replay subject then replace the original function with a new function that will call the original function applying the same logic we saw before.
Then the service will look like the following:
@Injectable()
export class UserService {
constructor(private _client: HttpClient) {}
@Cache({
ttl: 2500
})
public findAll(id): Observable<any> {
return this._client.get(`https://reqres.in/api/users?page=${id}`)
}
}
So beautiful!
Extra points
Now here it comes my friend that says: yo Davide that's cool but if you call that function with a different argument for sure you need to do the http call again. i.e.: different input different output. Right?
Oh right, that's easy:
export function Cache(options: CacheOptions) {
let lastCallArguments: any[] = [];
return (target: any, propertyKey: string, descriptor) => {
const originalFunction = descriptor.value;
target[`${propertyKey}_cached`] = new ReplaySubject(1, options.ttl);
descriptor.value = function(...args) {
let argsNotChanged = true;
for (let i = 0; i < lastCallArguments.length; i++) {
argsNotChanged = argsNotChanged && lastCallArguments[i] == args[i];
}
if (!argsNotChanged) { // args change
this[`${propertyKey}_cached`] = new ReplaySubject(1, options.ttl);
}
lastCallArguments = args;
const req: Observable<any> = originalFunction.apply(this, args).pipe(
tap((response) => {
this[`${propertyKey}_cached`].next(response);
})
);
// despite what the documentation says i can't find that the complete is ever called
return race(this[`${propertyKey}_cached`], req);
};
return descriptor;
};
}
You can play with this code in the following stackbliz and find the complete source code on my github.
Please note that the code on github will probably move to another package in the future.
Caveats
-
If the method that we need to cache makes use of the typescript defaulting mechanism. i.e.:
public findAll(id: number = 1) { ... }
and then it's called like
service.findAll()
it happens that theargs
variable will be[]
an empty array as the defaulting takes place only when we call.apply
so that in the following example no change of arguments it's detected
service.findAll() service.findAll(2)
-
Let's look at the example in home.component of the forementioned stackbliz example
setTimeout(() => { this.$log.d('starting subscriber'); this.userService.findAll(1).subscribe((data) => { this.$log.d('starting subscribed'); this.$log.d(data); this.users = data; }) }, 0); setTimeout(() => { this.$log.d('first subscriber 1 sec later'); this.userService.findAll(1).subscribe((data) => { this.$log.d('first subscribed'); this.$log.d(data); }) }, 1000); setTimeout(() => { this.$log.d('second subscriber 2 sec later'); this.userService.findAll(1).subscribe((data) => { this.$log.d('second subscribed'); this.$log.d(data); }) }, 2000); setTimeout(() => { this.$log.d('third subscriber 3 sec later, ttl expired. shoult hit the endpoint'); this.userService.findAll(1).subscribe((data) => { this.$log.d('third subscribed'); this.$log.d(data); }) }, 3000); setTimeout(() => { this.$log.d('fourth subscriber 4 sec later, argument changed. should hit the endpoint'); this.userService.findAll(2).subscribe((data) => { this.$log.d(' fourth subscribed'); this.$log.d(data); }) }, 4000); setTimeout(() => { this.$log.d('fifth subscriber 5 sec later, argument changed. should hit the endpoint'); this.userService.findAll(1).subscribe((data) => { this.$log.d(' fifth subscribed'); this.$log.d(data); }) }, 5000);
which outputs something like the following on console
[...] third subscriber 3 sec later, ttl expired. shoult hit the endpoint arguments are [1] argsNotChanged true this actually hit the endpoint starting subscribed {page: 1, per_page: 6, total: 12, total_pages: 2…} first subscribed {page: 1, per_page: 6, total: 12, total_pages: 2…} second subscribed {page: 1, per_page: 6, total: 12, total_pages: 2…} third subscribed {page: 1, per_page: 6, total: 12, total_pages: 2…} [...]
As you can see when we subscribe again after the cache is expired all previous subscriptions are notified again.
Thanks for reading so far I hope you enjoyed and remember: if you like this article share it with your friends, if you don't keep it for yourself ;)
(2023/05/03) Edit: Update github repo link
Top comments (13)
Looks fancy but it doesn't work for me :/ with th first block (without caring about the args) only the first call works, then nothing.
But when i update to care about different args, i just call the endpoint each time, even with the same args...
Sorry for this late reply but somehow i missed your comment. The decorator is now available through npm please try to use it from
@microphi/cache
. If it still doesn't work please open an issue here issuesI see that sometimes you use
target[
${propertyKey}_cached]
and othersthis[
${propertyKey}_cached]
. Is that an error?I tried tweaking that and it still fails
For me this doesn't work(@microphi/cache) it fetch only one id ok
If you share your code maybe I can help.
- wrong result in the template:
and for userId=1, 17 I get some result but should be different
- img :
Regards, Tomaž
hm I can't upload image
Maybe a silly question but if you remove the @Cache decorator, do you actually get what you expect?
Is there anyway you can share the project so that i can run it?
If you can please open an issue here with the steps to reproduce the problem.
The second example works perfectly. Btw, can you fix the broken links in the post? I couldn't find a GitHub repo
Thanks for point that up. I fixed it.