重新发出缓存的(shareReplay)HTTP请求?

时间:2018-07-16 14:18:24

标签: angular rxjs angular-httpclient

我想将HTTP请求的结果缓存在类提供的Observable中。另外,我必须能够显式地使缓存的数据无效。由于subscribe()创建的Observable上对HttpClient的每次调用都会触发一个新请求,因此重新订阅似乎是我的最佳选择。因此,我得到了以下服务:

import { Injectable } from '@angular/core'
import { HttpClient } from '@angular/common/http';

import { Observable } from 'rxjs';
import { shareReplay, first } from 'rxjs/operators';

@Injectable()
export class ServerDataService {
  public constructor(
    private http: HttpClient
  ) { }

  // The request to retrieve all foos from the server
  // Re-issued for each call to `subscribe()`
  private readonly requestFoos = this.http.get<any[]>("/api/foo")

  // Cached instances, may be subscribed to externally
  readonly cachedFoos = this.requestFoos.pipe(shareReplay(1));

  // Used for illustrating purposes, even though technically
  // ngOnInit is not automatically called on Services. Just
  // pretend this is actually called at least once ;)
  ngOnInit() {
    this.cachedFoos.subscribe(r => console.log("New cached foos"));
  }

  // Re-issues the HTTP request and therefore triggers a new
  // item for `cachedFoos`
  refreshFoos() {
    this.requestFoos
      .pipe(first())
      .subscribe(r => {
        console.log("Refreshed foos");
      });
  }
}

致电refreshFoos时,我希望发生以下事情:

  1. 发出新的HTTP-请求,这种情况发生了!
  2. "Refreshed foos"被打印,发生这种情况!
  3. "New cached foos"被打印,这不会发生!,因此我的缓存未通过验证,并且使用cachedFoos管道订阅了async的UI未更新。

我知道,因为第2步有效,所以我可能可以通过使用显式ReplaySubject并在其上手动调用next而不是打印到控制台来手工整理解决方案。但这感觉很hacky,我希望可以有更多的“ rxjsy-way”来实现这一目标。

这给我带来了两个密切相关的问题:

  1. 为什么触发底层cachedFoosrequestFoos订阅未更新?
  2. 我该如何正确地实现一个refreshFoos变量,最好仅使用RxJS来更新cachedFoos的所有订阅者?

1 个答案:

答案 0 :(得分:0)

我最终引入了一个专用类CachedRequest,该类可以重新订阅任何Observable。另外,下面的类还可以通知外界当前是否发出了请求,但是该功能带有巨大注释,因为Angular(正确地)使Template表达式中的副作用阻塞了。

/**
 * Caches the initial result of the given Observable (which is meant to be an Angular
 * HTTP request) and provides an option to explicitly refresh the value by re-subscribing
 * to the inital Observable.
 */
class CachedRequest<T> {
  // Every new value triggers another request. The exact value
  // is not of interest, so a single valued type seems appropriate.
  private _trigger = new BehaviorSubject<"trigger">("trigger");

  // Counts the number of requests that are currently in progress.
  // This counter must be initialized with 1, even though there is technically
  // no request in progress unless `value` has been accessed at least
  // once. Take a comfortable seat for a lengthy explanation:
  //
  // Subscribing to `value` has a side-effect: It increments the
  // `_inProgress`-counter. And Angular (for good reasons) *really*
  // dislikes side-effects from operations that should be considered
  // "reading"-operations. It therefore evaluates every template expression
  // twice (when in debug mode) which leads to the following observations
  // if both `inProgress` and `value` are used in the same template:
  //
  // 1) Subscription: No cached value, request count was 0 but is incremented
  // 2) Subscription: WAAAAAH, the value of `inProgress` has changed! ABORT!!11
  //
  // And then Angular aborts with a nice `ExpressionChangedAfterItHasBeenCheckedError`.
  // This is a race condition par excellence, in theory the request could also
  // be finished between checks #1 and #2 which would lead to the same error. But
  // in practice the server will not respond that fast. And I was to lazy to check
  // whether the Angular devs might have taken HTTP-requests into account and simply
  // don't allow any update to them when rendering in debug mode. If they were so
  // smart they have at least made this error condition impossible *for HTTP requests*.
  //
  // So we are between a rock and a hard place. From the top of my head, there seem to
  // be 2 possible workarounds that can work with a `_inProgress`-counter that is
  // initialized with 1.
  //
  // 1) Do all increment-operations in the in `refresh`-method.
  //    This works because `refresh` is never implicitly triggered. This leads to
  //    incorrect results for `inProgress` if the `value` is never actually
  //    triggered: An in progress request is assumed even if no request was fired.
  // 2) Introduce some member variable that introduces special behavior when
  //    before the first subscription is made: Report progress only if some
  //    initial subscription took place and do **not** increment the counter
  //    the very first time.
  //
  // For the moment, I went with option 1.
  private _inProgress = new BehaviorSubject<number>(1);

  constructor(
    private _httpRequest: Observable<T>
  ) { }

  /**
   * Retrieve the current value. This triggers a request if no current value
   * exists and there is no other request in progress.
   */
  readonly value: Observable<T> = this._trigger.pipe(
    //tap(_ => this._inProgress.next(this._inProgress.value + 1)),
    switchMap(_ => this._httpRequest),
    tap(_ => this._inProgress.next(this._inProgress.value - 1)),
    shareReplay(1)
  );

  /**
   * Reports whether there is currently a request in progress.
   */
  readonly inProgress = this._inProgress.pipe(
    map(count => count > 0)
  );

  /**
   * Unconditionally triggers a new request.
   */
  refresh() {
    this._inProgress.next(this._inProgress.value + 1);
    this._trigger.next("trigger");
  }
}