重新发出缓存的 (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
时,我预计会发生以下情况:
- 一个新的
HTTP
-请求被提出,这发生了!
打印出"Refreshed foos"
,出现这种情况!
"New cached foos"
已打印,这不会发生! 因此我的缓存未经过验证,订阅 cachedFoos
的 UI使用 async
-管道未更新。
我知道,因为第 2 步有效,我可能可以通过使用显式 ReplaySubject
并在其上手动调用 next
而不是打印到控制台来拼凑一个手动解决方案。但这感觉很老套,我希望有更多 "rxjsy-way" 可以做到这一点。
这让我想到了两个密切相关的问题:
- 为什么当底层
requestFoos
被触发时 cachedFoos
订阅没有更新?
- 我怎样才能正确实现一个
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");
}
}
我想将 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
时,我预计会发生以下情况:
- 一个新的
HTTP
-请求被提出,这发生了!
打印出 "Refreshed foos"
,出现这种情况!"New cached foos"
已打印,这不会发生! 因此我的缓存未经过验证,订阅cachedFoos
的 UI使用async
-管道未更新。
我知道,因为第 2 步有效,我可能可以通过使用显式 ReplaySubject
并在其上手动调用 next
而不是打印到控制台来拼凑一个手动解决方案。但这感觉很老套,我希望有更多 "rxjsy-way" 可以做到这一点。
这让我想到了两个密切相关的问题:
- 为什么当底层
requestFoos
被触发时cachedFoos
订阅没有更新? - 我怎样才能正确实现一个
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");
}
}