Повторная выдача кэшированного (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
Я ожидаю, что произойдут следующие вещи:
- Новый
HTTP
-запрос сделан, это происходит! "Refreshed foos"
напечатано, это происходит!"New cached foos"
печатается, этого не происходит! И, следовательно, мой кэш не проверен и пользовательский интерфейс, который подписан наcachedFoos
используяasync
-труба не обновляется.
Я знаю, что, поскольку шаг 2 работает, я мог бы, вероятно, взломать ручное решение с помощью явного ReplaySubject
и звонит next
вручную вместо этого вместо печати на консоль. Но это кажется хакерским, и я ожидал бы, что есть более "подходящий" способ сделать это.
И это подводит меня к двум тесно связанным вопросам:
- Почему
cachedFoos
подписка не обновляется, когда основнойrequestFoos
срабатывает? - Как я мог правильно реализовать
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");
}
}