テストステ論

京大卒の高テス協会会長がテストステロンに関する情報をお届けします

【アプリ開発】Firestoreのクエリは読み込んだドキュメント数で課金されるので工夫したという話【イータイルズ】

Firestoreというのは今年2月に正式されたFirebaseの新しいデータベースですが、料金方式が少しわかりづらく、

hackernoon.com

Firestoreによって72時間で300万円吸われたという話もあります。読むのが辛くなるレベルのだらだらした記事であり、「直した」と書いてあるのにどうやって直したか書いていない文系脳なのですが、それはともかく、Firestoreというのが使い方を誤ると課金がとんでもないことになりうるサービスということはわかると思います。現在のイータイルズですら、雑な作り方をすると無料枠は飛び出てしまうと思います。

料金方式がわかりづらいのは、ドキュメントがわかりづらいというのもありますが、そもそも料金方式自体が直感的ではないというのが理由です。また、クライアントライブラリのキャッシュなどによってもわかりづらくなっている面もあり、実際に失敗してる人は多いのではないかと思いました。

そこで今回は、イータイルズにレーティング機能を実装した時に行った「Firestoreを使うに当たっての工夫」について2点共有します。

Firestoreのクエリ課金について

Firestoreにおける登場人物はドキュメントとコレクションしかいません。ドキュメントというのは1つのJSONファイルのようなもので、コレクションというのはその集合です。課金は、クエリの回数ではなく、読み込んだドキュメントの個数に対して決まります。

冒頭に紹介した記事では、

Every time we call the service vakis.ts , on the constructor method was the line this.loadPayments() which called the service payments.ts and with that service we were printing the information of a Vaki. This means, that with every visitor to our site, we needed to call every document of payments in order to see the number of supports of a Vaki, or the total collected. On every page of our app!

と書いてあるとおり、毎回全データを読み込んで、そこから集計作業をするというコードを書いていたら、とんでもないドキュメント量とクエリ回数の積に対して請求されたという話で、これはFirestoreの課金うんぬんの前に、計算量の見積もりの時点でだめだとわかるだろ(実際にロードに30秒かかったそうです)と思うのでプログラマが単にだめな気がしますが、

例えば、典型的な例としては、あるコレクション中、ある値についてそれより上の値を持ったドキュメントの数を取得したい場合にも、今のFirestoreでは、全ドキュメントを読み込んでからサイズを取得する他ないため、ドキュメント数に対して課金されます。この仕様についてはユーザからも不満が上がっていて、countのようなクエリを実装してくれという話も上がっていますが、Firestoreの概念的にドキュメントとコレクションしかない世界を描いているため、どうやって返すんだろうという疑問はあります。(意味のないドキュメントで埋めて返してあげるとかが考えられます)

レーティング機能の概要

マッチの結果、新しいレーティングがどうやって計算されるかは以下のドキュメントに書きました。マッチの結果をFirestoreに送るとそれをトリガにしてCloud Functionsをキックして、そこで現在のレーティングを読み込み、新しいレーティングを計算して格納するということをしています。

www.akiradeveloper.com

そして今回追加したのがレーティングのリーダーボードです。

f:id:akiradeveloper529:20190625093007j:plain

工夫

工夫1:ロード結果のキャッシュ

レート変動のログをグラフ表示していますが、そのデータをどうするかという話です。

https://firebase.google.com/docs/firestore/billing-example

でチャットアプリを作る例を使ってFirestoreの料金について解説されています。

The home screen of the app loads the 25 most recent chats, incurring charges for 25 document reads. Assume that an active user opens the app 5 times per day, totaling 125 reads per user each day. However, more efficient queries, like the one in the following example, can reduce this load.

db.collection('groups')
  .where('participants', 'array-contains', 'user123')
  .where('lastUpdated', '>', lastFetchTimestamp)
  .orderBy('lastUpdated', 'desc')
  .limit(25)

チャットアプリで最新の25件を常にロードするというのだと重複した部分も課金されてしまうので、真に新しいメッセージだけをロードしましょうという話です。

このlastFetchTimestampというのをどこから持ってくるのか、そしてすでに読んだメッセージをどこに保存するのかが書いてないですが、とりあえず私はローカルDBのRealmを使ってコピーしています。クライアントライブラリにキャッシュを持っているため、そこに差分ロードしてからローカルだけ読む

        interface GetOptions {
          source: 'default' | 'server' | 'cache';
        }

という実装でも動くかも知れないですが、隠蔽されたキャッシュの制御が出来なくて、いつ消されるのかとか古いものを消したい時はどうするのかとか困りそうだったので、自分で制御出来るRealmを使っています。

工夫2:リーダーボード

リーダーボードを素直に作ると、現在レーティングのコレクションをソートして上位から100件持ってくるとかいう実装になりそうですが、これをやると毎回100ドキュメント請求されるハメになります。(今は10もないですがすぐに100になります!)

なので、Cloud Functionsを使ってその結果を定期的にドキュメントに保存して1ドキュメントとして取得することにしてます。このように、読み込みたいデータ自体をデータベースに保存してクエリを節約するというのがNoSQLを使う場合の基本的な思想のようです。

幸い、イータイルズのゲームの特性上、リーダーボードはランクマッチを行った後にしか更新しなくて良いため、ランクマッチの時間帯を制限することにすることによってユーザの不満が起こらないように出来ました。

さいごに

イータイルズはGoogle Play ServicesとFirebaseだけで作っており、自前のサーバーを持っていません。実際、世の中にあるような典型的なアプリのほとんどは、AWSを使って自前でサーバーを立てたりしなくても実装出来たり、大半をFirebaseで作って一部だけ自前で作るという構成で実装出来るような気がします。非常に便利なサービスなので、今後も使っていく上で知見が得られたら共有しようと思います。