GAミント至上主義

コストにうるさいWEBアプリ開発者。最近はPython, Vue.js, Kubernetesがメイン@株式会社ビズオーシャン。https://github.com/uyamazak/

Vue.jsのPWAでバックエンドをDjangoからFirebaseに移行した

現在開発中のVuejsを使ったPWAで、開発当初はログインやデータの保存にDjangoを使っていたけど、ずっと気になっていたFirebaseに変更してみることにした。

クライアント側のコードはほぼそのまま流用し、機能も少なかったので、1日程度でとりあえず動いた

とりあえず移行した機能は下記。

  • Googleログイン(Firebase Authentication)
  • ユーザーごとのデータバックアップ+ロード(Cloud Firestore ※まだベータ)

もちろん移行できなかった、しなかった機能もある。

  • PDF生成サーバーLinuxでヘッドレスChromeを使うため)
  • アップロードされた画像をリサイズしてBase64で返すもの(Djangoのまま、これは自分のJS力次第でCloud Functionsで行けそう)

印象としては、もうモバイルに限らず、新規WEBアプリはこれでいいんじゃないかという印象。
足りないものはCloud Functionsなり、自分でサーバーを立てる別途GKEで立ててAPIとして使えばほぼ大丈夫そう。
安いし、管理不用で、スケールアウトは実質無限大。

導入のながれとしては、すでにGCPを使っており、プロジェクトもあるのでそのまま進む。

firebase.google.com

そうするといろいろ設定が表示されるので使う。

webpackでやっているのでnpmでインストール

npm install --save firebase

Vueのプラグインという形でラッパーを作り、どこからでもthis.$firebaseという形で呼び出せるようにした。
やることが増えたら機能ごとに分けたほうが良さそう。

とりあえず動いたって感じなので不用なコピペしたコードが残っており、めっちゃ汚い。

import firebase from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'
import VueLocalForage from 'vue-localforage'
const _ = require('lodash')
// ↓登録後にもらえるやつ
const firebaseConfig = {
  apiKey: 'yourKey',
  authDomain: 'projectName.firebaseapp.com',
  databaseURL: 'https://projectName.firebaseio.com',
  projectId: 'projectName',
  storageBucket: '',
  messagingSenderId: '00000000'
}
firebase.initializeApp(firebaseConfig)
// ↓これいれないとなんかエラーでる
const storeSettings = {
  timestampsInSnapshots: true
}

const saveKeys = ['format_rirekisho', 'basicData', 'yagiProfile', 'recentEdit']
const BACKUP_VERSION = 'v20180511-01'
const BACKUP_COLLECTION_KEY = 'backups'
const BACKUP_LIMIT_NUM = 10


const FirebaseLib = {
  install: function (Vue, options) {
    Vue.use(VueLocalForage)
    const main = new Vue({
      data: function () {
        return {
          authProvider: new firebase.auth.GoogleAuthProvider(),
          db: {},
          user: {},
          backupList: []
        }
      },
      computed: {
        isAuthenticated: function () {
          if (this.user && this.user.uid) {
            return true
          } else {
            return false
          }
        }
      },
      methods: {
        signIn: function () {
          firebase.auth().signInWithRedirect(this.authProvider).then(function (result) {
            // This gives you a Google Access Token. You can use it to access the Google API.
            console.log(result)
            var token = result.credential.accessToken;
            // The signed-in user info.
            var user = result.user;
            app.$message({
              message: 'ログインしました:' + user.email,
              type: 'info'
            })
            // ...
          }).catch(function (error) {
            console.log(error)
            // Handle Errors here.
            var errorCode = error.code;
            var errorMessage = error.message;
            // The email of the user's account used.
            var email = error.email;
            // The firebase.auth.AuthCredential type that was used.
            var credential = error.credential;
            // ...
          })
        },
        signOut: function () {
          const app = this
          firebase.auth().signOut().then(function () {
            // Sign-out successful.
            app.$message({
              message: 'ログアウトしました',
              type: 'info'
            })
          }).catch(function (error) {
            app.$message({
              message: 'ログアウトに失敗しました',
              type: 'error'
            })
            console.log(error)
          })
        },
        showResultMessage: function (message, type) {
          this.$message({
            message: message,
            type: type
          })
        },
        setupDb: function () {
          this.db = firebase.firestore()
          this.db.settings(storeSettings)
        },
        loadBackup: function (id) {
          this.setupDb()
          let errors = []
          const app = this
          this.db.collection(BACKUP_COLLECTION_KEY).doc(id).get()
            .then(
              function (documentSnapshot) {
                console.log(documentSnapshot.data())
                let data = JSON.parse(documentSnapshot.data().data)
                _.forEach(saveKeys, function (key) {
                  if (data.hasOwnProperty(key)) {
                    app.$setItem(key, data[key],
                      function (err) {
                        if (err) {
                          console.log(err)
                          errors.push(err)
                          return
                        }
                        console.log('sync-loaded:', key)
                        app.$eventHub.$emit('sync-loaded-' + key, data[key])
                      })
                  }
                })
                if (errors.length === 0) {
                  app.$message({
                    message: 'バックアップを読み込みました' + documentSnapshot.data().label,
                    type: 'success'
                  })
                }
              }
            )
            .catch(function (error) {
              app.$message({
                message: 'バックアップの読み込みに失敗しました',
                type: 'error'
              })
              console.log(error)
            })

        },
        deleteBackup: function(id){
          this.db.collection(BACKUP_COLLECTION_KEY).doc(id).delete().then(function() {
              console.log("Document successfully deleted!");
              return true
          }).catch(function(error) {
              console.error("Error removing document: ", error);
              return false
          })
        },
        getBackupList: function () {
          if (!this.isAuthenticated) {
            return
          }
          this.setupDb()
          const uid = this.user.uid
          this.backupList = []
          const app = this
          this.db.collection(BACKUP_COLLECTION_KEY)
            .where('uid', '==', this.user.uid)
            .limit(20)
            .orderBy('timestamp', 'desc')
            .get()
            .then(
              function (querySnapshot) {
                let count = 0
                querySnapshot.forEach(function (doc) {
                  // doc.data() is never undefined for query doc snapshots
                  // console.log(doc)
                  if (count < BACKUP_LIMIT_NUM) {
                    app.backupList.push({
                      id: doc.id,
                      value: doc.data()
                    })
                  } else {
                    console.log(count, doc.data().label)
                    app.deleteBackup(doc.id)
                  }
                  count++
                });
              });
        },
        addBackup: async function (label) {
          if (!this.isAuthenticated) {
            this.$message({
              message: 'ログインしてください',
              type: 'error'
            })
            return
          }
          let storageData = {}
          await this.$iterateStorage(function (value, key, iterationNumber) {
            if (saveKeys.includes(key)) {
              console.log(key, value)
              storageData[key] = value
            }
          }, function (err) {
            if (!err) {
              console.log('Iteration has completed');
            }
          })
          if (!storageData) {
            this.$message({
              message: 'データがありません',
              type: 'error'
            })
            return
          }
          var ts = Date.now()
          this.setupDb()
          const backupData = {
            label: label,
            uid: this.user.uid,
            timestamp: ts,
            data: JSON.stringify(storageData),
            version: BACKUP_VERSION
          }
          console.log(backupData)
          const app = this
          this.db.collection(BACKUP_COLLECTION_KEY).add(backupData).then(
            function () {
              app.$message({
                type: 'success',
                message: '保存成功:' + label
              })
            }
          ).catch(function (error) {
            app.$message({
              type: 'error',
              message: '保存失敗:' + label
            })
          })
        }
      }
    })
    firebase.auth().onAuthStateChanged(function (user) {
      main.user = user
    })
    firebase.auth().getRedirectResult().then(function (result) {
      console.log('getRedirectResult', result)

      if (result.credential) {
        // This gives you a Google Access Token. You can use it to access the Google API.
        // var token = result.credential.accessToken;
        main.showResultMessage('ログインしました:' + result.user.email, 'success')
        // ...
      }
      // The signed-in user info.
      var user = result.user;
    }).catch(function (error) {
      // Handle Errors here.
      var errorCode = error.code;
      var errorMessage = error.message;
      // The email of the user's account used.
      var email = error.email;
      // The firebase.auth.AuthCredential type that was used.
      var credential = error.credential;
      main.showResultMessage('ログインに失敗しました:' + error.message, 'error')
    });
    Vue.prototype.$firebase = main
  }
}
export default FirebaseLib

これをmain.jsで読み込む。

import FirebaseLib from './assets/lib/FirebaseLib'
Vue.use(FirebaseLib)

はまったところ

Cloud Firestoreの権限

バックアップはログインしたユーザーで、自分のしか見れたり、作れたりしないといけない。
ベータのせいか、あんまりドキュメントがなく、下記のような形でひとまず動いている。

service cloud.firestore {
  match /databases/{database}/documents {
    match /backups {
    	allow list: if request.auth.uid != null && request.query.limit <= 20;
      match /{backupid} {
      	allow read, delete: if request.auth.uid == resource.data.uid;
        allow write: if request.auth.uid == request.resource.data.uid;
      }
    }
  }
}

まずdatabaseとかdocumentsとか、collectionとか独自の概念ではまる。
listを付けないと一覧を取る際にエラーになるところではまる。
次に追加するデータはrequest.resource.data.uid、すでにあるのはresource.data.uidの違いがわからずはまる。
あと更新にしばらく時間がかかる上に、更新サれたかどうかも分からないのでちょっと手が止まる。

と、慣れないことなので大分時間をくったけど、こういう細かい権限管理はRDBではできないのですげー便利だと感じた。

ログイン状態を取るには非同期が必須

ページ読み込み時やvueのテンプレート上、mountedなどで現在のユーザー情報を取れなくて困ったが公式に書いてあった。

Firebase でユーザーを管理する  |  Firebase

firebase.auth().onAuthStateChanged(function (user) {
      main.user = user
})

これでユーザー状態をVueの監視下にいれておけば便利。

1日程度のちょっとした勉強で強大な力とスピードを得られた気がする。

Vue.js + Firebaseで個人開発もやってみたいと思う。