shareReplayをキャッシュとして使う実装をレビューで止める
shareReplayをキャッシュとして使う実装をレビューで止める
マスタデータの取得を1回で済ませたい、という要件に対して shareReplay(1) を置く実装はよく見る。2回目以降は通信せず、保持してある値が返る。動作としては期待どおりになる。
それでも shareReplay をキャッシュとして扱うと、後から2種類の問題が出る。購読が残り続ける問題と、値が更新されない問題である。どちらも shareReplay の契約どおりの挙動であり、不具合ではない。
既定の refCount が残すもの
shareReplay の既定は、バッファ数が無制限で refCount が false である。shareReplay(1) と書いてもバッファ数が1になるだけで、refCount は false のままになる。
refCount: false は、購読者が0人になっても上流の購読を解除しないことを意味する。
readonly masters$ = this.http.get<Master[]>('/api/masters').pipe(
shareReplay(1),
@Reviewer既定では `refCount` が false のため、購読者が0人になっても上流の購読が残ります。完了しない上流を共有する場合、購読が解除されないまま残り続けます。);HttpClient のように1回emitして complete する上流であれば、完了とともに購読は終わるため実害は出にくい。問題になるのは、complete しない上流を共有した場合である。WebSocket の受信、interval によるポーリング、Subject を元にしたストリームがこれにあたる。購読者が画面から消えても、上流は動き続ける。
購読者がいなくなった時点で上流も止めたいなら、refCount を明示する。
readonly notifications$ = this.socket.messages$.pipe(
shareReplay({ bufferSize: 1, refCount: true }),
);実際に流して確認すると、refCount: true では購読者が0になった時点で上流の解除処理が走り、次の購読で上流が改めて実行される。refCount: false では解除されず、次の購読には保持していた値がそのまま渡る。
@Reviewer: この共有元はWebSocketなのでcompleteしません。既定の `refCount: false` だと画面を離れても受信が続くため、`refCount: true` を指定するか、購読側で寿命を管理してください。completeした後に残るもの
refCount: true にすれば購読者に連動する、と理解すると次を見落とす。上流が成功して complete した後は、購読者が0になっても保持した値が残る。
理由は、refCount が制御するのは上流への購読であって、ReplaySubject が保持している値ではないためである。上流が complete した時点でその購読はもう存在しないので、解除する対象がない。
実際に確かめると、refCount: true を指定した共有Observableでも、complete 後の再購読では上流が再実行されず、前回の値がそのまま返る。
[上流] 購読 (1回目)
sub1: value-1
[上流] teardown
(購読者は0。上流は complete 済み)
sub2: value-1 ← 上流は再実行されず、保持していた値が返る読み取り専用のマスタデータならこれで困らない。困るのは、更新後に最新を取り直したい場合である。shareReplay には、有効期限、手動での無効化、古い値を返しつつ裏で取り直す仕組みのいずれも無い。
必要であれば、共有Observable自体を作り直すか、Store へ状態として持つか、専用のキャッシュ層を用意することになる。shareReplay にその役割を期待した時点で設計が足りていない。
@Reviewer: 更新後に一覧を取り直したい要件があるようですが、`shareReplay` は成功した値を保持し続けます。無効化する手段が別に必要です。共有Observableを作り直す方針でよいですか。共有されていない shareReplay
3つ目は、そもそも共有が成立していない書き方である。
getMasters(): Observable<Master[]> {
return this.http.get<Master[]>('/api/masters').pipe(
shareReplay({ bufferSize: 1, refCount: true }),
);
@Reviewerメソッドが呼ばれるたびに新しい共有Observableが作られるため、呼び出し間では共有されません。同じインスタンスをフィールドに保持する必要があります。}shareReplay が共有するのは、そのOperatorを適用して作った1つのObservableインスタンスへの購読である。呼び出しのたびに pipe を通して新しいインスタンスを作れば、それぞれが独立した共有単位になる。
実際に2回呼んで購読すると、上流が2回購読される。1回で済ませる目的は達成されない。
private readonly masters$ = this.http.get<Master[]>('/api/masters').pipe(
shareReplay({ bufferSize: 1, refCount: true }),
);
getMasters(): Observable<Master[]> {
return this.masters$;
}このパターンは、レビューで見つけやすい部類に入る。shareReplay が return 文の中にあれば疑ってよい。
@Reviewer: この `shareReplay` はメソッドが呼ばれるたびに作られるので、呼び出し間で共有されていません。フィールドへ保持する意図でしたか。computed との違い
Signal を併用していると、shareReplay と computed() を似たものとして扱いたくなることがある。どちらも「一度計算した結果を持ち回る」という印象があるためだ。
契約は別物である。
| 何をするか | |
|---|---|
shareReplay |
上流への購読を複数の購読者で共有し、過去のemitを後から購読した側へ再生する |
computed() |
依存するSignalから派生値を遅延評価し、依存が変わるまで結果を保持する |
computed() に購読という概念はなく、再生する過去のemitもない。shareReplay の Signal 版ではない。置き換えられる関係にないので、どちらかへ寄せる設計判断も成り立たない。
レビュー観点チェックリスト
- 上流が complete するか。complete しないなら
refCount: trueが指定されているか shareReplay(1)でrefCountまで指定したつもりになっていないかreturn文の中でshareReplayを適用していないか- 更新後に取り直す要件があるのに、無効化の手段が無いまま使っていないか
- キャッシュとして使うなら、有効期限と無効化をどこが持つか決まっているか
computed()の代わりとして持ち出していないか
おわりに
shareReplay は共有と再生のOperatorであり、その範囲では正確に動く。レビューで問題になるのは、キャッシュに期待する機能を勝手に読み込んでしまう場合である。
確認する点は2つに絞れる。上流が complete するかどうかと、その共有Observableがどこに保持されているか。この2つが答えられれば、購読が残る問題も共有されない問題も先に見つかる。