Firebase+Vue.jsで湯婆婆(Pub/Sub+プッシュ通知もあるよ)

# はじめに
Javascriptだけでももう何番目かわからない湯婆婆の記事なのですが、何かしら独自性を持たせるべくmBaaSとして有名なFirebaseとVue.jsで湯婆婆していきます。

## デモサイト
[Firebase湯婆婆](https://yubaba-7bad4.web.app/)
### スクリーンショット

![湯婆婆](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/c22fdff2-dd06-4dfb-d233-d02be5a59343.jpeg)
契約書の全文を知っている方がいたら教えて下さい。

![ハク](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/ca212e8a-ab22-d8a6-50c3-c415aa1629b8.jpeg)
通知を許可して湯婆婆するとハクが名前を持ってきてくれます。


# この記事で取り扱わないこと
- Firebaseの初期設定
- FirebaseAdminSDKの認証など
- Vue.js/Vue-CLIの使い方

# Firebase(バックエンド)
主に使用する機能は
- Authentication
- Cloud Firestore(以下Firestore)
- Cloud Functions
です。また従量課金プランで使用できるCloud Schedulerを使って、古いドキュメントの削除とプッシュ通知送信も行います。

処理の流れとしては
1. フロントエンドからFirestoreに名前の入ったドキュメント作成
2. ドキュメント作成に成功したら該当ドキュメントをリアルタイム監視
3. バックエンドはドキュメント作成をトリガーにして名前を奪う処理を行う。
4. 処理結果が更新されたら画面表示を切り替え
という感じ。
ただ名前から1文字だけ取り出すならHTTPトリガーの関数で十分ですが、Firebaseの機能を一通り触るならこのやり方になると思います。

## Firestore
### ドキュメント構造とセキュリティルール
サーバーにリクエストを送る代わりにFirestoreを使うようなイメージですが、「クライアント側から直接読み書きする」というFirestoreの性質上、セキュリティルールの設定が必ず必要になります。セキュリティルールはアクセス制御だけでなくスキーマ検証もある程度行えるので、ここで妙な契約書は弾くようにしましょう。（湯婆婆が変な壺を買ってしまう展開は避けたい）

```javascript:firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function checkNewContract(auth, contract ){
      return contract.size() == 6
      && contract.uid is string && contract.uid == auth.uid
      && contract.name is string
      && contract.newName == null
      && contract.isFinished is bool && contract.isFinished ==false
      && contract.token is string
      && contract.createdDate is timestamp
      }
    match /contract/{docId} {
      allow get: if request.auth.uid != null && request.auth.uid== resource.data.uid ;
      allow create: if request.auth.uid != null && checkNewContract(request.auth, request.resource.data)
    }
  }
}
```

Cloud functionでの更新が行われた際、isFinishedフラグがTrueになっていたら処理完了としてVue.js側で表示切り替えを行います。

基本的に新規作成とドキュメント単体の読み込みしかしないので、許可を出すのはgetとcreateだけで大丈夫です。書かなかった処理は全部不許可になります。上にあるcheckNewContract()関数がスキーマ検証関数です。FirestoreはIDがランダムに振られるので、作成日時と更新日時はドキュメントに含めておいたほうが無難です。

## Authentication
今回は匿名認証を使用します。個人情報を取得せずにUIDだけを付与する形になります。「ログインさせる必要はないけどFirestoreのセキュリティルールに従わせたい」という場合には便利です。

![名無しがいっぱいコレクション](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/cd3bb665-912e-e02a-49db-9858e577fe62.png)
名無しがこんな感じで並んでいきます。おそらく端末単位でUIDが振られるはず。

## cloud functions
Firebaseの根幹をなす機能です。今回はここで湯婆婆していきます。

### 名前を奪う関数
今回はJavascriptで書きます。FirebaseのCLIで初期化する際にTypescriptを選択することも可能です。設定するとデプロイ処理時にlintもやってくれるので非常に便利です。処理の中身は先行記事と似たようなものですが、split()メソッドを使わないことで𠮷田さん問題を回避しています。

MDN曰く
> 警告: 空文字列 ("") を区切り文字列として使用すると、文字列がユーザーが知覚可能な文字 (書記素クラスター) に分割されるわけではなく、 Unicode 文字 (コードポイント)、ただし UTF-16 コード単位です。これはサロゲートペアを破壊します。 StackOverflow の “How do you get a string to a character array in JavaScript?” を参照してください。

という事らしいです。

```javascript:./components/contractEvent.js
import * as functions from 'firebase-functions';
import admin from './components/firebase';
// 
export const yubaba = functions.firestore
.document('contract/{contractId}').onCreate((snapshot, _context) => {
    const ref = snapshot.ref
    const data = snapshot.data()
    const name = data.name
    const newName =robName(name)
    // const db =admin.firestore()
    ref.update({
        newName,
        isFinished:true,
        updateDate:admin.firestore.FieldValue.serverTimestamp()
    }).catch((e)=>{
        console.log(e)
    })
    return 0
})

function robName(name:string):string|null{
    if(!name) return null
    const nameArry =[...name]
    const len = nameArry.length
    if(!len) return null
    const i =  Math.floor(Math.random() * len)
    return nameArry[i]
}

```
OnCreateトリガーの場合、コールバック関数の第1引数にはFirestoreでget()した際と同じ返り値なのでdata()メソッドを使えば中身が出てきます。あとは名前を奪ってそのまま更新するだけ。

# Vue.js(フロントエンド)
Vue側ではFirebaseSDKを読み込みます。普通に初期化してimportしても良いのですが、今回はpluginとしてVueのインスタンスから呼び出せるようにします。

```javascript:firebase.js
import firebase from 'firebase/app';
import 'firebase/firestore';
import 'firebase/functions';
import 'firebase/auth';
import 'firebase/messaging'
import 'firebase/analytics'

import { firebaseConfig, vapiKey} from './private/firebaseConfig'

const MyFirebase = {
  install(Vue) {
    // 親の顔より見た初期化
    if(!firebase.apps.length){
      firebase.initializeApp(firebaseConfig)
    }
    const isSupported = firebase.messaging.isSupported()

    // FCM対応の場合のみ有効化
    const messaging = isSupported ?firebase.messaging(): null
    const analytics = firebase.analytics()

    // どこでも呼び出し可能にする
    Vue.prototype.$firebase = firebase
    Vue.prototype.$auth = firebase.auth()
    Vue.prototype.$db = firebase.firestore()
    Vue.prototype.$functions = firebase.functions()
    // サーバータイムスタンプや配列操作などの便利な値が使える
    Vue.prototype.$fieldValue = firebase.firestore.FieldValue
    Vue.prototype.$analytics = analytics
    Vue.prototype.$messaging = messaging
    Vue.prototype.$vapiKey = vapiKey
  }
}
export { MyFirebase }
```

これをmain.jsで読み込むことで、Vueファイル上で直接呼び出せるようになります。

```javascript:main.js
import Vue from 'vue'
import App from './App.vue'
import {MyFirebase} from './plugins/firebase'

Vue.config.productionTip = false
Vue.use(MyFirebase)

new Vue({
  render: h => h(App),
}).$mount('#app')
```

肝心のVueファイルのscript。色々雑。

```html:App.vue
<script>
export default {
  name: 'App',
  data(){
    return {
      state:0,
      name:'',
      newName :"",
      token:'',
      resultListener:null
    }
  },
  computed:{
    // splitメソッドで分割するとサロゲートペアが壊れる。
    nameSeparatedArray(){
      if(typeof this.name !== 'string') return []
      return [...this.name]

  },
  btnMsg(){
    if(this.state===2) return 'やり直す'
     return '記'
  },
  yubaba(){
    if(this.state===0) return "契約書だよ。そこに名前を書きな"
    if(this.state===1) return `フン。${this.name}というのかい。贅沢な名だねぇ。`
    if(this.state===2 && this.newName) return `フン。${this.name}というのかい。贅沢な名だねぇ。今からお前の名前は『${this.newName}』だ。いいかい、『${this.newName}』だよ。分かったら返事をするんだ、『${this.newName}』!!`
    return '湯婆婆 は動作を停止しました。問題の解決策をオンラインで確認できます。'
  }
},
created(){
  // 匿名認証を行う。
  const user = this.$auth.currentUser
  if(!user){
    this.$auth.signInAnonymously().catch(()=>{
      this.hasError()
    })
  }
  if(this.$messaging){
    this.$messaging.getToken({vapidKey: this.$vapiKey}).then((currentToken) => {
      if (currentToken) {
        this.token = currentToken
      }
      })
      .catch(
        () => {
          this.token=''
        });
  }
},
beforeDestroy(){
  this.disconnect()
},
    methods:{
      isShow(char,index,nameArray,newName){
        // 契約前なら表示
        if(!newName) return true
        // 契約後で新しい名前と同じで無ければ非表示
        if(char !== newName) return false
        // 新しい名前と同じだが、重複している場合は最初に出てきた部分のみを表示する
        const firstWordIndex = nameArray.findIndex(c=>c===newName)
        if(firstWordIndex < index) return false
        return true
      },
      sign(uid,name,token){
        if(!uid ) return
        if(this.state===1) return
        if(this.state==2){
          this.reset()
          return
        }
        this.disconnect()
        this.$db.collection('contract').add({
          uid,
          name,
          newName:null,
          token:token||'',
          isFinished:false,
          createdDate:this.$fieldValue.serverTimestamp()
        }).then((result)=>{
          this.resultListener = this.onSnapshot(result)
          this.state=1
        }).catch(()=>{
          this.hasError()
        })
      },
      hasError(){
        this.state=2
        this.newName=''
        this.disconnect()
      },
      async onSnapshot(docRef){
        return await docRef.onSnapshot(doc=>{
          const data =doc.data()
          if(data.isFinished){
            setTimeout(()=>{
            this.newName = data.newName
            this.state=2
            this.disconnect()
            },1000)
          }
        })
      },
    disconnect(){
      if(typeof this.resultListener=== 'function'){
        this.resultListener()
      }
    },
    reset(){
      this.state=0
      this.name=''
      this.newName=''
    },
    tweetText(){
      return `Firebase湯婆婆「${this.yubaba}」`
    }
    }
}
</script>
```

## Cloud Scheduler(有料)
従量課金のBlazeプランに切り替えると、GCPのコンソール側からCloud Schedulerが使用可能になります。ここからはCloud SchedulerとCloud functionsのPubSubトリガーを使って機能を追加していきます。

現状でも名前を奪う処理は実装できているのですが、このまま放置していると無限に契約書が積まれて油屋が崩壊します。
（あと契約書を探されて本当の名前を見れられると何かと面倒なので、自動的に古い契約書を削除する処理を実装します。）

GCPのコンソールからプロジェクトを選んで、Cloud Schedulerを選択、あとは下の画像のようにPub/Subに設定したスケジュールを作成するだけ。
![細かい事は公式ドキュメント参照](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/425022/9030f793-2cd2-fa75-186f-eb8e20698379.png)
多分このへんは[公式ドキュメント](https://firebase.google.com/docs/functions/schedule-functions?authuser=0)がわかりやすい。

スケジュールを作成したら、Cloud functions側でもPubsubトリガーの関数を作成する。
例えば上のPubsubに対応する関数はこんな感じ。

```javascript:pubSubEvent.js
import * as functions from 'firebase-functions';
import admin from './components/firebase';

export const haku = functions.pubsub.topic('haku').onPublish((_) => {
    // ここに処理
  }

```

## Cloud messaging
湯婆婆が名前を奪う場面も有名ですが、その翌日にハクがおにぎりくれるあの場面も同じくらい有名なはず。
あの場面で千尋はハクからカードを渡され、「千尋」という自分の名前を忘れかけていたことに驚くのですが、せっかくなのでこの場面もやってしまいましょう。（そうしないと独自性がなくなってしまうので。）

### 対応環境の問題
微妙に面倒くさいのがsafari/iOSへの対応です。
FCMは[SafariとIEが非対応](https://firebase.google.com/support/guides/environments_js-sdk?hl=ja)となっているので、そのまま何もせずに読み込むとエラーになります。
なのでmessagingを呼び出す際にisSupported()メソッドで対応状況を確認します。

```javascript
  const messaging = firebase.messaging.isSupported() ? firebase.messaging(): null
```

サーバータイムスタンプを呼び出すときと同じく()が付きません。

### サービスワーカー
バックグラウンドで通知を受け取る場合は、サービスワーカーが必要になります。
コードについては[公式ドキュメント](https://firebase.google.com/docs/cloud-messaging/js/client?authuser=0)のコピペでいけます。

```javascript:firebase-messaging-sw.js
importScripts('https://www.gstatic.com/firebasejs/6.3.4/firebase-app.js')
importScripts('https://www.gstatic.com/firebasejs/6.3.4/firebase-messaging.js')

firebase.initializeApp({
    messagingSenderId: "ebikani"
})

const isSupported = firebase.messaging.isSupported()
// messages.
if(isSupported){
    firebase.messaging()
}
```

Vue-CLIで開始した場合は、publicディレクトリに上記のスクリプトを入れておけば大丈夫です。

## サーバー側
削除と一緒に通知を投げる処理は雑ですがこんな感じ。

```javascript:pubSubEvent.js
import * as functions from 'firebase-functions';
import admin from './components/firebase';
import { subHours } from 'date-fns'

export const haku = functions.pubsub.topic('haku').onPublish((_) => {
  const db = admin.firestore()
  const messaging=admin.messaging()
  // 作成されてから6時間以上経過したドキュメントを全取得
  const oldestDate:Date = subHours(new Date(),6) 
  db.collection('contract')
  .where("createdDate","<=",oldestDate)
  .orderBy("createdDate","desc")
  .get().then((snapshot)=>{
    const pushSendedUids:Set<String> = new Set()
    // バッチのインスタンスとカウンタ
    let batchCounter:number = 0
    let batch = db.batch()
    // 各ドキュメントを確認する。
    snapshot.forEach((doc) => {
      const data = doc.data()
      // メッセージのペイロード
      const messagePayload = generatePayload(data.name,　data.newName,　data.token)
      // 同じ人に何度も通知を送るのは嫌なのでsetにUIDを入れて識別する。
      if(data.token && !pushSendedUids.has(data.uid) && messagePayload){
        // ここでプッシュ送信
          messaging.send(messagePayload).then((result)=>{
            console.log("メッセージ送信:",result)
          }).catch((e)=>{
            console.log('送信失敗:',e)
          })
          pushSendedUids.add(doc.uid)
      }
      // バッチにオペレーションを追加していく
      batch.delete(doc.ref)
      batchCounter++
      // 500件追加したら実行して初期化
      if(batchCounter>499){
        batch.commit().catch((e)=>{
          console.log('バッチ失敗:',e)
        })
        batch=db.batch()
        batchCounter=0
      }
    });
    // 忘れずにバッチ実行
    batch.commit().catch((e)=>{
      console.log('バッチ失敗:',e)
    })
  }).catch((e:any)=>{
    console.log('ドキュメント取得失敗:',e)
  })
  
  return 0
});

function generatePayload( realName:string,newName:string,token:string ){
  if(!realName || !newName || !token) return null
  const title:string ='Firebase湯婆婆'
  const body:string =`「${realName}って… 私の名だわ。」`
  const url:string='https://yubaba-7bad4.web.app/'
  const image:string='https://yubaba-7bad4.web.app/img/onigiri.jpg'
  const icon:string ='https://yubaba-7bad4.web.app/favicon.ico'
  return {
    notification: {
      title,
      body,
      image,
    },
    android: {
      ttl: 3600 * 1000,
      notification: {
        click_action: url,
        icon,
      }
    },
    webpush: {
      headers: {
        TTL: '15'
      },
      fcm_options: {
        link: url
      }
    },
    token
  }
}
```

# 謝辞・感想
駆け足気味の記事ですが、読んでいただいてありがとうございました。また最初に湯婆婆した@Nemesis様、様々な言語で湯婆婆している投稿者の方々にも感謝を申し上げます。
中身は稚拙ですが、Firebaseの特徴的な機能(リアルタイムアップデートなど)は一通り触れることができたと思います。この記事が誰かの参考になれば幸いです。