Повторная выдача кэшированного (shareReplay) HTTP-запроса?

Я хотел бы кэшировать результат 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-труба не обновляется.

Я знаю, что, поскольку шаг 2 работает, я мог бы, вероятно, взломать ручное решение с помощью явного ReplaySubject и звонит next вручную вместо этого вместо печати на консоль. Но это кажется хакерским, и я ожидал бы, что есть более "подходящий" способ сделать это.

И это подводит меня к двум тесно связанным вопросам:

  1. Почему cachedFoos подписка не обновляется, когда основной requestFoos срабатывает?
  2. Как я мог правильно реализовать refreshFoos-вариант, что, предпочтительно с использованием только RxJS, будет обновлять всех подписчиков cachedFoos?

1 ответ

Решение

Я закончил тем, что ввел специальный класс 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");
  }
}
Другие вопросы по тегам