🔥

Firebaseを使い倒して、チャット部屋単位でプレゼンスを構築する(マルチタブ対応)

2022/05/13に公開

プレゼンスって何?

ユーザーがオンラインでアクティブになっているか、その状態のことです。
ちなみに、個人開発して先日無事にリリースした、集中したいときに集中できる SNS「MOKMO」で必要となった機能です。
このサービスの詳細と工夫した点は以下の記事を是非ご覧ください!

https://zenn.dev/h_sakano/articles/eaec32b780685e

今回出来上がったもの

以下のように、作業部屋にアクセスした際、リアルタイムにユーザー情報を表示する機能を作りました。

チャット部屋単位でプレゼンスを管理する

ユーザーが作業部屋にアクセスすると、自動的に参加者リストへ追加されるようになっております。
逆にユーザーがタブを閉じたり、PC がスリープモードになったりした場合には自動的に参加者リストから削除されます。

公式ドキュメントはあるが…

Firebase にプレゼンス管理専用のサービスはありません。
Firestore/Realtime Database/Cloud Functions の各種サービスを組み合わせて自分で実装する必要があります。
その点は以下の公式ドキュメントに記載されています。

https://cloud.google.com/firestore/docs/solutions/presence?hl=ja

しかし、この公式ドキュメントに記載されている例は、サービスに対して 1 つのプレゼンス情報を管理するものです。
今回のように作業部屋単位で、1 つのサービス内で複数のプレゼンスを管理するには工夫が必要でした。
また、この例は複数のタブでサービスを開かれる想定がされておらず、その点も改良が必要な点でした。

実装

フロント側の実装

以下のコードは、MOKMO のコードの中から、Realtime Database との接続部分を一部を抜粋したものとなります。

const connection = useRef<{
  listener: number | null;
  ref: ThenableReference | null;
}>({ listener: null, ref: null });

useEffect(
  () => {
    // ...
    const db = getDatabase();
    const userConnectionsRef = ref(db, `/connections/${user.uid}`);
    const connectedInfoRef = ref(db, ".info/connected");

    // プレゼンスの構築
    // 参考: https://firebase.google.com/docs/database/web/offline-capabilities#section-sample
    const connect = async () => {
      const onValueUnsubscribe = onValue(connectedInfoRef, async (snapshot) => {
        if (!snapshot.val()) {
          return;
        }

        if (!connection.current.ref) {
          connection.current.ref = push(userConnectionsRef);
        }

        await onDisconnect(connection.current.ref).remove();
        await set(connection.current.ref, {
          roomId: room.id,
        });
      });
      // ...
    };

    connect().catch(() => {
      // ...
    });

    // ...
  },
  [
    /* ... */
  ]
);

公式ドキュメントとの主な違いは、以下の部分です。

if (!connection.current.ref) {
  connection.current.ref = push(userConnectionsRef);
}

await onDisconnect(connection.current.ref).remove();
await set(connection.current.ref, {
  roomId: room.id,
});

マルチタブに対応するため、push を使ってタブごとに参照を分けるようにしています。
また、値には roomId を含め、後述する Cloud Functions の Realtime Database トリガーで利用しています。

Realtime Database の権限設定について

適用しているセキュリティルールは以下の通りです。

{
  "rules": {
    "connections": {
      "$uid": {
        ".read": false,
        ".write": "auth != null && auth.uid == $uid",

        "$uuid": {
          ".write": "(newData.exists() && $uid === auth.uid) || !newData.exists()"
        }
      }
    }
  }
}

フロントから送信した接続情報は、Cloud Functions で管理者権限を使って読み取るため、ユーザーは Read できないようにしています。
また、Write について、基本的にはそれぞれのユーザーについて、自分の接続情報のみ Write できるようになっています。

ただし、削除に関しては、接続情報の ID さえ知っていれば削除できるようになっています。
これはユーザーがサインアウトを行って接続を切った場合、サインアウト後のユーザーが接続情報を削除できるようにするためです。
ユーザーに Read 権限を与えていないため、接続情報の ID はバックエンドでしか使用できず、他人に漏れることも無いためセキュリティ上問題はないと考えています。
(ちなみに、仮に漏れたとしても、参加者リストからユーザーを削除できるだけ)

Cloud Functions でプレゼンス情報を Firestore に書き込む

Realtime Database に情報が書き込まれたとき、トリガーされるようにします。
書き込み前後のデータを比較して、冪等性に注意して入退室処理を行います。

export const onEnterRoom = functions
  .region("asia-northeast1")
  .database.ref("/connections/{uid}")
  .onWrite(async (change, context) => {
    const sanitizedEventId = context.eventId.replace(/[./]/g, "-");
    const triggered = await hasAlreadyTriggered(
      "onEnterRoom",
      sanitizedEventId
    );
    if (triggered) {
      return;
    }
    const uid = context.params.uid;
    const beforeSnapshot = await getFirestore()
      .collection("_onlineRooms")
      .doc(uid)
      .get();
    const beforeData = beforeSnapshot.data();
    const before = beforeData?.rooms;
    const afterSnapshot = await getDatabase().ref(`connections/${uid}`).get();
    const after = afterSnapshot.val();

    const beforeRoomIdSet: Set<string> = before
      ? new Set(before)
      : new Set();
    const afterRoomIdSet: Set<string> = after
      ? new Set(after
      : new Set();
    const addRoomIds = [...difference(afterRoomIdSet, beforeRoomIdSet)];
    const deleteRoomIds = [...difference(beforeRoomIdSet, afterRoomIdSet)];

    return (async () => {
      for (const room of addRoomIds) {
        functions.logger.log("try to add a connection to the room", room);
        await updateMember(room, uid, /* isOnline */true);
        functions.logger.log("finish adding a connection to the room", room);
      }

      for (const room of deleteRoomIds) {
        functions.logger.log("try to delete a connection from the room", room);
        await updateMember(room, uid, /* isOnline */false);
        functions.logger.log("finish deleting a connection to the room", room);
      }
    })();
  });

詳細は省きますが、updateMember 関数の中で Firestore にユーザー情報のプレゼンス情報を書き込んでいます。

Firestore のリアルタイムリスナーでプレゼンス情報の変更を検知して表示する

onSnapshot(
  query(
    collection(roomRef, 'members'),
    where('user', '!=', meRef),
    where('isOnline', '==', true),
  ).withConverter(roomMemberConverter),
  {
    next: (snapshot) => {
      // 参加者リストの更新処理
    },
  },
),

これは特に難しくないので、説明は省きます。

まとめ

Firebase の各種サービスを組み合わせると、以下のことが実現できました。

  • 1 つのサービス内で複数のプレゼンスを管理できた
  • マルチタブにも対応できた

Discussion