Firebase Cloud Firestoreとキャッシュ
Firebase Cloud FirestoreはNoSQL方式のクラウドDBです。
使い時・使いどころはよぉく吟味する必要があるものの、昨今のWeb・モバイルの抱えるプロブレムにうまくターゲットしています。
インフラとしての出来の良さもさることながら、無料で提供されているSDKもまあまあ使いやすいです。
特に、DBから取得したデータを勝手にキャッシュしておいてくれてオフライン時でもシームレスにデータ参照できる仕組みはかなり頑張っているなという印象を受けます。
Firestoreのキャッシュ戦略はいまいち
とはいえ全く不満がないわけではありません。
その1つがキャッシュの制御です。
fun getCitySf() {
val docRef = db.collection("cities").document("SF")
docRef.get()... // 省略
上記のコードでは、サーバーに問い合わせるかキャッシュを使うかはその時のネットワークの状況によって決定されます。
オンラインならサーバー問い合わせ、オフラインならキャッシュです。
しかし、オンラインであっても回線が細くて応答が遅い場合にはキャッシュを使ってほしいこともあるでしょうし、オフラインの場合でもサーバー問い合わせをした上で失敗を返して欲しいこともあるでしょう。
リファレンスを読むと、 get
関数にソースオプションを渡せばサーバーに問い合わせるかキャッシュを使うが選べることがサラリと書いてありますが、具体的にどのような実装にすればよいかまでは書いていません。
前置き
というわけで、ユースケースごとに実装を紹介していきます……とその前に、前置きです。
Firestoreは設計思想としてユーザー側に細かなキャッシュの制御を許していないフシがあります 。
したがってキャッシュの制御にこだわろうとすればするほど泥沼にハマっていきます。
この点についてはこの記事の最後で改めて言及しております。
なので、実装のくだりだけ読んで即導入するのはお控えください。
キャッシュとうまく付き合う実装
というわけで今度こそユースケースごとに実装を紹介していきます。
環境/準備
改めまして今回の環境です。
本筋とは関係ありませんが、 addOnCompleteListener
を使った書き方は好きじゃないのでawait拡張関数を導入しています。
...
dependencies {
implementation 'com.google.firebase:firebase-firestore-ktx:21.2.1'
// Task#await拡張を使っています: https://github.com/Kotlin/kotlinx.coroutines/tree/master/integration/kotlinx-coroutines-play-services
def coroutines_version = '1.3.0'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutines_version"
}
パターン1:呼び出し側でキャッシュ使用かサーバー問い合わせかを指定する
はじめに紹介するのは誰でも思いつくやり方。
suspend fun getCitySf(usesCache: Boolean): City {
val docRef = db.collection("cities").document("SF")
val source = if (usesCache) Source.CACHE else Source.SERVER
val city = docRef.get(source).await().let { ds: DocumentSnapshot ->
// DocumentSnapshotからCity型への変換
...
}
return city
}
引数にキャッシュを使うかどうかのフラグを指定させるだけです。
キャッシュの使用可否を上位層で決定してもよい場合はこれで十分でしょう。
パターン2:キャッシュがあればキャッシュを使いなければサーバーに問い合わせる
しかし、「呼び出し時にいちいち usesCache
を与えるのはいやだ! getCitySf
でいい感じに判断してくれ!!!」という場合も多々あるでしょう。
ここでは いい感じ
=キャッシュがあればキャッシュを使い、なければサーバーに問い合わせ と解釈します。
suspend fun getCitySf(): City {
return try {
getCityImpl(usesCache = true)
} catch(t: Throwable) {
getCityImpl(usesCache = false)
}
}
private suspend fun getCitySfImpl(usesCache: Boolean): City {
val docRef = db.collection("cities").document("SF")
val source = if (usesCache) Source.CACHE else Source.SERVER
val city = docRef.get(source).await().let { ds: DocumentSnapshot ->
// DocumentSnapshotからCity型への変換
...
}
return city
}
getCitySfImpl
はパターン1の getCitySf
をprivateにしてリネームしただけです。これを今回作る getCitySf
の部品とします。
getCitySf
では、最初にキャッシュからのデータ取得を試みます( getCityImpl(usesCache = true)
)。
キャッシュが存在する場合は getCityImpl(usesCache = true)
の結果がそのまま getCitySf
の結果になります。
キャッシュが存在しない場合は getCityImpl(usesCache = true)
は例外を送出するため、すかさずcatchして今度はサーバー問い合わせで取得します( getCityImpl(usesCache = false)
)。
サーバーへの問い合わせに成功すれば getCityImpl(usesCache = false)
が getCitySf
の結果になります。
こうすれば getCitySf
は引数を取ることがなく、上位層(呼び出し側)でキャッシュの使用可否を指定することがなくなります。
「いや、引数は“毎回”指定したくないだけでたまには指定したい場合もあるんだ……」という場合は省略可能引数を使って以下のようにしてみてはどうでしょうか。
enum GetType {
CACHE,
SERVER,
CACHE_THEN_SERVER
}
suspend fun getCitySf(getType: GetType = GetType.CACHE_THEN_SERVER): City {
return when(getType) {
GetType.CACHE -> {
getCitySfImpl(usesCache = true)
}
GetType.SERVER -> {
getCitySfImpl(usesCache = false)
}
GetType.CACHE_THEN_SERVER -> {
try {
getCityImpl(usesCache = true)
} catch(t: Throwable) {
getCityImpl(usesCache = false)
}
}
}
}
private suspend fun getCitySfImpl(usesCache: Boolean): City {
// 以下省略
...
}
パターン3:可能な限りサーバーに問い合わせてほしいが応答に時間がかかるようならキャッシュを使って欲しい
パターン2で言及した いい感じ
には他にも種類があります。
たとえば、可能な限りサーバーに問い合わせてほしいが応答に時間がかかるようならキャッシュを使う
ことも1つでしょう。
suspend fun getCitySf(): City {
return try {
val c = getCityImpl(usesCache = true)
try {
withTimeout(1000) {
getCityImpl(usesCache = false)
}
} catch(t: Throwable) {
c
}
} catch(t: Throwable) {
// キャッシュがなかった場合の処理。
// キャッシュがない場合はどれだけ時間をかけてもいいからサーバーに問い合わせるようにする。
getCityImpl(usesCache = false)
}
}
private suspend fun getCitySfImpl(usesCache: Boolean): City {
// 以下省略
...
}
パターン2と似たようなコードですが、キャッシュの取得に成功した後にサーバーへの問い合わせを行っています。その際、 withTimeout
内で実行しています。
withTimeout
はタイムアウト用のコルーチン関数で、指定した時間内に処理が終わらなかった場合例外が送出されます。
今回は1000ms以内にサーバーからの応答があった場合 1 、サーバーの問い合わせ結果を使っています。1000msを超えてもサーバーからの応答がなかった場合、catchでキャッシュの結果を返すようにしています。
パターン2同様、呼び出し側から引数を与えて制御したいこともある場合はこうです。
enum GetType {
CACHE,
SERVER,
SERVER_THEN_CACHE
}
suspend fun getCitySf(getType: GetType = GetType.SERVER_THEN_CACHE): City {
return when(getType) {
GetType.CACHE -> {
getCitySfImpl(usesCache = true)
}
GetType.SERVER -> {
getCitySfImpl(usesCache = false)
}
GetType.SERVER_THEN_CACHE -> {
try {
val c = getCityImpl(usesCache = true)
try {
withTimeout(1000) {
getCityImpl(usesCache = false)
}
} catch {
c
}
} catch(t: Throwable) {
// キャッシュがなかった場合の処理。
// キャッシュがない場合はどれだけ時間をかけてもいいからサーバーに問い合わせるようにする。
getCityImpl(usesCache = false)
}
}
}
}
private suspend fun getCitySfImpl(usesCache: Boolean): City {
// 以下省略
...
}
さらなるパターンと脱Firestoreの検討:Firestoreとうまく付き合おう
キャッシュ制御のパターンとして、他にも
- 鮮度の新しいデータはキャッシュを優先して鮮度の古いデータはサーバー問い合わせする
- ↑+パターン3を組み合わせる
などが考えられます。これらは鮮度(最後にデータをサーバーから取得した時刻)を管理しなければならないので少々厄介です。
また、アプリ再起動後も鮮度情報を維持しなければならない場合、鮮度の永続化処理も必要になります。つまりキャッシュの制御のためのキャッシュ(鮮度)制御です。
ここまで来ると、 Firestoreとのお付き合いの方法も再検討するべきです 。
Firestoreに限ったことではありませんが、クラウドサービスは最大公約数的にソリューションを提供するものです。瑣末事や関心事の中心にない問題はうまく解決することはできません。
Firestoreの場合ですと、簡便に扱えることやオンライン/オフラインをシームレスに扱えることに重点を置いているので、キャッシュを細かく制御したいことはそもそも解決すべき問題として捉えていない可能性が高いです。
あなたが作っているものが少しずつ育てていくプロダクトの場合、プロダクトを取り巻く問題は日々変わっていきます。
最初はマッチしていたプロダクトとクラウドサービスが段々ズレていくこともままあります。
そのような状況においてはFirestoreから脱却し、別のDBサービスや自前で用意したDBシステムに移行することも検討した方がいいでしょう。
そして移行までの間に今回紹介したパターンを“つなぎ”のテクニックとして導入するのはありだと思います。
Firestoreのキャッシュとうまく付き合うためにはFirestoreそのものとうまく付き合っていきましょう。
-
正確には「1000ms以内にサーバーの問い合わせ結果がありCity型に変換できた場合」です。DocumentSnapshot型からCity型への変換処理に時間がかかる場合、DocumentSnapshotの取得処理とその後の変換処理は分けてください ↩