The Round

合同会社ナイツオの開発ブログ

[PR] 5分から相談できるGCP™ 開発コンサル!→こちら

Firebaseでチャットアプリを作る日記(11日目)〜 メッセージの検索(復活編)

すこし遅れましたが明けましておめでとうございます。本年も宜しくお願い申し上げます。

さて、昨年末Advent Calendar用に書いていた「Firebaseでチャットアプリを作る日記」ですが、今年もペースは落としつつ少しずつ進めていければと思っています。

本日は10日目の記事で撃沈したメッセージの検索に再チャレンジします。
(※結論から言うと成功しました^^)

やりたいこと

  • メッセージを全文検索したい
  • 自分の所属する複数ルームを跨いで検索
  • 投稿日降順でソート
  • Firestoreのみで実現(3rd partyツールを使わない)

作戦

10日目の記事では、bigramで分割したトークンをドキュメントのarrayフィールドに保持してarray-containsで検索をかける作戦でしたが、array-containsは1回のクエリで1度しか使えないという制約があり撃沈しました。orz

そこで作戦を変更してトークンをmapフィールドに保存します。フィールドのkeyにトークンを、valueに固定値trueを保存することでarray-containsではなく == true で検索することができます。

トークンは10日目の記事ではmessagesドキュメントに直接埋め込むつもりでしたが、今回はmessageIndexコレクションのドキュメントとして別の場所に保存します。

Cloud Datastoreの様にarray-containsを複数回呼び出せるのならばwhere数が増減しても同一の複合インデックスで検索&ソートかけられるはずだったのですが、それがかなわず不特定複数プロパティを == で検索する方式となる為複合インデックスを用意することができなくなりました。

ソートは、「ソート指定がない場合はドキュメントIDでソートされる」という仕様を利用します。
ドキュメントIDにソート項目をプレフィクスとして付与してmessageIndexesドキュメントを保存します。そしてmessageIndexesコレクションに対してクエリを実行します。
今回は投稿日降順でソートをかけたいので、投稿日を反転させた日付文字列をIDプレフィックスとします。

messageIndexesドキュメントにrooms IDとmesssages IDを持たせてmessagesドキュメントを辿れる様にします。

実装

テキスト分割

10日目の記事ではbigram(2文字ずつのトークン)に分割していました。

今回もほぼ同様ですが、bigramに合わせてunigram(1文字ずつのトークン)も用意します。これにより1文字のみの部分一致検索も可能となります。

function bigram(s) {
  s = s.trim().replace(/\s\s*/g, ' ');

  let resultSet = new Set();

  let prev;
  for (let i = 0; i < s.length; i++) {
    if (i > 0 && prev != ' ' && s[i] != ' ') {
      resultSet.add((s[i-1] + s[i]).toLowerCase());
    }
    prev = s[i];
  }
  return Array.from(resultSet);
}

function unigram(s) {
  s = s.trim().replace(/\s\s*/g, ' ');

  let resultSet = new Set();

  for (let i = 0; i < s.length; i++) {
    if (s[i] != ' ') {
      resultSet.add(s[i].toLowerCase());
    }
  }
  return Array.from(resultSet);
}

function biunigram(s) {
  return bigram(s).concat(unigram(s));
}

biunigram関数で、例えば test という文字列は te es st t e s t というトークンに分割されます。

メッセージの追加

messagesドキュメントとmessageIndexesドキュメントをバッチ保存します。

export function addMessage(room, user, text) {
  const db = firebase.firestore();

  const msgRef = db.collection("rooms").doc(room.id).collection("messages").doc()

  // 検索結果を降順にソートする為に反転させたタイムスタンプ文字列をプレフィクスに付与する
  const epoch3000 = 32503680000000; // 3000.1.1のUNIXミリ秒
  const tsPrefix = new Date(32503680000000 - Date.now()).toISOString();
  const msgIdxId = `${tsPrefix} ${room.id} ${msgRef.id}`;

  const msgIdxRef = db.collection("messageIndexes").doc(msgIdxId)

  let message = {
    from: user.email,
    text: text,
    members: room.members,
    createdAt: firebase.firestore.FieldValue.serverTimestamp(),
    indexId: msgIdxId // 削除や変更に備えてmessageからmessageIndexも参照できる様にしておく
  };
  
  let messageIndex = {
    from: user.email,
    members: room.members,
    roomId: room.id,
    messageId: msgRef.id,
    indexes: {} // 検索用インデックス
  }

  // 検索用インデックス作成
  biunigram(message.text).forEach(idx => {messageIndex.indexes[idx] = true});

  // 登録
  const batch = db.batch();

  batch.set(msgRef, message);
  batch.set(msgIdxRef, messageIndex);

  batch.commit().catch(function(error) {
    // とりあえず手抜きでalert
    window.alert('message post failed. ' + error.code + ':' + error.message);
  });
}

これで保存されたドキュメントは下記のイメージとなります。 (メッセージに「test」と入力した場合)

f:id:knightso:20200116041943p:plain
messageIndexes

検索

次に検索コードです。

export function searchMessages(roomId, text) {

  let user = get(currentUser);

  let db = firebase.firestore();

  text = text.trim().toLowerCase();

  let tokens = [];
  if (text.length == 1) {
    tokens = [text]; // 1文字のみの場合はそのまま検索
  } else if (text.length > 1) {
    tokens = bigram(text); // 2文字以上はbigramに分割
  }

  // まずmessageIndexesに対してクエリ実行して、そこからmessagesを参照する
  let query = db.collection("messageIndexes").where("members", "array-contains", user.email);

  // indexesに対しトークン全てを「==」で検索する
  // フィールド名にアルファベット・数字以外が含まれる場合はfirebase.firestore.FieldPathを使う必要がある
  tokens.forEach(token => {query = query.where(new firebase.firestore.FieldPath("indexes", token), "==", true);});

  return query.get().then(function (querySnapshot) {
    let _msgPromises = [];

    querySnapshot.forEach(function (idxRef) {
      let idx = idxRef.data();

      // messageIndexesの検索結果を元にmessagesドキュメントを取得
      _msgPromises.push(db.collection("rooms").doc(idx.roomId).collection("messages").doc(idx.messageId).get());
    })

    return Promise.all(_msgPromises).then(function(values) {
      return values.map(msgRef => {
        let msg = msgRef.data({serverTimestamps: "estimate"});
        msg.id = msgRef.id;
        return msg;
      });
    });
  });
}

ユーザーの入力値もbigramで分割してindexesフィールド(map)に対して「==」で検索をかけています。 「==」で検索できるものならばmessageIndexesにフィールド追加も可能です。

「membersフィールドにサインインユーザーが含まれていること」と、「ユーザー入力のbigramトークンが全てインデックスに含まれていること」を一つのクエリでフィルタしています。

特殊なフィールド名の指定

一点ハマったので共有しておきます。

今回フィールド名(mapのkey)にメッセージから作成したトークンを保存しています。これにはドット含む記号などあらゆる文字が含まれる可能性があります。
クエリ実行する場合、通常mapフィールドのパスは indexes.fieldName などの様にドット区切りで指定しますが、フィールド名に記号が含まれるとこの指定方法が使えなくなります。

ドキュメントには

Must enclose each field name in backticks unless the field name meets the following requirements:
* The field name contains only the characters a-z, A-Z, 0-9, and underscore (_)
* The field name does not start with 0-9

の様にバッククォートで囲めと書かれていますが、これが期待通り動作しません。

正しくはfirebase.firestore.FieldPathを使用する必要がある様です。

参考: firebase - Firestore Query Properties with special characters - Stack Overflow

セキュリティルール

最後にセキュリティルールの追加です。 ユーザーがmembersフィールド(array)に設定されている場合のみ登録・参照可能とします。

    match /{path=**}/messageIndexes/{index} {
      allow create: if request.auth.uid != null &&
        request.auth.token.email_verified && 
        request.auth.token.email == request.resource.data.from && 
        request.auth.token.email in request.resource.data.members;
      allow read: if request.auth.uid != null &&
        request.auth.token.email_verified &&
        request.auth.token.email in resource.data.members;
    }

複合インデックス

今回は「==」のみの検索でフィールドソートなしの為、複合インデックスの追加は不要です。

実行!!

f:id:knightso:20200116042339j:plain
検索画面

検索結果取得できました!\(^o^)/

TODO

サーバータイムスタンプの取得

messageIndexesはメッセージ登録時刻降順でソートする仕様なのですが、メッセージ登録時刻は firebase.firestore.FieldValue.serverTimestamp でサーバー側で設定される為、フロントで取得することができません。

その為今回はフロント側システム時刻から計算しています。ユーザーがおかしなローカル時間のPCを使っていると検索結果の時系列が狂ってしまいます。

とりあえず思いつく解決策としては、messages登録をトリガーとしてCloud Functions起動してmessageIndexes保存をサーバーサイドで行うこと、でしょうか。これは次回の宿題にしたいと思います。

他の解決策やベストプラクティスご存知の方は教えてください。 🙇‍♂️

検索結果ノイズ

bigramで検索する為検索結果にノイズが発生する可能性があります。
例えば「東京都」で検索した場合「東京」と「京都」を含むメッセージがヒットしてしまいます。
メッセージが長文になればなるほどノイズ発生の確率もあがります。

trigram, 4-gramなどの長いトークンを増やせばノイズ減らせるかもしれませんがインデックス量が増加します。

トークン分割に形態素解析を行うのもよいかもしれません。これも別の機会に挑戦してみたいと思います。

まとめ

やや無理やりですが、Firestoreのみを使って検索機能を実現することができました!🎉🎉🎉

年を跨いだリベンジです!

messageIndexesをもう少し工夫することでもう少し柔軟な検索を行うことも出来そうです。
その辺りはまた別のブログでまとめたいと思います。*1

*1:細かなTipsはQiitaに書くかも・・