我想将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
的UI未更新。我知道,因为第2步有效,所以我可能可以通过使用显式ReplaySubject
并在其上手动调用next
而不是打印到控制台来手工整理解决方案。但这感觉很hacky,我希望可以有更多的“ rxjsy-way”来实现这一目标。
这给我带来了两个密切相关的问题:
cachedFoos
时requestFoos
订阅未更新?refreshFoos
变量,最好仅使用RxJS来更新cachedFoos
的所有订阅者?答案 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");
}
}