5

I've found a number of approaches to cache reactive observables and, more specifically, the results of http requests. However, I am not fully satisfied with the proposed solutions because of the reasons below:

1. This answer https://stackoverflow.com/a/36417240/1063354 uses a private field to store the result of the first request and reuses it in all subsequent calls.

the code:

private data: Data;    
getData() {
    if(this.data) {
        return Observable.of(this.data);
    } else {
        ...
    }
}

The sad thing is that the power of observables is completely ignored - you do all the stuff manually. In fact I wouldn't look for a proper solution if I was satisfied with assigning the result to a local variable/field. Another important thing which I consider a bad practice is that a service should not have a state - i.e. should have no private fields containing data which are changed from call to call. And it's fairly easy to clear the cache - just set this.data to null and the request will be reexecuted.

2. This answer https://stackoverflow.com/a/36413003/1063354 proposes to use ReplaySubject:

    private dataObs$ = new ReplaySubject(1);

    constructor(private http: Http) { }

    getData(forceRefresh?: boolean) {
        // If the Subject was NOT subscribed before OR if forceRefresh is requested 
        if (!this.dataObs$.observers.length || forceRefresh) {
            this.http.get('http://jsonplaceholder.typicode.com/posts/2').subscribe(
                data => this.dataObs$.next(data),
                error => {
                    this.dataObs$.error(error);
                    // Recreate the Observable as after Error we cannot emit data anymore
                    this.dataObs$ = new ReplaySubject(1);
                }
            );
        }

        return this.dataObs$;
    }

Looks pretty awesome (and again - no problem to clear the cache) but I am not able to map the result of this call, i.e.

service.getData().map(data => anotherService.processData(data))

which happens because the underlying observer has not called its complete method. I'm pretty sure that a lot of reactive methods won't work here as well. To actually get the data I have to subscribe to this observable but I don't want to do it: I want to get the cached data for one of my components via a resolver which should return an Observable (or Promise), not a Subscription:

The route

{
    path: 'some-path',
    component: SomeComponent,
    resolve: {
      defaultData: DefaultDataResolver
    }
}

The Resolver

...
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<Data> {
    return this.service.getData();
}

The component is never activated because its dependency is never resolved.

3. Here https://stackoverflow.com/a/36296015/1063354 I found a proposal to use publishLast().refCount().

the code:

getCustomer() {
    return this.http.get('/someUrl')
        .map(res => res.json()).publishLast().refCount();
}

This satisfies my demands for both caching and resolving BUT I haven't found a clean and neat solution to clear the cache.

Am I missing something? Could anyone think out a better way to cache reactive observables being able to map their results as well as refresh the cached data once it's no longer relevant?

Community
  • 1
  • 1
Ultimacho
  • 113
  • 1
  • 9
  • Possible duplicate of [What is the correct way to share the result of an Angular 2 Http network call in RxJs 5?](https://stackoverflow.com/questions/36271899/what-is-the-correct-way-to-share-the-result-of-an-angular-2-http-network-call-in) – Matjaz Hirsman Nov 14 '17 at 22:50

3 Answers3

2

This simple class caches the result so you can subscribe to .value many times and makes only 1 request. You can also use .reload() to make new request and publish data.

You can use it like:

let res = new RestResource(() => this.http.get('inline.bundleo.js'));

res.status.subscribe((loading)=>{
    console.log('STATUS=',loading);
});

res.value.subscribe((value) => {
  console.log('VALUE=', value);
});

and the source:

export class RestResource {

  static readonly LOADING: string = 'RestResource_Loading';
  static readonly ERROR: string = 'RestResource_Error';
  static readonly IDLE: string = 'RestResource_Idle';

  public value: Observable<any>;
  public status: Observable<string>;
  private loadStatus: Observer<any>;

  private reloader: Observable<any>;
  private reloadTrigger: Observer<any>;

  constructor(requestObservableFn: () => Observable<any>) {
    this.status = Observable.create((o) => {
      this.loadStatus = o;
    });

    this.reloader = Observable.create((o: Observer<any>) => {
      this.reloadTrigger = o;
    });

    this.value = this.reloader.startWith(null).switchMap(() => {
      if (this.loadStatus) {
        this.loadStatus.next(RestResource.LOADING);
      }
      return requestObservableFn()
        .map((res) => {
          if (this.loadStatus) {
            this.loadStatus.next(RestResource.IDLE);
          }
          return res;
        }).catch((err)=>{
          if (this.loadStatus) {
            this.loadStatus.next(RestResource.ERROR);
          }
          return Observable.of(null);
        });
    }).publishReplay(1).refCount();
  }

  reload() {
    this.reloadTrigger.next(null);
  }

}
Matjaz Hirsman
  • 326
  • 3
  • 9
0

With option 3. to allow clearing of the cache you could assign the observable to a private member and return that, eg.

getCustomer() {
    if (!this._customers) {
        this._customers = this.http.get('/someUrl')
        .map(res => res.json()).publishLast().refCount();
     }
     return this._customers
}

clearCustomerCache() {
    this._customers = null;
}
glendaviesnz
  • 1,889
  • 14
  • 12
  • I found this whole approach [here](http://www.syntaxsuccess.com/viewarticle/caching-with-rxjs-observables-in-angular-2.0) and tried setting the observable to null. Unfortunately this didn't work for me - the service continued to return the previous value not making any real http calls. Can you please explain how these actions are supposed to solve the problem (what's the idea)? – Ultimacho Apr 17 '17 at 11:27
  • ``` Property 'publishLast' does not exist on type 'Observable'.``` How can i solve this error ? – vivex Nov 23 '17 at 16:30
  • 1
    @Vivek try - import 'rxjs/add/operator/publishLast'; – glendaviesnz Nov 24 '17 at 01:48
0

My approach to caching would be keeping a state in a reducer/scan fn:

edit 3: Added a piece of code to invalidate the cache by keyboard event.

edit 2: The windowWhen operator is also suitable for the task and allows to express the logic in a pretty concise way:

const Rx = require('rxjs/Rx');
const process = require('process');
const stdin = process.stdin;

// ceremony to have keypress events in node

stdin.setRawMode(true);
stdin.setEncoding('utf8');
stdin.resume();

// split the keypress observable into ctrl-c and c observables.

const keyPressed$ = Rx.Observable.fromEvent(stdin, 'data').share();
const ctrlCPressed$ = keyPressed$.filter(code => code === '\u0003');
const cPressed$ = keyPressed$.filter(code => code === 'c');

ctrlCPressed$.subscribe(() => process.exit());

function asyncOp() {
  return Promise.resolve(Date().toString());
}

const invalidateCache$ = Rx.Observable.interval(5000).merge(cPressed$);
const interval$ = Rx.Observable.interval(1000);

interval$
  .windowWhen(() => invalidateCache$)
  .map(win => win.mergeScan((acc, value) => {
    if (acc === undefined) {
      const promise = asyncOp();
      return Rx.Observable.from(promise);
    }

    return Rx.Observable.of(acc);
  }, undefined))
  .mergeAll()
  .subscribe(console.log);

It would perform the async option only every 5s and cache the result for other omissions on the observable.

Sun Apr 16 2017 11:24:53 GMT+0200 (CEST)
Sun Apr 16 2017 11:24:53 GMT+0200 (CEST)
Sun Apr 16 2017 11:24:53 GMT+0200 (CEST)
Sun Apr 16 2017 11:24:53 GMT+0200 (CEST)
Sun Apr 16 2017 11:24:57 GMT+0200 (CEST)
Sun Apr 16 2017 11:24:57 GMT+0200 (CEST)
mkulke
  • 496
  • 3
  • 5
  • Your answer is very thorough, thanks! One thing however: if I was going to clear the cache based on a timeout (as far as I could understand) I would rather use `publishReplay(1, 1000)` instead of `publishLast()` which would invalidate the cache every 1 second. Is this what you intended to show? If yes then it's not really what I wanted - the cache should be cleared explicitly by calling a method. Can you rewrite your example so that the cache will be cleared on demand (with no timeouts logic)? – Ultimacho Apr 17 '17 at 11:24
  • @Ultimacho ah, i see. the interval in `clearCacheInterval$` is meant to be an example, it could be merged/replaced with any other Observable, for example mouse clicks. I changed the example to also clear the cache when the user pressed 'c'. – mkulke Apr 17 '17 at 16:49