Firestoreのカラムのインクリメントの歴史


Firebase Advent Calendar 2019 8日目の記事になります

前提として自分が知っている範囲なのでこれより昔の話しもあるかもしれません。

現在のインクリメント

いまは2種類のインクリメントを使い分けすることができます。

1個目

fieldValue.increment()

こんな感じで使います。

const firestore = firebase.firestore()

firestore.collection('nanika').doc('nanika').update({
 likeCoune: firestore.fieldValue.increment(),
});

デクリメントもできます。

firestore.collection('nanika').doc('nanika').update({
 likeCoune: firestore.fieldValue.increment(-1),
});

2個目

Firebase extensionsのDistributed Counter を使う

こちらは前述のincrement()より性能を求めた場合の選択肢になります。
Firestoreのドキュメントは1秒に1回の更新という制約があります。
その制約上、1documentの何らかの数値を短時間の間に頻繁に更新するような場合にincrement()は向いていません。
( increment()でも充分いけてる。という話しも聞くのでまずは実際の運用に合うのか試すのをおすすめします。)

こちらを導入するのは少し面倒です。
上記のページからインストールリンクを押下し、自分のプロジェクトのコンソールへ飛び設定する
or
上記ページ下部にある、CLIコマンドでインストール

あとは、表示される組み込み手順を実行してください。(とはなりません

例としてこんなFirestore rules が書かれているのでこれに沿って、Distributed Counterが裏で使用するコレクション( counter_shards )に対して、ルールを設定します。
visitsの部分を自分の好きなカラムに変える感じですね。

match /databases/{database}/documents/pages/{page} {
  // Allow to increment only the 'visits' field and only by 1.
  match /_counter_shards_/{shardId} {
    allow get;
    allow write: if request.resource.data.keys() == ["visits"]
                   && (resource == null || request.resource.data.visits ==
                   resource.data.visits + 1);
  }
}

次に Google Cloud Schedulerを有効にする。

gcloud services enable cloudscheduler.googleapis.com
gcloud scheduler jobs create http firestore-sharded-counter-controller --schedule="* * * * *" --uri=https://asia-northeast1-xxxxx.cloudfunctions.net/ext-firestore-counter-controller --project=xxxxx

こんなコマンドを実行します。
Distributed Counterは、Cloud Schedulerで定期実行するfunctionの内部で元のドキュメント(インクリメントしたいカラムがあるドキュメント)を更新します。
この仕組みで1秒に1回の更新という制約の上でインクリメントを実現されています。

最後(これが面倒だった。自分がよくわかってないだけかも)
クライアントで設定した Distributed Counter を使えるようにします。
Distributed Counterは拡張機能なのでFisrebase、FirestoreのSDKには入っていません。

おもしろい導入の仕方が説明されています。

https://github.com/firebase/extensions/blob/master/firestore-counter/clients/web/dist/sharded-counter.js
このコードをコピーしてプロジェクトに組み込んでくれ。と言われます。(まじで?)
まぁ言われた通りに素直にコピーして適当にプロジェクトのどこかに置きましょう。

<html>
<head>
  <script src="https://www.gstatic.com/firebasejs/[version]/firebase-app.js"></script>
  <script src="https://www.gstatic.com/firebasejs/[version]/firebase-firestore.js"></script>
  <script src="sharded-counter.js"></script>
</head>
<body>
  <script>
    // Initialize Firebase.
    var firebaseConfig = { projectId: "alu-dev" };
    firebase.initializeApp(firebaseConfig);
    var db = firebase.firestore();

    // Initialize the sharded counter.
    var visits = new sharded.Counter(db.doc("pages/hello-world"), "visits");

    // Increment the field "visits" of the document "pages/hello-world".
    visits.incrementBy(1);

    // Listen to locally consistent values.
    visits.onSnapshot((snap) => {
      console.log("Locally consistent view of visits: " + snap.data());
    });

    // Alternatively, if you don't mind counter delays, you can listen to the document directly.
    db.doc("pages/hello-world").onSnapshot((snap) => {
      console.log("Eventually consistent view of visits: " + snap.get("visits"));
    });
  </script>
</body>
</html>

こんなコードのサンプルがあるので参考に実装します。非常にシンプルなコードです。
現実的にこのように実装することはほぼないと思われますので、コードを変えます。

import * as sharded from '/path/to/sharded-counter.js'

const visits = new sharded.Counter(db.doc("pages/hello-world"), "visits");
visits.incrementBy(1);

こんな風に書き換えたところで動くことはありません。
Distributed Counterの内部でスコープないにFirebaseが存在していることが前提になっていて、Firebaseがないぞ。とエラーが出ます。

仕方がないので、sharded-counter.jsの最初に以下のようなコードを追加しました。
(手元にコードが残ってないので記憶だけで書いているんですが、Firestoreかもしれません)

import Firebse from 'firebase';

これで動くようになったはずです。
かんたんにインストールできる風の拡張機能なのに面倒でびっくりしました。
なお、このSDK(手動コピー)はWeb用しか用意されていません。
iOS, Androidでは現時点では導入ができません。(SDKのコードは公開されているので同じ実装すればいけるじゃんって同僚が言ってました)

別の導入方法としてコピー先を変えて、こちらをコピーしてプロジェクトに入れる。という方法をしている人もいました。(同僚で
https://github.com/firebase/extensions/blob/master/firestore-counter/clients/web/src/index.ts
こっちのほうがいいかもですね。

昔のインクリメント

現在の便利な実装がわかりました。
では昔(1年前とか)はどんな感じだったんでしょうか。
昔も2種類あったようです。

その1

実際に使っていたコードを貼ります。(名称などは書き換えてます)

export const incrementHogeCount = async (docId: string): Promise<any> => {
  const ref = db.collection('Hoge').doc(docId);
  const doc = await ref.get();
  if (!doc.exists) {
    await ref.set({ count: 0 });
  }

  try {
    return db.runTransaction(async (t: Transaction): Promise<any> => {
      const doc = await t.get(ref);
      const data = doc.data()!;
      const count = data.count + 1;
      return t.update(ref, { count });
    });
  } catch (e) {
    return incrementHogeCount(docId);
  }
};

このコードではインクリメントしたいドキュメントのIDを渡されて、
このドキュメントが存在しなければ初期化。
存在する場合は、
トランザクションを貼り現在の値に +1 したデータで更新。
更新時にエラー(競合)が発生した場合はリトライ。
です。

10回インクリメントをほぼ同時に実行した場合に、1秒に1回更新の制約もあり
だいたい10秒後に10回のインクリメントが終わります。
(あまりにも来た場合はたぶんダメ)

最初に書いた fieldValue.increment() は内部的にはこれをもっといい感じにやってくれているんじゃないだろうか。
いい時代になりましたよ。

その2

https://firebase.google.com/docs/firestore/solutions/counters?hl=ja
これです。

そうです。2個目に書いたDistributed Counterはこれを実装してくれています。
ここまでの更新速度を求めたことがなかったので、これは実装したことはありませんでした。
拡張機能なのに入れるのが面倒と言ったんですが、自分で実装するより遥かにかんたんです。
ありがとうございます!

まとめ

Firebaseはめちゃくちゃ便利になっていってます。
開発者の方々に感謝して使っています。