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

Re-issuing a cached (shareReplay) HTTP request?

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

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. 打印出
  3. "Refreshed foos",出现这种情况!
  4. "New cached foos" 已打印,这不会发生! 因此我的缓存未经过验证,订阅 cachedFoos 的 UI使用 async-管道未更新。

我知道,因为第 2 步有效,我可能可以通过使用显式 ReplaySubject 并在其上手动调用 next 而不是打印到控制台来拼凑一个手动解决方案。但这感觉很老套,我希望有更多 "rxjsy-way" 可以做到这一点。

这让我想到了两个密切相关的问题:

  1. 为什么当底层 requestFoos 被触发时 cachedFoos 订阅没有更新?
  2. 我怎样才能正确实现一个 refreshFoos 变体,最好只使用 RxJS,更新 cachedFoos 的所有订阅者?

我最后介绍了一个专用的 class CachedRequest,它允许重新订阅任何 Observable。作为奖励,下面的 class 还可以通知外界当前是否发出请求,但该功能带有 huge 注释,因为 Angular (正确地)扼杀模板表达式中的副作用。

/**
 * 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");
  }
}