見出し画像

Firebase Realtime Databaseを利用した『Navitime Travel』の旅行プランリアルタイム共同編集機能について

こんにちは、Jです。
ナビタイムジャパンで『NAVITIME Travel』アプリの開発を担当しています。

2023年12月11日に旅行プランのリアルタイム共同編集機能をリリースしました。
『NAVITIME Travel』には、旅行プランを友達や家族と一緒に編集したいというご意見が以前から多数寄せられていました。この機能により、今までは1人だけで旅行プランを作成する必要があったのに対して、複数人での編集が可能になり、より快適に旅行プランの作成ができるようになりました。
共同編集機能を実現するにあたり、Firebase Realtime Databaseを採用しました。

この記事ではRealtime Databaseを利用した機能の制作過程をご紹介いたします。


『NAVITIME Travel』について

『NAVITIME Travel』は、旅行プランの作成や飛行機・宿泊施設の予約、全国の旅行ガイド記事の閲覧ができるサービスです。スポットの位置関係や移動時間を考慮した旅行プランの作成が簡単に行える「旅行プランニング」機能を提供しています。

Firebase Realtime Database とは

Firebaseは、Googleが提供するモバイル・Webアプリケーション開発プラットフォームでバックエンドの処理を簡単に実装できるサービスを提供しています。
その中の1つであるRealtime Databaseは、データの保管と同期を行うことができるデータベースです。データは JSON として保存され、接続されているすべてのクライアントとリアルタイムで同期されます。iOS、Android、JavaScript SDK を使用してクロスプラットフォーム アプリを構築した場合、すべてのクライアントが、1 つの Realtime Database インスタンスを共有して、最新のデータによる更新を自動的に受信します。


プランの共同編集機能の概要

複数人でプランをリアルタイムに編集できる機能です。
プラン作成者は他のユーザーをメンバーとしてプランに招待することができ、招待されたユーザーはプランの編集ができるようになります。プランにスポットの追加や削除などを行った場合は、他のメンバーのプラン画面にも変更が反映されます。
プランの更新から保存完了までの間に、他ユーザーが同時に更新を行った場合に不整合が起きないよう、他ユーザーの同時書き込みを制限する仕様にしています。


プラン更新の流れ

サーバーにプランを更新するリクエストを送る前後で、Realtime Databaseと通信を行い、ユーザーが最新のプランを適切に保持できるようにします。
Realtime Databaseではプラン更新中のデータと更新完了のデータを管理しています。
同時書き込みができないようにするため、サーバーのデータを更新する前の段階で、他ユーザーの更新中データがないか判定を行います。
基本的なプラン更新のフローは以下です。

プラン更新時のデータの流れ
  1. AさんがRealtime Databaseに更新中データ保存をリクエスト

  2. AさんがRealtime Databaseから更新中データ保存完了のレスポンスを取得

    1. 同時更新の場合はここで失敗します

  3. Aさんがサーバーにプランデータ保存をリクエスト

  4. Aさんがサーバーからプランデータの保存完了のレスポンスを取得

  5. AさんはRealtime Databaseにプラン更新完了データ保存をリクエスト

  6. Bさんが最新のプラン更新完了データを取得する

  7. Bさんは最新のプランデータを取得するためサーバーにプラン取得をリクエスト

  8. Bさんはサーバーから最新のプランデータを取得


セキュリティルールの設定

Realtime Databaseを使用する場合は、はじめにセキュリティルールをFirebaseのコンソールで設定する必要があります。データ構造と読み取り / 書き込みのアクセス権を定義します。
私はRealtime Databaseを利用した実装が初めてだったため、セキュリティルールの理解が不十分でした。セキュリティルールで何を定義して、どこまで値を制御できるかなどを知ることで、Realtime Databaseとアプリの連携を適切に行うことができました。

セキュリティルール

プラン毎に更新完了と更新中データを保持するように定義しました。

  • last-update 更新完了データ

    • update-time プラン更新完了日時(文字列)

    • has_changed_date 日程更新フラグ(真偽値)

  • being-updated 更新中データ

    • timestamp 更新開始日時(数値)

データに対してのアクセス権を定義するには以下のルールを追加します。

$$
\begin{array}{|l|l|} \hline
\text{.read} & データの読み取りを許可する\\ \hline
\text{.write}&データの書き込みを許可する  \\ \hline
\text{.validate}&値の適切なフォーマット方法やデータ型などを定義  \\ \hline
\end{array}
$$

.validate」に図のような定義を行うことで値の型を制限し、判定に当てはまらない場合Realtime Databaseを更新しないようになります。ここにtrueを入れている場合、どの型でも更新できるようになります。開発初期はtrueにしていたので、想定していない型で保存してしまい、データをアプリ側で受け取った時に、型が一致せずアプリでエラーが発生してしまいました。送るデータを間違えなければ発生しませんが、開発中でも適切に定義を追加することをお勧めします。

セキュリティルールには変数と関数が定義されているため、それらを用いてアクセス権の判定を行っています。
使用した変数、関数の意味を簡単にまとめました。

$$
\begin{array}{|l|l|} \hline
\text{data} & 現在保持しているデータ\\ \hline
\text{newData}&新しく書き込まれるデータ  \\ \hline
\text{now}&現在の\text{UNIX}エポックミリ秒  \\ \hline
\text{exists()}&データが含まれているかどうかの真偽値  \\ \hline
\text{child()}&指定された相対パスの\text{RuleDataSnapshot}取得  \\ \hline
\text{val()}&\text{RuleDataSnapshot}から値を取得  \\ \hline
\end{array}
$$

プランの同時更新をできないようにするためには、更新中データの「.write」に判定を追加して対応しました。初めは権限をtrueにして、アプリ側で判定を行う予定でしたが、どうしても実装的に難しい部分があり悩んでいました。セキュリティルールで使用できる変数や関数を知ることで、機能を実現することができました。私が思っていたよりも、詳細な定義をセキュリティルールに追加できました。


アプリの実装

ここからは実装の中身についてご紹介します。アプリにFirebaseのライブラリを取り込んでいるのが前提です。
Realtime Databaseからの読み込み処理は以下のような形で実装しています。コードはAndroidの例です。以下ではプランの更新完了データが更新された時にイベントを検知する処理です。セキュリティルールで定義したupdating-plans/プランID/last-update にアクセスします。ValueEventListenerの定義を追加することで、指定したプランIDに対して、更新毎にイベントを検知して読み込みを行うことができます。onDataChange()に処理が入ってきた場合にサーバーからプランを取得することで、アプリに表示しているプランを最新の状態にしています。

fun loadRealtimeDatabase(planId: String) {
   val dbReference = Firebase.database.reference
         .child("updating-plans").child(planId).child("last-update")
   val postListener = object : ValueEventListener {
       override fun onDataChange(dataSnapshot: DataSnapshot) {
           // データが変更された時の処理
       }

       override fun onCancelled(databaseError: DatabaseError) {
           // エラー時の処理
       }
   }
   dbReference.addValueEventListener(postListener)
}

プラン更新完了時の書き込み処理は以下のように実装しました。
削除に関してはremoveValue()を使用することで、指定した階層以下のデータを削除できます。
更新はupdateChildren()を使用することで、複数の値を同時に更新できます。

private val realtimeDatabase = Firebase.database.reference.child("updating-plans")

fun deleteRealtimeDatabases(planId: String) {
   realtimeDatabase.child(planId).child("last-update").removeValue()
}

fun updateRealtimeDatabase(planId: String, hasChangedDate: Boolean) {
   val updateTime = OffsetDateTime.now().toString()
   val childUpdates = hashMapOf<String, Any>(
       "/update-time" to updateTime,
       "/has-changed-date" to hasChangedDate
   )
   realtimeDatabase.child(planId).child("last-update").updateChildren(childUpdates)
}

書き込みの成功/失敗はListenerから取得できます。
更新する値がセキュリティルールで定義した条件に当てはまらない場合は、失敗のListenerで検知することができます。
プランの更新中データには書き込みの成功/失敗を検知して、プランのデータ更新をサーバーにリクエストするかどうかの判定に利用しています。

private val realtimeDatabase = Firebase.database.reference.child("updating-plans")

realtimeDatabase.child(planId).child("being-updated")
           .updateChildren(childUpdates)
           .addOnSuccessListener {
               // 更新成功
           }
           .addOnFailureListener {
               // 更新失敗
           }

アプリ側の実装は上記の組み合わせで、リアルタイム共同編集機能を実現できました。Androidで実装した例を載せていますが、iOSでもほぼ同じ形で実装できます。


最後に

Realtime Databaseを利用した開発を初めて行いましたが、資料も豊富で処理もシンプルなため、利用するハードルは高くないと思いました。
実機での読み込み動作に関しては、更新が完了した後すぐにデータを受け取ることが可能なため、他ユーザーが更新したプランの画面反映を即時に行うことができました。複数人での編集も違和感なく動作させることができ、良い機能になったと思っています。
最後までご覧いただきありがとうございました。
本記事が開発の一助になれば幸いです。