1

Is there any operator in Angular or RxJS, that would replace several http requests into one, if they are called in short period of time?


F.e. when I open my application, all of my 20 components during ngOnInit are making the same http call:

enter image description here

I've created a cache based on Angular HttpInterceptor (f.e. https://blog.logrocket.com/caching-with-httpinterceptor-in-angular/), however because all those calls are performed upon initialization, the cache cannot be built quickly enough.

I could put all components into one parent, that would pass the data to children via Inputs, but I wanted to remain with the architecture, where component can initialize itself (they are reused in other places).

I tried to use RxJS share or share replay, but I'd like to stop sharing after f.e. 1 second. I'd love to put such operator in my service:

getData(): Observable<any> {
    return = this.http.get<any>('endpoint')
      .pipe(
        shareIfCalledWithin( 1000ms ),
      );
JoshThunar
  • 452
  • 1
  • 6
  • 15
  • 1
    The parent component is the best idea for you. You can still make your component reusable by adding an additional condition - If there is no data from a parent - make a server request from the child's ngOnInit. – Dmitry Grinko Mar 17 '21 at 01:34
  • why not use APP_INITIALIZER? https://angular.io/api/core/APP_INITIALIZER. This makes that before start your application, a function is called. See, e.g. this SO: https://stackoverflow.com/questions/49707830/angular-how-to-correctly-implement-app-initializer to know how implement it – Eliseo Mar 17 '21 at 10:33
  • @Eliseo I'm also showing those components later, f.e. inside the dialog, with different id Input parameter. Therefore, I cannot preload the data. – JoshThunar Mar 17 '21 at 12:28
  • my idea was has a "cache" you always cal to a service functions that return an observables- if not exist `data`, make a httpClient.get using tap to store the value in the variable `data`, if exist, return `of(data)` – Eliseo Mar 17 '21 at 14:42

3 Answers3

3

Parent component is to provide share data is a good idea.

Another general solution is maybe delegate the http call to a subject and fire the http call from this subject, subject trigger frequency can be controlled by debounceTime

const getData = new Subject()
getData.next()
    
const getDataStream = getData.pipe(debounceTime(1000),
switchMap(_ => this.http.get......),
share())
    
getDataStream.subscribe(data => you data...)

You can place this logic in a parent component or shared service.

Dmitry Grinko
  • 13,806
  • 14
  • 62
  • 86
Fan Cheung
  • 10,745
  • 3
  • 17
  • 39
1

I tend to think that the more logic you push in a service the better, and this case seems a good candidate for this approach.

More specifically, as @Fan Cheung has already suggested, I would create a service (shared via Dependency Injection), which exposes 2 APIs

  • a method to trigger the execution of the remote service
  • an Observable which emits the result of the service call once it arrives

So the code of the service could look like this

export class MyService {
  // define a private Subject and then export it as an Observable
  // this Subject emits any time a result is received from the remote service
  // you can use a ReplaySubject if you want chaching
  private _dataFromRemoteService$ = new Subject<any>()
  // API clients can subscribe to to receive the data
  public dataFromRemoteService$ = _dataFromRemoteService$.asObservable()

  // private Subject used as trigger for the fetch operation
  private _trigger$ = new Subject<any>();
  // API to be called to trigger the execution of the remote service
  public fetchData() {
    _trigger.next();
  }
  // private method that subscribes to the trigger and uses debounce to control the number of calls to remote service
  private _fetchData() {
     this._trigger.pipe(
        debounceTime(1000),
        switchMap(this.http.get<any>('endpoint')
     )
     // the result received from the remote service is broadcasted to all subscribers of dataFromRemoteService$
     .subscribe(_dataFromRemoteService$)
  }
  
  constructor() {
     // start the subscription
     _fetchData();
  }
}

Now any Component subscribe to dataFromRemoteService$ to receive the data once ready and can call the method fetchData to trigger the execution of the remote service.

export class MyComponent implements OnInit {
  constructor(private service: MyService) {}

  ngOnInit(): void {
     // Subscribe to the stream of results of the execution of the service
     this.service.dataFromRemoteService$.subscribe(
        data => {// do something with the data received from remote service}
     )

     // trigger the execution of the service
     this.service.fetchData();
  }
}

One of the main advantages of this approach is that you can test the service easily in different situations, e.g. with many clients requesting the remote data in a fast sequence.

Picci
  • 16,775
  • 13
  • 70
  • 113
0

Thank you, in the meantime I've came up with an upgrade to my cache service, but since it's seems quite over-engineering, I'll probably accept other answer. Still, I believe the below code might be helpful to someone in the future.

Generally, the first request of with the specific type (urlWithParams) is stored in pending. Until this request is finished, further requests of the same type are stored in waiting with corresponding new, empty subject and observable from this subject is returned. When the pending request finishes, it sends the correct HttpResponse to waiting subjects of this type.

@Injectable()
export class HttpCacheInterceptor implements HttpInterceptor {
  constructor(private cache: CoreHttpCacheService) {}

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if( req.method !== 'GET' && req.method !== 'OPTIONS' ) {
      this.cache.clear();
    }

    if( req.method !== 'GET' || req.headers.get( CC.NO_CACHE ) ) {
      return next.handle(req);
    }

    const cachedResponse: HttpResponse<any> = this.cache.get(req.urlWithParams);

    if( this.cache.pending.has( req.urlWithParams ) ) {
      const sbj = new Subject<HttpEvent<any>>();
      this.cache.waiting.push( {r: req.urlWithParams, s: sbj} );
      return sbj.asObservable();
    } else {
      if( cachedResponse ) {
        return of( cachedResponse.clone() );
      } else {
        if( ! this.cache.pending.has( req.urlWithParams ) ) {
          this.cache.pending.add( req.urlWithParams );
        }
        return next.handle(req).pipe(
          tap(stateEvent => {
            if(stateEvent instanceof HttpResponse) {
              this.cache.set(req.urlWithParams, stateEvent.clone());

              let i = this.cache.waiting.length;
              while( i-- ) {
                if( this.cache.waiting[i].r === req.urlWithParams ) {
                  this.cache.waiting[ i ].s.next( stateEvent.clone() );
                  this.cache.waiting.splice( i, 1 );
                }
              }

              this.cache.pending.delete( req.urlWithParams );
            }
          })
        );
      }
    }
  }
}


@Injectable({
  providedIn: 'root'
})
export class CoreHttpCacheService {
  pending = new Set< string >();
  waiting: {r: string, s: Subject<HttpEvent<any>> }[] = [];

  private cache: Map< string, HttpResponse<any> > = new Map();

  private timers = new Map< string, NodeJS.Timer >();

  get( key: string ) {
    return this.cache.get( key );
  }

  set( key: string, v: any ) {
    this.timers.set( key, setTimeout( () => this.clear( key ), Constants.TIME_100SEC ) );
    return this.cache.set( key, v );
  }

  clear( key?: string ) {
    if( key !== undefined ) {
      this.cache.delete( key );
    } else {
      this.cache.clear();
    }
  }
}

This example will fail on HTTP error, compares requests only by urlWithParams and CoreHttpCacheService fields are public, so be careful if you'd like to use it.

JoshThunar
  • 452
  • 1
  • 6
  • 15