2022/05/28

Firestoreでリアルタイムチャットを実装する

こんにちは。ばんがです。

最近、現在業務で開発しているサービスでリアルタイムチャットを実装する機会がありました。
Firestoreを触るのもほぼ初めて、チャット機能も初めて実装したので備忘録として残しておきます。

今回作ったサンプル
https://github.com/bangarrr/firestore-realtime-chat

新規メッセージを監視する

まずは基本ですが、リアルタイムチャットなので新しく投稿されたメッセージがないかFirestoreを監視する必要があります。
その場合は通常のドキュメント取得で利用するget ではなくonSnapshot メソッドを利用してクエリの監視を行います。

参考:複数ドキュメントの監視
https://firebase.google.com/docs/firestore/query-data/listen#listentomultiple_documents_in_a_collection

まずはこれを用いてベースとなる簡単なチャットアプリを実装してみました。(下記コード)

import {collection, onSnapshot, orderBy, query, addDoc} from "firebase/firestore";
import {db} from "@/libs/firebase";
import {useEffect, useState} from "react";

type Message = {
  text: string;
  date: number;
}

const Chat = () => {
  const messageRef = collection(db, "messages")
  const [message, setMessage] = useState('')
  const [messages, setMessages] = useState<Message[]>([])

  useEffect(() => {
    const createListener = () => {
      const q = query(messageRef, orderBy('date'))

      return onSnapshot(q, (querySnapshot) => {
        const newMessages: Message[] = []
        querySnapshot.forEach((doc) => {
          const data = doc.data()
          newMessages.push({
            text: data.text,
            date: data.date
          })
        })
        setMessages(newMessages)
      })
    }

    const listener = createListener()

    return () => listener()
  }, [])

  const sendMessage = async () => {
    if (message.length === 0) return

    await addDoc(messageRef, {
      text: message,
      date: new Date().getTime()
    })
    setMessage('')
  }

  return (
    <>
      <div style={{
        padding: "20px",
        borderBottom: "1px solid #ccc"
      }}>
        チャットページ
      </div>

      <div style={{ maxWidth: '960px', margin: '0 auto'}}>
      <div>
        {messages.map((m, index) => (
          <div key={index} style={{ padding: "20px", margin: "8px 0", boxShadow: "rgba(0, 0, 0, 0.16) 0px 1px 4px" }}>
            {m.text}
          </div>
        ))}
      </div>
      <div>
        <input type="text" placeholder="メッセージを入力" value={message} onChange={e => setMessage(e.target.value)}/>
        <button onClick={sendMessage}>送信</button>
      </div>
      </div>
    </>
  )
}

export default Chat


上記では、createListener という関数で onSnapshot を実行し、クエリの監視を開始しています。

ポイントとしては以下です。

  • リスナーの作成はコンポーネントのマウント時に1回だけ実行したいので、useEffect内でリスナーの作成を行うようにします
  • onSnapshotは監視開始後、クエリ対象に新たなデータが追加されるとそのデータを検知してくれます。ただし、初回実行時のquerysnapshotには、クエリ対象の全データが含まれているので、わざわざ現在(初期状態)のチャットデータを別に取得する必要はありません
  • onSnapshotは返り値としてリスナーデタッチ用の関数を返すので、監視が不要になったタイミング(=アンマウント時)にそれを呼び出してリスナーのデタッチをしましょう


変更の差分のみを検知する

先程の例では、querysnapshotにはクエリ対象の全データが毎回(変更が検知されるたびに)含まれていました。
つまり、変更があるたびにmessages全入れ替え、のイメージです。

実際には変更があった差分データのみを検知、処理できたほうが色々やりやすいので、変更差分のみを取得する docChanges() を利用します。
また、docChanges() を使うとデータが追加されたのか、変更なのか削除なのか、という変更の種類もわかるので、変更によって処理をわけることができます。

これを使ってリスナーとmessagesの更新処理を書き換えると以下のようになります。
docChanges では変更されたデータのみが取得されるので、その分messages(state)の更新が若干複雑にはなりますが、 added modified deletedと変更内容によって柔軟に処理を書くことができます。

type ChangedData = {
  type: string;
  newIndex: number;
  oldIndex: number;
  data: Message;
}

...

return onSnapshot(q, (querySnapshot) => {
  const changedData: ChangedData[] = []

  querySnapshot.docChanges().forEach((change) => {
    const data = change.doc.data()

    changedData.push({
      type: change.type,
      newIndex: change.newIndex,
      oldIndex: change.oldIndex,
      data: {
        id: change.doc.id,
        text: data.text,
        date: data.date
      }
    })
  })

  updateMessages(changedData)
})

...

const updateMessages = (changes: ChangedData[]) => {
  setMessages(prev => {
    const newMessages = [...prev]

    changes.forEach(change => {
      if (change.type === "added") {
        newMessages.push(change.data)
      } else if (change.type === "modified") {
        const index = newMessages.findIndex(item => item.id === change.data.id)
        if (index >= 0) newMessages.splice(index, 1, change.data)
      }
      if (change.type === "removed") {
        const index = newMessages.findIndex(item => item.id === change.data.id)
        if (index >= 0) newMessages.splice(index, 1)
      }
    })

    return newMessages
  })
}


メッセージの削除機能も追加し、挙動を確認してみます。
2つのブラウザでチャット画面を開いて操作してみると、変更がリアルタイムに変更されていることがわかります!


読み取り回数を抑える

onSnapshotを使うと、初回実行時は監視対象のクエリ結果の全データがロードされるため、実行ごとに全データ件数分の読み取り回数が発生します。
読み取り回数の多さはそのまま料金に直結するので、できるだけ無駄な読み取りは抑えたいところです。

プロダクトの要件によりますが、実際のところ、過去全てのデータを監視する必要や、初回ロードで全データを取ってくる必要はそこまでないことが多いんじゃないでしょうか。

例えば以下のような仕様が考えられます。(無限スクロールでの実装)

  • 初回時は直近10件分のメッセージを取得し、それ以降のデータを監視する。
  • 過去のメッセージを遡るごとに、追加でさらに過去のメッセージを取得する。これらのデータは監視対象としない。


読み取り回数を抑えつつ、かつできるだけシンプルに実装するならこんな感じの実装で良いと思っています。
追加で取得した過去メッセージを監視対象に入れることもできますが、正直ちょっと複雑な実装になるし、過去のメッセージは稀にみることはあってもリアルタイムで変更される意味は少ないと思います。

ということで上記の方針でコードを修正してみます。

まず監視対象が直近10件のメッセージ以降となるようにonSnapshotのクエリを変更します。

監視対象を直近10件のメッセージ以降にするためには、直近10件のメッセージのうち最古のメッセージを判別しておく必要があります。
なのでまず最初に直近10件のメッセージを取得し、observeFromとして最古のメッセージを保持しておきます。

# メッセージ取得上限
export const MessageLimit = 10

const [observeFrom, setObserveFrom] = useState<DocumentData | null>(null)

useEffect(() => {
  const initialize = async () => {
    const q = query(messageRef, orderBy('date', 'desc'), limit(MessageLimit))
    const docs = (await getDocs(q)).docs
    const oldestMessage = docs[docs.length - 1]
    setObserveFrom(oldestMessage)
  }
  initialize()
}, [])


上記を利用してリスナのクエリをobserveFrom以降のドキュメントに限定してあげましょう。
startAtはいわゆるカーソルで、指定したドキュメントを開始点としてそれ以降のドキュメントを検索してくれます。

const createListener = () => {
  const q = query(messageRef, orderBy('date'), startAt(observeFrom))

  return onSnapshot(q, (querySnapshot) => {
    ...
  }
}


コード全体は以下のようになりました。
コードが大きくなってきたのでコンポーネントを分けています。

Chat.tsx

import {
  collection,
  onSnapshot,
  orderBy,
  query,
  addDoc,
  deleteDoc,
  doc,
  getDocs,
  limit,
  startAt
} from "firebase/firestore";
import {db} from "@/libs/firebase";
import {useEffect, useState} from "react";
import dayjs from "dayjs";
import 'dayjs/locale/ja';
import relativeTime from 'dayjs/plugin/relativeTime'
import updateLocale from 'dayjs/plugin/updateLocale'

dayjs.extend(relativeTime)
dayjs.extend(updateLocale)
dayjs.locale('ja');
dayjs.updateLocale('ja', {
  relativeTime: {
    future: '%s後',
    past: '%s前',
    s: '少し',
    m: '1分',
    mm: '%d分',
    h: '1時間',
    hh: '%d時間',
    d: '1日',
    dd: '%d日',
    M: '1月',
    MM: '%d月',
    y: '1年',
    yy: '%d年',
  },
});
import ScrollableCommentList from "@/components/ScrollableCommentList";
import firebase from "firebase/compat";
import DocumentData = firebase.firestore.DocumentData;

export type Message = {
  id: string;
  text: string;
  date: number;
}

type ChangedData = {
  type: string;
  newIndex: number;
  oldIndex: number;
  data: Message;
}

export const MessageLimit = 10

const Chat = () => {
  const messageRef = collection(db, "messages")
  const [message, setMessage] = useState('')
  const [monitoringMessages, setMonitoringMessages] = useState<Message[]>([])
  const [observeFrom, setObserveFrom] = useState<DocumentData | null>(null)

  const updateMessages = (changes: ChangedData[]) => {
    setMonitoringMessages(prev => {
      const newMessages = [...prev]

      changes.forEach(change => {
        if (change.type === "added") {
          newMessages.push(change.data)
        } else if (change.type === "modified") {
          const index = newMessages.findIndex(item => item.id === change.data.id)
          if (index >= 0) newMessages.splice(index, 1, change.data)
        }
        if (change.type === "removed") {
          const index = newMessages.findIndex(item => item.id === change.data.id)
          if (index >= 0) newMessages.splice(index, 1)
        }
      })

      return newMessages
    })
  }

  const deleteMessage = (id: string) => {
    deleteDoc(doc(messageRef, id))
  }

  useEffect(() => {
    const initialize = async () => {
      const q = query(messageRef, orderBy('date', 'desc'), limit(MessageLimit))
      const docs = (await getDocs(q)).docs
      const oldestMessage = docs[docs.length - 1]
      setObserveFrom(oldestMessage)
    }
    initialize()
  }, [])

  useEffect(() => {
    if (!observeFrom) return

    const createListener = () => {
      const q = query(messageRef, orderBy('date'), startAt(observeFrom))

      return onSnapshot(q, (querySnapshot) => {
        const changedData: ChangedData[] = []

        querySnapshot.docChanges().forEach((change) => {
          const data = change.doc.data()

          changedData.push({
            type: change.type,
            newIndex: change.newIndex,
            oldIndex: change.oldIndex,
            data: {
              id: change.doc.id,
              text: data.text,
              date: data.date
            }
          })
        })

        updateMessages(changedData)
      })
    }

    const listener = createListener()

    return () => listener()
  }, [observeFrom])

  const sendMessage = async () => {
    if (message.length === 0) return

    await addDoc(messageRef, {
      text: message,
      date: new Date().getTime()
    })
    setMessage('')
  }

  return (
    <>
      <div style={{
        padding: "20px",
        borderBottom: "1px solid #ccc"
      }}>
        チャットページ
      </div>

      <div style={{maxWidth: '960px', margin: '0 auto', padding: '0 20px'}}>
        {!!observeFrom && (
          <ScrollableCommentList
            monitoringMessages={monitoringMessages}
            deleteMonitoringMessage={deleteMessage}
            observeFrom={observeFrom}
          />
        )}
        <div>
          <input
            type="text"
            placeholder="メッセージを入力"
            value={message}
            onChange={e => setMessage(e.target.value)}
            onKeyDown={e => {
              if (e.key === 'Enter') {
                sendMessage()
              }
            }}
          />
          <button onClick={sendMessage}>
            送信
          </button>
        </div>
      </div>
    </>
  )
}

export default Chat


ScrollableCommentList.tsx
メッセージの表示や無限スクロールの実装、過去メッセージの取得などはこちらで行なってます。

import InfiniteScroll from "react-infinite-scroller";
import {useEffect, useRef, useState} from "react";
import dayjs from "dayjs";
import {Message} from "@/components/Chat";
import {collection, orderBy, query, startAfter, limit, getDocs} from "firebase/firestore";
import {db} from "@/libs/firebase";
import firebase from "firebase/compat";
import DocumentData = firebase.firestore.DocumentData;
import { MessageLimit } from "./Chat"

type Props = {
  monitoringMessages: Message[]
  deleteMonitoringMessage: (id: string) => void
  observeFrom: DocumentData | null
}

export const sleep = (duration: number) =>
  new Promise((resolve) => setTimeout(resolve, duration));

const ScrollableCommentList: React.FC<Props> = ({monitoringMessages, deleteMonitoringMessage, observeFrom}) => {
  const messageRef = collection(db, "messages")
  const scrollBottomRef = useRef<HTMLDivElement>(null);
  const [hasMore, setHasMore] = useState(true)
  const [isFetching, setIsFetching] = useState(false)
  const [unmonitoredMessages, setUnmonitoredMessages] = useState<Message[]>([])
  const [oldestMessage, setOldestMessage] = useState<DocumentData | null>(observeFrom)

  useEffect(() => {
    if (monitoringMessages.length > 0) {
      scrollBottomRef?.current?.scrollIntoView();
    }
  }, [monitoringMessages]);

  const fetchMoreMessages = async () => {
    if (!oldestMessage) return

    setIsFetching(true)
    const searchQuery = query(
      messageRef,
      orderBy('date', 'desc'),
      startAfter(oldestMessage),
      limit(MessageLimit)
    )
    const docs = (await getDocs(searchQuery)).docs
    const messages: Message[] = []
    for (const doc of docs) {
      const data = doc.data()
      messages.push({
        id: doc.id,
        text: data.text,
        date: data.date
      })
    }
    setHasMore(messages.length === MessageLimit)
    setOldestMessage(docs[docs.length - 1])
    setUnmonitoredMessages([...messages.reverse(), ...unmonitoredMessages])
    setIsFetching(false)
  }

  const deleteUnmonitoredMessage = () => {}

  return (
    <div style={{ height: '600px', overflow: 'auto' }}>
      <InfiniteScroll
        loadMore={fetchMoreMessages}
        loader={
          <div key={0} style={{ textAlign: 'center', padding: '20px 0', backgroundColor: '#73B3D9'}}>
            ローディング...
          </div>
        }
        hasMore={hasMore}
        isReverse={true}
        initialLoad={false}
        useWindow={false}
      >
        {unmonitoredMessages.map((m) => (
          <MessageItem message={m} deleteMessage={deleteUnmonitoredMessage} key={m.id}/>
        ))}
        {monitoringMessages.map((m) => (
          <MessageItem message={m} deleteMessage={deleteMonitoringMessage} monitoring={true} key={m.id}/>
        ))}
      </InfiniteScroll>
      {/* 最下部へのスクロール用div */}
      <div ref={scrollBottomRef}></div>
    </div>
  )
}

const MessageItem: React.FC<{message: Message, deleteMessage: (id: string) => void, monitoring?: boolean}> = (
  {message, deleteMessage, monitoring = false}
) => {
  return (
    <div
      style={{
        padding: "20px",
        margin: "8px 0",
        boxShadow: "rgba(0, 0, 0, 0.16) 0px 1px 4px",
        display: 'flex',
        justifyContent: 'space-between',
        backgroundColor: monitoring ? '#fff': '#eee'
      }}
    >
      <div>
        <span>{message.text}</span>
        <div style={{ fontSize: '0.8rem', color: '#aaa'}}>{dayjs(message.date).fromNow()}</div>
      </div>
      <button onClick={() => deleteMessage(message.id)}>削除</button>
    </div>
  )
}

export default ScrollableCommentList


以下のような挙動になりました。
(わかりやすいように監視外のメッセージ(追加ロードのメッセージ)は色を変えて、過去メッセージのロードに遅延を入れています)

新しいメッセージの追加や削除はリアルタイムに反映され、過去のメッセージの削除はリロードしないと反映されていないことがわかります。
初回読み込み時は10件のメッセージのみ読み込まれ、それ以降はメッセージが追加・削除されるたびに1件ずづ読み込みが発生します。
過去分のメッセージは、過去に遡らない限り読み込みは発生しないので、不必要な読み込みは抑えられています。

所感

Firestoreは初見なこともあり最初は慣れませんでしたが、公式ドキュメントは整備されているので見ればだいたいのことは解決できました。
また、onSnapshotのような監視機能も備わっているため、今回のようなリアルタイムなチャットをサクッと作れてしまい驚きです。

とはいえある程度ちゃんとしたチャットを作ろうとすると、今回紹介した読み取り回数など考慮すべき点や難しい点が他にも色々でてきたので、今後も改善を進めていければと思います。