shareReplayをキャッシュとして使う実装をレビューで止める

マスタデータの取得を1回で済ませたい、という要件に対して shareReplay(1) を置く実装はよく見る。2回目以降は通信せず、保持してある値が返る。動作としては期待どおりになる。

それでも shareReplay をキャッシュとして扱うと、後から2種類の問題が出る。購読が残り続ける問題と、値が更新されない問題である。どちらも shareReplay の契約どおりの挙動であり、不具合ではない。

既定の refCount が残すもの

shareReplay の既定は、バッファ数が無制限で refCountfalse である。shareReplay(1) と書いてもバッファ数が1になるだけで、refCountfalse のままになる。

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 では解除されず、次の購読には保持していた値がそのまま渡る。

Comment
@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 にその役割を期待した時点で設計が足りていない。

Comment
@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$;
}

このパターンは、レビューで見つけやすい部類に入る。shareReplayreturn 文の中にあれば疑ってよい。

Comment
@Reviewer: この `shareReplay` はメソッドが呼ばれるたびに作られるので、呼び出し間で共有されていません。フィールドへ保持する意図でしたか。

computed との違い

Signal を併用していると、shareReplaycomputed() を似たものとして扱いたくなることがある。どちらも「一度計算した結果を持ち回る」という印象があるためだ。

契約は別物である。

何をするか
shareReplay 上流への購読を複数の購読者で共有し、過去のemitを後から購読した側へ再生する
computed() 依存するSignalから派生値を遅延評価し、依存が変わるまで結果を保持する

computed() に購読という概念はなく、再生する過去のemitもない。shareReplay の Signal 版ではない。置き換えられる関係にないので、どちらかへ寄せる設計判断も成り立たない。

レビュー観点チェックリスト

shareReplayを見たときの確認項目
  • 上流が complete するか。complete しないなら refCount: true が指定されているか
  • shareReplay(1)refCount まで指定したつもりになっていないか
  • return 文の中で shareReplay を適用していないか
  • 更新後に取り直す要件があるのに、無効化の手段が無いまま使っていないか
  • キャッシュとして使うなら、有効期限と無効化をどこが持つか決まっているか
  • computed() の代わりとして持ち出していないか

おわりに

shareReplay は共有と再生のOperatorであり、その範囲では正確に動く。レビューで問題になるのは、キャッシュに期待する機能を勝手に読み込んでしまう場合である。

確認する点は2つに絞れる。上流が complete するかどうかと、その共有Observableがどこに保持されているか。この2つが答えられれば、購読が残る問題も共有されない問題も先に見つかる。