CloudFunctionとFirestoreで作るサーバーレスダッシュボード開発5つの勘所

Masahiro Osanai
Voicy Engineering
Published in
15 min readMar 19, 2019

はじめに

こんにちは、Voicyデータストラテジストの小山内です。Voicyではプロダクトの大改修フェイズを迎えており、殆どのサービスを作り直す勢いで、開発チームが日夜開発に勤しんでいます!

↓例えば、バックエンドは、ぱんでぃ(Yoshimasa Hamada)さんが担当しています。

大改修にも含まれる新機能の1つとして、”放送の聴取ログをパーソナリティさんに提供するダッシュボード”を開発する事となりました。

複数の実装方法を考えた中で、今回はGCPの各種プロダクトを活用した、サーバーレスなダッシュボードを作る方向性で検討を進めています。

本記事では、実際に技術検証を進めるに当たって、ポイントとなった点をサンプルコードと共に共有します。

同じ様なダッシュボード開発ではなくても、GCP製品をお使いの方で、参考になりそうな箇所があったら、嬉しい限りです!✏️

対象読者と記事から得られるナレッジ

対象読者:

  • ダッシュボード機能の様な、データを活用したプロダクトを開発している方
  • Firestoreを使っている方

記事から得られるナレッジ:

・記事内では以下5つのポイントを説明しています!

1: CloudFucntion上でクエリ実行&集計結果をテーブルに保存するHow

2: Pub/SubをFunction内で呼び出して、ジョブフローを作るHow

3: Firestoreの一括書き込みで 大量のドキュメントを短期間で更新するHow

4:サブコレクションを使って、データモデリングを行うHow

5:クライアント側からFirestoreにクエリを発行するHow

“素敵なダッシュボード”に求められる条件ってなんだっけ?

いきなり実装の詳細に入る前に、アーキテクチャを設計するに当たって考えた事を簡単に紹介します!

今回、機能を開発するに当たり、利用者 / 開発者の両方の視点から、”素敵なダッシュボード”に求められる要件を整理しました。サマリと、今回のアーキテクチャでケアした部分を併記すると以下になります。

データを見る人に提供したい体験 👨‍👩‍👧

  • ノンストレスでサクサク/待ち時間を感じる事なくデータを閲覧できる

事前集計の可能なメトリクスは、一定間隔でバッチ実行し、 データストアに格納

  • 深掘り分析に役立つ、詳細なメトリクスを閲覧できる

BigQueryに保存するローデータをデータソースにする事で、standardSQLで表現可能な範囲において、いかなる指標も追加できる

開発チームに提供したい体験 👨‍💻

・指標の追加/削除など仕様に変更があった場合にも、簡単に対応できる

スキーマレスなFirestore&カラム指定無しのコーディングで、0開発でスキーマを事後変更できる

・将来的にデータ量がスケールした場合にも、追加対応不要で安定稼働できる

大規模データの格納にも十分なパフォーマンスを発揮するFirestoreを使用する

・シンプルなデータ設計で、データエンジニア/実装担当の学習コストを低く抑える事ができる

Firestoreのサブコレクションを用いた直感的に理解しやすいDB構造と、クエリ

・安い💰

10万書き込みでも$0.18のスタートアップにも優しいお値段

Cloud Firestoreの料金

https://firebase.google.com/docs/firestore/pricing?hl=ja

実装上のポイント

先に述べた実装要件を満たす様に、設計したアーキテクチャが以下になります。

もの凄くザックリと説明すると、”BigQuery での集計/Firestoreへのデータ格納の2つをCloudFuncitonに時系列に沿って実行する”構成となっています。

全体の簡易図

STEP1: BigQueryで集計する

実装上のポイント1:CloudFucntion上でクエリ実行&集計結果をテーブルに保存する方法

BigQuery クライアント ライブラリを使用する事で、CloudFunction上でBigQueryに関する様々な処理を実行する事ができます。

Voicyですと、実際に最終的にFirestoreに投入するデータは、BigQueryに集計・保存してあるので、BQにクエリを発行し、新規のテーブルを保存する処理が必要になります。

CloudFunctionの最大実行時間は540秒ですが、BigQueryのクエリ実行は非常に高速なので、今回の要件だと実行時間が問題になる事は無さそうでした。

コード1: CloudFunction内でBigQueryでクエリ実行&テーブル保存 — Python

# ライブラリのインポート
from google.cloud import bigquery
# 初期化
project_id = 'voicy-xxxxxx'
client = bigquery.Client(project = project_id)
# ジョブ設定の記述
dataset_id = 'dashboard_soure_data'
path = 'daily_uu'
job_config = bigquery.QueryJobConfig()
table_ref = client.dataset(dataset_id).table(path)
job_config.destination = table_ref
job_config.write_disposition='WRITE_TRUNCATE'
# クエリの発行&テーブル保存
query_job = client.query(
query,
location='US',
job_config=job_config)
# APIリクエストの発行 --> クエリの実行&テーブルへ保存
query_job.result() # Waits for the query to finish
print('Query results loaded to table {}'.format(table_ref.path))

実装上のポイント2:Pub/SubをFunction内で呼び出して、ジョブフローを作る

Pub/Subを間に挟む事で、関数の実行順を担保しながら、マイクロサービス的に処理を繋げる事ができます。具体例を挙げると、以下のような流れで、データを処理しています。

1: Function A内で、BigQueryで計算実行&DAUをテーブルに格納

2: Function Aの末尾で、Pub/Subの”dashboard_report_daily”にイベントをパブリッシュする

3: Function Bを、Pub/Subの”dashboard_report_daily”をトリガーとして監視するように設定。2が終了した瞬間にFirestoreへの、DAU格納処理を実行する

このように処理を細分化しておく事で、今後別の機能でDAUの集計値を使いたくなった場合でも、処理を使い回す事が出来ると考えています。

BigQuery同様に、Pub/Subもクライアントライブラリが用意されているので、こちらを使用します。(とってもシンプル!💡)

コード2: CloudFunction内でPub/SubにイベントをPublishする— Python

# ライブラリのインポート
from google.cloud import pubsub
# 初期化
publisher = pubsub.PublisherClient()
# イベントをパブリッシュ!
publisher.publish("projects/voicy_xxxx/topics/xxxx",b'Query finished!')

CloudFunctionは、デフォルトでは各プロジェクトにデフォルトのサービスアカウントの権限で実行されます。同一プロジェクト内のリソースは認証無しでアクセスできますが、異なるプロジェクトのリソースを操作する際には、追加の権限が必要になるので、都度対応しつつ進める必要があります。

参考記事

STEP2: Firestoreのバッチ書き込み実行

さて、ここまでで、①CloudFucntion内でBigQueryを操作し、②Pub/Subを使って複数のFucntionでフローを組む 事が出来るようになりました。

続いて、実際にFirestoreにデータを書き込む部分を見ていきたいと思います。

実装上のポイント3:Firestoreの一括書き込みで 大量のドキュメントを短時間で更新する

今回のダッシュボード機能の開発、実は当初AWSのDynamoDBをクライアントから呼び出すデータストアとして使用する予定でしたが、書き込み性能で難しいところがあり、断念した経緯があります。

Firestoreは、一括書き込みの仕組みを持っており、こちらを使用する事で大量データの書き込みを個別で処理するよりも高速で捌く事ができます。

コード上では、batchオブジェクトにset()でドキュメントをセット→ commit()で書き込み処理実行というフローになります。

Firestoreの書き込み性能については、以下の制約があるので実利用の際には、検討が必要です。

1: 1秒間の最大ドキュメント書き込み回数は最大10,000回

2: 一括書き込みで同時にcommit()できるドキュメント数は最大500個

Firestoreの書き込みに関連する制約

具体的なコードは後ほど紹介しますが、実際に書き込みのパフォーマンスを見てみましょう。

GCPのコンソールから確認できるFirestore書き込み処理の実行ログ

約50,000件の書き込みで2分30秒かかるパフォーマンスでした🎉

今回の案件では必要十分な性能なので問題ないですが、より多くのデータを書き込み処理を行う場合は、CloudFunction上では捌き切れなくなるため、別の実行環境を用意する必要がありそうです。

実際に、バッチを生成し、Firestoreにドキュメントの書き込みを行うコードは以下になります。先に述べた制約があるため、500回単位でbatchオブジェクトを初期化しながら、commit()をループしている所がポイントです。

def fs_batch_write(dataset):    # 書き込み先のコレクションパスを設定 
records_collection = self.db.collection('speakers')

# 初期化
index = 0
batch = self.db.batch()
# 書き込み処理のループ開始 (今回は、PandasのDataFrameを使用)
for idx,record in dataset.iterrows():
# Python dictionary型に変換
document = record.to_dict()
# 500レコード毎にバッチをコミット
if index % 500 == 0:
if index > 0:
# バッチをコミット!
batch.commit()
# 次のイテレーションに備えて、バッチを初期化
batch = self.db.batch()
index += 1
# ドキュメントの書き込み先参照を作成
record_ref = records_collection.document(str(document["DocumentID"])
# バッチに書き込み先参照とドキュメントを渡して、処理をセット
batch.set(record_ref, document)
# ループを抜ける前に、コミットを実行
if idx % 500 != 0:
batch.commit()

実装上のポイント4:サブコレクションを駆使して、直感的に理解しやすいDB構造にする

記事冒頭、実装時に気をつけるポイントとして、以下の点を挙げていました。

・シンプルなデータ設計で、データエンジニア/実装担当の学習コストを低く抑える事ができる

Firestoreのサブコレクションを用いた直感的に理解しやすいDB構造と、クエリ

Firestoreでは、 サブコレクションを活用する事で、人間が直感的に把握しやすいデータモデルを設計する事ができます。

実際に、データモデリングを行う際には、セキュリティ要件や、発行される可能性のあるクエリの種類(ビジネスロジック)も考慮し、以下のような構成にしました。

ダッシュボード向けFirestoreのデータモデリング例

Voicyの場合だと、個別のパーソナリティが複数の放送を保持し、さらに個別の放送は複数のチャプターによって構成されているという関係性があります。DAUなどの時系列データもまた親子関係として、それぞれのドキュメントに紐づいています。

こうした関係性をサブコレクションで表現する事で、直感的に理解しやすい構成を意識しました。

一方で、こちらの構成だと、”各パーソナリティ/放送を跨いだデータを取得するクエリ” は実行できないため、例えば、”Voicy内全ての放送の中で1.000UU以上の放送の一覧”などは取得できません。

しかし、もし実際に今後そのようなクエリ(設計時には想定していなかった新規のビジネスロジック) が必要になった際には、データの複製や、非正規化を行い柔軟に設計も変更する予定です。

ダッシュボードの様に、ユーザーの使用状況に合わせて、データモデル自体が変化する可能性が高い機能は、NoSQLのスキーマレスな性質と大変相性が良いものだと感じます。

STEP3: FirestoreClientライブラリからデータ読み込み

実装上のポイント5:クライアント側からFirestoreにクエリを発行する

ここまでの説明で、サブコレクションを用いてモデリングされたFirestoreに、大量のデータを短時間で格納する事が出来ました。

最後の仕上げに、クライアント側から、Firestore側にクエリを発行してみます。 以下の様なクエリを投げる事で、SQLの様なデータ取得処理を記述できます。具体的なクエリの書き方は、公式ドキュメントをご参照下さい

db.collection(u'Speakers').where('SpeakerName','==','Voicy社内報')

非常にシンプルな書式なので、クエリをデバッグしながら組み立てて行けば、詰まる所も殆ど無くデータを取得できるかと思います!

サーバーレスなダッシュボードが完成しました!

再掲

長くなりましたが、色々とつまづきそうなポイントを越え、実装を終えることが出来ました。最後まで読んで頂きありがとうございました🍵

部分的にでも、参考になる箇所があれば、Voicyではこうやって作っているよという事で、ナレッジとして使って頂ければと思います🔥

Voicyでは、共に働く仲間を募集しています!

Voicyでは、共に”音声×テクノロジーでワクワクする社会を作る”仲間を募集しています。全方位絶賛募集中ですので、気になった方はぜひオフィスに遊びに来てください〜!

https://www.wantedly.com/companies/voicy

もくもく会もほぼ毎週開催しています!

↓その他データ関連のテックブログ記事へのリンク(Voicyのデータ活用が丸裸!👀)

--

--

Masahiro Osanai
Voicy Engineering

JDSCのプロダクトマネージャーです。機械学習/GCP/AWS/低温調理/スーパー銭湯に興味津々です。