設計時に考慮したいFirestoreオフライン対応の注意事項

公開:2021/01/06
更新:2021/02/24
6 min読了の目安(約5800字TECH技術記事

はじめに

当初は、オンライン前提でFirebase、Firestoreを使ったWebアプリケーションを作成していました。やっぱりアプリケーションの一部はオフライン対応していた方が便利かなと思い、オフライン対応の修正を追加しようとしましたが、結局断念しました。

2021年2月24日追記

不具合解消のため、やっぱりオフライン対応を追加しました。

https://note.com/sushiwork/n/n548a2ce54b8e
内容は記事の最後に追記しています。

オフライン対応を入れるには、設計段階でオフライン対応を考慮するべき

今回の場合はオフライン対応は必須機能じゃないので、後で追加しようとして断念するという結果になりましたが、オフライン対応を考えている場合は、設計段階で考慮しておかないと、後で大きな手戻りが発生する可能性があります。

よって、オフライン対応を入れたいなら、最初からオフライン機能をオンにして開発した方がいいと思います。

「実践Firestore」を読もう

実践Firestoreの「第3章 オフラインモード」に、オフライン対応を追加するときに考慮すべき注意点が記載されています。

3.1 オフラインモードの有効化
3.2 オフライン時の書き込みオペレーション
3.3 オフライン・データの読み取り
3.4 リアルタイム・リスナー
3.5 キャッシュ優先読み取り

この本は、Firestoreを使って本格的に開発をするなら、必読の1冊です。特にFirestoreのセキュリティールールをどうやって書いたら分からず悩んでいる人には、特におすすめです。この本のおかげで、セキュリティールールを自信を持って書けるようになりました。著者様、ありがとうございます。

実践Firestore (技術の泉シリーズ NextPublishing)

serverTimestampの罠

基本内容は「実践Firestore」に任せて、本に書いてなかった(と思われる)、僕がハマった箇所を紹介します。と言っても、よくよく考えれば分かる当たり前のことです。

Firestoreでドキュメントを更新する際に、firebase.firestore.FieldValue.serverTimestamp() を使うと、ローカルタイムではなく、サーバの時間でドキュメントのカラムを更新してくれます。

Firestoreのオフライン機能をオンにした場合、serverTimestampの値は、リクエストした日時(オフライン状態の時にドキュメントの更新が実行された時間)ではなく、オフライン状態からオンライン状態に復帰し、実際に登録がなされた日時が登録されます。

作成しているアプリケーションでは、リクエストした日時を登録したいので、オフライン対応をするとなると、serverTimestampが使えません。それなら仕方ないので、serverTimestampを使うのをやめて、ローカルタイムを生成して登録することを検討しました。

でも、そうすると、Firestoreのセキュリティールールがうまく書けません。リクエスト日時を正確に(もしくは、これでOKと思えるレベルで)バリデーションする方法が僕が知る限りではありませんでした。

serverTimestampを使った場合のセキュリティールールは、こう書ける
request.resource.data.hogeAt == request.time

作成しているアプリケーションでは、その日時の正確性が重要なので、不正確な日時が登録されると不都合があります。そこでオフライン対応は断念することにしました。

あと、別の知見としては、オフライン状態の時に、serverTimestampを追加して更新したドキュメントをgetで取得すると、serverTimestampを入れたカラムの値はnullとなります。実際にFirestoreに登録されるまでは、serverTimestampのカラムはnullということです。

Cloud Functionsはなるべく使わない

オフライン対応をするなら、Firebase Cloud Functionsの利用は最低限にしておく必要があります。https callable functionはオンライン状態の時にしか使えません。また、Trigger functionも、ドキュメントが実際に保存された後に実行されるのでオフライン状態の時には起動しません。

開発環境でオフライン状態の再現

開発環境では、オフライン状態、オンライン状態を簡単に切り替えられるようにしておくと、テストしやすいです。テスト用に作成したVueコンポーネントを掲載しておきます。

「v-btn」と「v-btn-toggle」は、vuetifyのコンポーネントです。vuetifyを利用していない方は適宜書き換えてください。このコンポーネントを、App.vueなどに登録して使用します。

画面

Vueコンポーネントのコード

NetworkToggleButton.vue
<template>
  <div v-if="showable">
    <v-btn-toggle v-model="networkStatus" tile color="primary" group>
      <v-btn value="enable" @click="enableNetwork">
        enaable
      </v-btn>
      <v-btn value="disable" @click="disableNetwork">
        disable
      </v-btn>
    </v-btn-toggle>
  </div>
</template>

<script>
// firestoreは、firebase.firestore()です。
// import文は、各自の環境に合わせて書き換えてください。
import { firestore } from "@/config/firebase";

export default {
  data: function() {
    return {
      showable: process.env.NODE_ENV === "development",
      networkStatus: "enable",
    };
  },
  methods: {
    enableNetwork: function() {
      firestore.enableNetwork().then(function() {
        // eslint-disable-next-line no-console
        console.log("Network enaabled");
      });
    },
    disableNetwork: function() {
      firestore.disableNetwork().then(function() {
        // eslint-disable-next-line no-console
        console.log("Network disabled");
      });
    },
  },
};
</script>

公式ドキュメント: ネットワーク アクセスを無効化および有効化する

終わりに

オフライン対応を追加できなかったのは残念でしたが、この記事がお役に立てば幸いです。

作成中のアプリケーションももうすぐリリースできそうなところまで来てるので、良かったらTwitterのフォローもお願いいたします!

2021年2月24日追記 やっぱりオフライン対応を追加しました

追加した内容をメモしておきます。

Firestoreのオフライン対応設定

この順序通りに行う必要がある。
キャッシュの設定は、自分のアプリでは10MBくらいあれば十分なので、10MBを設定している。

// この設定をuseEmulatorの後に行うと、useEmulatorの設定が打ち消されてしまう
firestore.settings({
  cacheSizeBytes: 10485760, // 10MB
});

if (process.env.NODE_ENV === "development") {
  firestore.useEmulator("localhost", 5050);
}

// firestore.useEmulatorの後に行わないと下記のエラーが出る
// Uncaught FirebaseError: Firestore has already been started and its settings can no longer be changed.
// You can only modify settings before calling any other methods on a Firestore object.
firestore
  .enablePersistence({
    synchronizeTabs: true,
  })
  .catch(function(err) {
    if (err.code == "unimplemented") {
      // The current browser does not support all of the
      // features required to enable persistence
      // eslint-disable-next-line no-console
      console.log("Offline support error.");
    }
  });

公式ドキュメント

https://firebase.google.com/docs/firestore/manage-data/enable-offline?hl=ja

update処理を成功する前提に書き換える

firestoreのオフライン機能を使うと、オフライン状態の時の書き込み処理はキューに入り、オンラインに復帰した時に書き込み処理を実行してくれる。書き込み処理は、セキュリティールールに引っかかるなどがない限り成功するので、書き込んだ後の処理は、実際に書き込みが終了した後ではなく、終了したものとして、処理を書いてしまう。

具体的には下記のようにする。

  firestore
    .doc("aaa")
    .update(data)
    .then(() => {
      // 以前はここにupdate後のVuexのmutation処理を書いていた
    });
    
  // 成功する前提で、ここにVuexのmutation処理を書く
  commit(types.mutations.HOGE);

serverTimestampを使わず、ローカルタイムを使う

ここはトレードオフが生じるが、バグ修正とオフライン対応を優先させて、リクエスト日時の保存にserverTimestampを使うことやめ、ローカルタイムを使うことにした。

セキュリティールールをどう書いたかの詳細は省略するが、同じドキュメント内にある日時のデータを使って、アプリの要件に合うように書いた。ユーザのリクエスト日時は、事前に分からないので、正確な日時のバリデーションはできないが、アプリの要件に合わせて、許容できるレベルで書いた。

日時のセキュリティールールの書き方の例を挙げておく。

request.resource.data.startAt <= resource.data.startAt + duration.value(1, 'h')

と書けば、更新後のstartAtは、更新前のstartAtから1時間以内であることというルールを書ける。

今度こそ終わりに

Firestoreのオフライン対応は、対応する前提で設計した方が良さそうだなと、今回の経験を通して感じました。

参考URL

https://developers-jp.googleblog.com/2019/10/why-is-my-cloud-firestore-query-slow.html

理由その 2: オフライン キャッシュが大きすぎる

https://medium.com/firebase-developers/the-secrets-of-firestore-fieldvalue-servertimestamp-revealed-29dd7a38a82b

https://github.com/nuxt-community/firebase-module/issues/451#issuecomment-781636812