[Firebase] Firestoreでリアルタイムなチャットを作る (Web編) その4

今回はチャットで発言する内容にNGワードのチェックを入れたいと思います。 特定のキーワードが含まれている場合、「?」など伏せ字に置換する機能です。

実装方法を考える

方針

この実装方法は2つのパターンが考えられます。

クライアントでチェック
中身を覗かれるとNGワードのリストが漏洩しますし、サーバ側のAPIを直接リクエストされると機能しません。ただし高速に動作する可能性が高い。
サーバ側でチェック
NGワードのリストが漏洩する心配もなく、サーバ側APIを直接叩かれても機能する。ただしクライアント側で処理する場合に比べると低速。

あくまでFirestoreで実装した前提ですが、いずれも一長一短ありますがここはひとまずサーバ側で実装してみたいと思います。

ロジック

大まかに以下のフローにします。

  1. 発言すると/chatroom/room4/messagesにドキュメントが新規作成される
    • 送信データ→{uid:"ユーザーID", name:"名前", msg:"発言内容", date:"発言時間。UNIXTIME", flag:false}
    • flagフィールドを追加。デフォルトはfalse
  2. ドキュメントが新規作成されたイベントでCloudFunctionを実行
    • NGワードがあればmsgフィールドを置換
    • 処理が終わったらflagフィールドをtrueに変更
  3. リアルタイムに同期するデータは where("flag", "==", true) とする

ユーザーが発言しFirestoreに追加されたタイミングではクライアントに配布されません。CloudFunctionが実行されflagが書き換わったタイミングで配布されます。

これによって発言データを取得する処理はこれまで通り高速に可動しますので、Firestoreのメリットを最大限活かすことができます。問題はCloudFunctionの実行でどれくらい遅延するかですね。

開発

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

セキュリティルールはこれまでよりもチェックを厳重にしてみました。

service cloud.firestore {
  match /databases/{database}/documents {
    match /chatroom/room4/messages/{document} {
      allow get:  if true;
      allow list: if request.query.limit <= 20;
      allow create: if request.auth.uid != null
                       && request.resource.data.name is string
                       && request.resource.data.msg  is string
                       && request.resource.data.date is int
                       && request.resource.data.flag == false;
      allow update, delete: if request.auth.uid == resource.data.uid;
    }
  }
}

思ったよりも細かくValidationチックなことが行なえます。 パッと見てわかる気もしますが、詳細は以下の通りです。

  1. getは制限なし
  2. listは一度に20件まで。ログイン不要
  3. createは以下の条件をすべてクリアした場合のみ可能
    • ログインしている
    • nameは文字列型であること
    • msgは文字列型であること
    • dateは数値型であること
    • flagは常にfalseであること
  4. update, deleteは作成した本人のみ可能

特にflagの値が、クライアントから何らかの事情でtrueとして渡ってくると、CloudFunctionsを通さずにそのまま露出してしまうため、この部分のチェックは確実に行う必要があります。

CloudFunctionsの作成

CloudFunctionsの基本的な利用方法は以下のページをご覧ください。 blog.katsubemakito.net

今回はcheckChatMessageという名前のFunctionをfunctions/index.jsに定義しました。

const functions = require('firebase-functions');

exports.checkChatMessage = functions.region('asia-northeast1').firestore
  .document('/chatroom/room4/messages/{id}')
  .onCreate( (snap, context)=> {

    const newValue = snap.data();
    let msg = newValue.msg;
    msg = msg.replace(/(しいたけ|椎茸|シイタケ)/g, '???');

    snap.ref.set({
      name: newValue.name,
       msg: msg,
      date: newValue.date,
       uid: newValue.uid,
      flag: true
    });

    return(0);
});

このコードで/chatroom/room4/messagesへ新たにドキュメントが作成された場合、ここで指定した関数が実行されます。 onCreate()snapに新しく作成されたドキュメントの値が入っていますので、このオブジェクトを参照しつつデータを取り出します。最終的にドキュメントを新しいデータで更新するのですが、snap.refが新規に作成されたドキュメントのリファレンスとなっていますので、ここにset()していきます。

NGワードの処理は非常に雑ですw ここでは"しいたけ", "椎茸", "シイタケ"のいずれかの文字が含まれていた場合、"???"へ置換されます。NGワードの置換処理は非常に奥が深いので割愛しますが、ループで単語をひとつずつチェックすると処理スピードが出ないので、1つの正規表現にまとめてしまった方が良い場合があります。英語圏だとスペースで区切って単語レベルでチェックできるのですが日本語は難しいですよね。形態素解析まで持ち出すとパフォーマンスに影響してきますし…。

CloudFunctionsのコードが書けたらデプロイしておきます。

$ firebase deploy --only functions

クライアント

config.js

Firebaseのコンソールで表示される内容をそのままコピペしconfig.jsというファイル名で保存しました。これまでと同じです。

// コンソールの内容をそのままコピペ
var config = {
  apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  authDomain: "test-f76bc.firebaseapp.com",
  databaseURL: "https://test-f76bc.firebaseio.com",
  projectId: "test-f76bc",
  storageBucket: "test-f76bc.appspot.com",
  messagingSenderId: "1111111111111111"
};
firebase.initializeApp(config);

index.html

チャットの本体です。 変更点は大きく2箇所です。以下の同期処理を行う際に、flagがtrueになっているものだけをwhere()で条件指定しています。

//---------------------
// 同期処理
//---------------------
this.messagesRef.where("flag", "==", true).orderBy("date", "asc").limit(20).onSnapshot( (snapshot) => {

あとはFirestoreへデータを送信する際にflagを追加しています。

sendLogCreate: (str)=>{
    this.messagesRef.add({
      name: Chat.user.name,
      msg: str,
      date: new Date().getTime(),
      uid: Chat.user.uid,
      flag: false
    })

コード全文は以下になります。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Firestore Chat4</title>
  <style>
      #chatlog{ width:520px; height:300px; border:1px solid gray; overflow-y:scroll; }
      #pleaselogin{ width:540px; border:1px solid gray; padding:10px; background-color:lightgrey; }
      #uname{ width:130px; float:left; margin-right:10px; padding-top:5px; text-align:center}
      #msg{ width:300px; height:30px; margin-right:10px; font-size:12pt;}
      #sbmt{ width:100px; height:30px; }
      .hide{display:none}

      .chatlog-item{cursor:default;}
      .chatlog-item-mine{background-color:lightpink; cursor:pointer;}
  </style>
</head>
<body>
  <h1>Firestore Chat4</h1>

  <!-- 発言が表示される領域 -->
  <ul id="chatlog"></ul>

  <!-- 入力フォーム -->
  <div id="pleaselogin">
    ※チャットへの書き込みは<a href="/db/firestore5/login.html">ログイン</a>が必要です。
  </div>
  <form id="form1" class="hide">
    <div id="uname"></div>
    <input type="text" id="msg"><button type="button" id="sbmt">送信</button>
  </form>

  <script src="https://www.gstatic.com/firebasejs/5.8.1/firebase-app.js"></script>
  <script src="https://www.gstatic.com/firebasejs/5.8.1/firebase-auth.js"></script>
  <script src="https://www.gstatic.com/firebasejs/5.8.1/firebase-firestore.js"></script>
  <script src="/js/config.js"></script>
  <script>
    /**
     * Chatオブジェクト
     **/
    var Chat = {
      //----------------------------------------
      // プロパティ
      //----------------------------------------
      user: {
        uid: null,
        name: null
      },
      db: null,
      messagesRef: null,

      //----------------------------------------
      // メソッド
      //----------------------------------------
      /**
       * 初期処理
       **/
      init: () => {
        this.db = firebase.firestore();
        this.messagesRef = this.db.collection("chatroom").doc("room4").collection("messages");

        //---------------------
        // 同期処理
        //---------------------
        this.messagesRef.where("flag", "==", true).orderBy("date", "asc").limit(20).onSnapshot( (snapshot) => {
          snapshot.docChanges().forEach((change) => {
            // 追加
            if ( change.type === 'added' ) {
              Chat.addLog(change.doc.id, change.doc.data());
            }
            // 更新
            else if( change.type === 'modified' ){
              Chat.modLog(change.doc.id, change.doc.data());
            }
            // 削除
            else if ( change.type === 'removed' ) {
              Chat.removeLog(change.doc.id);
            }
          });
        });

        //---------------------
        // 送信ボタン
        //---------------------
        document.getElementById("sbmt").addEventListener("click", ()=>{
          let msg = document.getElementById("msg").value;
          if( msg.length !== 0 ){
            Chat.sendLogCreate(msg);
          }
        });
        // submitイベントは(いったん)無視する
        document.getElementById("form1").addEventListener("submit", (e)=>{
          e.preventDefault();
        });
      },

      /**
       * [Firestoreへ送信] ログを追加
       *
       * @param {string} str メッセージ
       **/
      sendLogCreate: (str)=>{
        this.messagesRef.add({
          name: Chat.user.name,
          msg: str,
          date: new Date().getTime(),
          uid: Chat.user.uid,
          flag: false
        })
        .then(()=>{
          let msg = document.getElementById("msg");
          msg.focus();
          msg.value = "";
        })
        .catch((error) => {
          console.log(`追加に失敗しました (${error})`);
        });
      },
      /**
       * [Firestoreへ送信] ログを削除
       *
       * @param {string} id
       **/
       sendLogRemove: (id)=>{
        this.messagesRef.doc(id).delete()
        .then(()=>{
          console.log(`削除しました (${id})`);
        })
        .catch((error) => {
          console.log(`削除に失敗しました (${error})`);
        });
      },

      /**
       * 描画エリアにログを追加
       *
       * @param {string} id
       * @param {object} data
       **/
       addLog: (id, data)=>{
        // 追加するHTMLを作成
        let log = `${data.name}: ${data.msg} (${getStrTime(data.date)})`;
        let li  = document.createElement('li');
        li.id   = id;                                 // id属性を作成
        li.dataset.uid = data.uid;                    // data-uid属性を作成
        li.classList.add( (data.uid===Chat.user.uid)? "chatlog-item-mine":"chatlog-item");
        li.appendChild(document.createTextNode(log));

        // 表示エリアへ追加
        let chatlog = document.getElementById("chatlog");
        chatlog.insertBefore(li, chatlog.firstChild);

        // 削除機能をセット
        document.getElementById(id).addEventListener("click", (e)=>{
          let uid = e.target.dataset.uid;
          let id  = e.target.id;

          if( uid === Chat.user.uid && confirm("削除しますか?") ){
            Chat.sendLogRemove(id);
          }
        });
      },

      /**
       * 描画エリアのログを変更
       *
       * @param {string} id
       * @param {object} data
       **/
       modLog: (id, data)=>{
        let log = document.getElementById(id);
        if( log !== null ){
          log.innerText = `${data.name}: ${data.msg} (${getStrTime(data.date)})`;
        }
      },

      /**
       * 描画エリアのログを削除
       *
       * @param {string} id
       **/
       removeLog: (id)=>{
        let log = document.getElementById(id);
        if( log !== null ){
          log.parentNode.removeChild(log);
        }
      }
    };  // Chat


    /**
     * 描画エリアのログを変更
     *
     * @param {string} id
     * @param {object} data
     **/
    firebase.auth().onAuthStateChanged( (user) => {
      // ログイン状態なら書き込みフォームを開放
      if( user !== null ){
        //隠す
        document.getElementById("pleaselogin").classList.add("hide");

        //表示
        document.getElementById("chatlog").classList.remove("hide");
        document.getElementById("form1").classList.remove("hide");

        // ユーザー情報を確保
        Chat.user.uid  = user.uid;
        Chat.user.name = user.displayName;
        document.getElementById("uname").innerText = Chat.user.name;
      }

      // Firestore処理開始
      Chat.init();
    });

    /**
     * UNIX TIME => MM-DD hh:mm
     **/
     function getStrTime(time){
      let t = new Date(time);
      return(
        ("0" + (t.getMonth() + 1)).slice(-2) + "-" +
        ("0" + t.getDate()       ).slice(-2) + " " +
        ("0" + t.getHours()      ).slice(-2) + ":" +
        ("0" + t.getMinutes()    ).slice(-2)
      );
    }
  </script>
</body>
</html>

login.html

Firebaseへログインするためのフォームです。こちらは変更していません。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Firestore Chat4</title>
  <link type="text/css" rel="stylesheet" href="https://cdn.firebase.com/libs/firebaseui/3.5.2/firebaseui.css" />
  <style>#auth{text-align: center;}</style>
</head>
<body>
  <section id="auth">
    <h1>Firestore Chat4</h1>
    <p>ログインまたは新規登録をしてください</p>
    <div id="firebaseui-auth-container"></div>
  </section>

  <script src="https://www.gstatic.com/firebasejs/5.8.1/firebase-app.js"></script>
  <script src="https://www.gstatic.com/firebasejs/5.8.1/firebase-auth.js"></script>
  <script src="https://www.gstatic.com/firebasejs/ui/3.5.2/firebase-ui-auth__ja.js"></script>
  <script src="/js/config.js"></script>
  <script>
    //----------------------------------------------
    // Firebase UIの設定
    //----------------------------------------------
    var uiConfig = {
        // ログイン完了時のリダイレクト先
        signInSuccessUrl: '/db/firestore5/',

        // 利用する認証機能
        signInOptions: [
          firebase.auth.EmailAuthProvider.PROVIDER_ID  //メール認証
        ],

        // 利用規約のURL(任意で設定)
        tosUrl: 'http://example.com/kiyaku/',
        // プライバシーポリシーのURL(任意で設定)
        privacyPolicyUrl: 'http://example.com/privacy'
      };

      var ui = new firebaseui.auth.AuthUI(firebase.auth());
      ui.start('#firebaseui-auth-container', uiConfig);
  </script>
</body>
</html>

インデックスを貼る

これで準備万端と思って実行すると以下のようなエラーがコンソールに表示されました。

Uncaught Error in onSnapshot: FirebaseError: The query requires an index. You can create it here: https://console.firebase.google.com/project/test-f76bc/database/firestore/indexes?create_index=EghtZXNzYWdlcxoICgRmbGFnEAIaCAoEZGF0ZRACGgwKCF9fbmFtZV9fEAI

Firebaseは基本的に自動でインデックスを張ってくれるのですが、それは単一のフィールドを操作する場合だけ。今回のようにflagで絞り込みをかけ、dateでソートをかけるといった複数の操作を行う場合には、明示的にインデックスを作成する必要があります。

操作方法としてはコンソールに表示されたURLをクリックし画面の指示に従うと自動で必要なインデックスが作成されます!これ最初はめっちゃびっくりしましたw なんてユーザーフレンドリー…じゃない開発者フレンドリーw

自分でゼロからやる場合はFirebaseのコンソールの「Database」→「インデックス」とたどり「インデックスを追加」ボタンをクリック。

入力画面が登場しますので、適宜設定を行います。

サンプル

実行結果

miku3.net

  • 書き込むためにはログインが必要です。閲覧は未ログイン状態でも行えます。
  • 自分が書き込んだログは背景がピンク色になり、クリックすると削除できます。
  • 自由に投稿していただいて問題ありませんが、公序良俗に反する書き込みはご遠慮ください。
  • 適当なタイミングでお掃除します。

CloudFunctionsが遅い問題

動いているサンプルを触るとわかるのですが、発言した際に実際に画面に表示されるまでワンテンポずれます。ワンテンポならまだ良いのですが場合によっては数十秒かかる場合もあります。

しばらく実行されないとスリープする

CloudFunctionsが実行されない状態が続くと自動的に落ちます。この状態で実行要求があると再び立ち上がるのですが数秒から数十秒かかるようです。

たまったもんじゃないですねw 本番(プロダクション)環境でひっきりなしに実行されるような物であれば問題なさそうですが、例えばメンテナンス開けなどしばらく実行されない状況が続いたあとだと初回の動作でこける可能性があります。よってメンテ開け前に中の人が事前に触っておく必要があるわけですがいつか事故になる気がしないでもないです。

これを設定するような項目は存在しないようなので、対策としては定期的に起動するようなスクリプトを組んでおくくらいでしょうか。

そもそも遅い

Firebaseのリアルタイムに同期する機能と比較すると非常に遅く感じます。 この点は諦めるしかありません。

今回のようなチャットの場合、クライアント側で視覚的にごまかすことで体感速度を上げることは可能です。

  1. 書き込んだ瞬間に自分だけには表示する
  2. NGワードがあれば後で更新する(書き換える)

遅いことを前提として仕様をきるしかありません。

参考ページ

firebase.google.com https://firebase.google.com/docs/firestore/security/secure-data?hl=jafirebase.google.com