GAミント至上主義

Web Monomaniacal Developer.

GCPを使ったPWAのサーバー構成を考える

Vue.jsとwebpackといくつかの独自サーバー処理が必要なアプリをどうGCPでデプロイするかを考えた。

静的ファイルとWEB API的な部分で分けて考える。

1、静的ファイル配信

Webpackでビルドしたjs、HTMLとかjs、CSS、画像とかメインになるファイルたち。

選択肢は3つ考えた。

Cloud Storage

AWSでいうS3。静的ファイルには一番安そう。
でも独自ドメインSSL化は単体では無理。
手前にHTTPSロードバランサーを入れるとそれだけで2,000円/月程度かかってしまう。

静的ウェブサイトのホスティング  |  Cloud Storage ドキュメント  |  Google Cloud

Nginx + HTTPSロードバランサー

やるとしたらGoogle Container Engine(GKE)でやる。Nginxで証明書もってもいいけど、GKEで外部IPを取るとTCPロードバランサーが使われ、HTTPSと値段は一緒なのでSSL処理を外に出せるHTTPSの方を使った方がお得感がある。

Nginxは鉄板だけど、変更の度にDockerイメージのデプロイがいるのは面倒な気がする。

Firebase Hosting

いまのところ最強くさい。

Hosting を使ってみる  |  Firebase

CLIでのデプロイとロールバック、管理画面でもいろいろできる。

さらにドメインのAレコードを向けるだけでSSL証明書もやってくれるもよう。

気になる料金は会社のアカウントでは有料プラン(Flame、月25ドル)になってしまっているので、容量10 GB、転送量50GB/monthまで使えて十分すぎる。

Firebase

さらにCloud Functions(以下CF)も組み合わせて使うことができ、同一ドメインの特定のパスをCFに渡せる。
今回は難しいけどサーバーサイドを全てCFのNode.jsで済ませることができればいわゆるサーバーレスが実現できそう。
レスポンス速度が気になるので今度試してみる。

2、WEB API

今回は、ヘッドレスChromeを利用したHTML→PDF変換サーバーがあるため、Dockerが必要。
またその前処理、画像のリサイズをPythonDjangoで作っており、これもDockerで動かしたいのでGKE一択となりそう。

他の選択肢も一応まとめる。

DjangoだけならスタンダードのApp Engineという手もあるが、いまさらPython2で書きたくない。

App Engine 環境の選択  |  App Engine Documentation  |  Google Cloud

フレキシブルのApp EngineでDockerを使う手もあるが、DjangoとPDF変換サーバーで最低2台必要で、PDF変換サーバーはグローバルに出したくないので、やっぱGKEが良さそう。
あとGKEに慣れてしまったので自由度の低いフレキシブルを使うには抵抗がある。

アクセスに大きな波があり、必要なスケール幅が読めないとかならApp Engine系の方がサーバー管理は楽そうだが今回は新規でそこまで波があるサービスではないのでGKEでなんとかできそう。

あと上記に出たCloud Functions。
今回はPDF変換サーバーは無理としてもDjango部分は何とかできるかもしれないが、どうせGKEを使うことになるのでまとめた方が良さそう。

まとめ

ということで、
静的ファイルはFirebase Hosting
APIや各種サーバーはGKEで構築するのが今回は良さそう。

Cloud FunctionでできることならHostingとの連携もできるので今後はどんどん使って行ったほうが良いと思う。

ブラウザでログインしたFirebaseのユーザー情報をサーバー側で取得する

前回からの引き続きやりたかったのは、Vue.jsとFirebase(ブラウザ上)でログイン済みユーザーのメールアドレスに、サーバー側からメールを送りたかった。

Vue.jsのPWAでバックエンドをDjangoからFirebaseに移行した - GAミント至上主義

もちろん、js側でFirebaseのユーザーのメールアドレスを取得して、サーバー側にPOSTなんかで一緒に送ればできる。

詳細は公式
Interface: User  |  Firebase


でもサーバー側ではそのメールアドレスが本当にそのユーザーのものなのかはわからず、でたらめなメールアドレスでも受け取ってしまうので危険なメール発射台にされてしまう可能性がある。
リクエストはユーザーのWEBブラウザから送られてくるのでIP制限もできない。

そのため、ブラウザのjs側からはtokenを送り、サーバー側でそのtokenを使ってユーザーのメールアドレスを取得して送信すれば、確実にユーザーのものに送ることができる。

今回Googleログインのみを使っているため、そのアクセストークンを取得し、GoogleのOAuthのAPIサーバー側に送ればいいかと思ったけど、そのトークンを取得できるのは下記のようなサインイン時のメソッドしか見当たらなかった。

サインイン直後なら使えるけど、期限が1時間で切れてしまうが、それを更新する方法が再ログインさせるしか見当たらず、ユーザー側の流れが切れてしまうし、リダイレクトを挟んだり処理が複雑になるので避けたい。

firebase.auth().signInWithPopup(provider).then(function(result) {
  // This gives you a Google Access Token. You can use it to access the Google API.
  var token = result.credential.accessToken;
  // The signed-in user info.
  var user = result.user;
  // ...
})

で、しばらくどうしようと思ってたけど、わざわざGoogleのOAuth APIからではなく、FirebaseのAPIから直接取ればいいと気づいた。

ログイン完了後は公式通りonAuthStateChangedでユーザーオブジェクトを取得できるので、グローバルなプロパティか、vuexなどに入れておくと便利。

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

firebase.auth().onAuthStateChanged(function(user) {
  if (user) {
    // User is signed in.
  } else {
    // No user is signed in.
  }
});

Firebase Auth REST API  |  Firebase

Firebaseのトークンは公式通りgetIdTokenで取得する。forceRefresh は付けといた方がいいのかな?

ID トークンを確認する  |  Firebase

firebase.auth().currentUser.getIdToken(/* forceRefresh */ true).then(function(idToken) {
  // Send token to your backend via HTTPS
  // ...
}).catch(function(error) {
  // Handle error
});


メールアドレスなどのユーザー情報を取得するエンドポイントは下記でfirebaseのAPI_KEYが必要。これは初期導入時にゲットしているはず。

https://www.googleapis.com/identitytoolkit/v3/relyingparty/getAccountInfo?key=[API_KEY]

このエンドポイントにサーバー側にAPI_KEYと、トークンを渡せば取得できそう。
有効期限はあるもののパスワードと同レベルの情報なので、HTTPSでPOSTで送らないとセキュリティ上まずいはず。


Python側(DjangoのView内)で、試しにVue側から送られてきたAPI_KEYとトークンを使って
ユーザー情報を取得する下記のような関数を作って動作確認

API_KEYはリクエストURLに含めるので正規表現でバリデーションか、エンコード等した方がよさげ。

import requests
import json


def get_userinfo(apikey, token):
    resp = requests.post('https://www.googleapis.com/identitytoolkit/v3/'
                                      'relyingparty/getAccountInfo'
                                      '?key={}'.format(apikey),
                                      params={'idToken': token})
    logger.debug("userinfo:{}".format(resp.text))
    return json.loads(resp.text)

logger.debugで下記のようなJSONで返ってくる

2018-05-17 12:24:07,080 [DEBUG] shanyang.views: userinfo:{
  "kind": "identitytoolkit#GetAccountInfoResponse",
  "users": [
    {
      "localId": "xxxxxxxxxxxxxxxxxxxxxxxxxx",
      "email": "xxxxxxxxxxxxx@example.co.jp",
      "displayName": "なまえ",
      "photoUrl": "https://lh3.googleusercontent.com/xxxxxxxxxxxxxxxxxxxxxxxxxxx/photo.jpg",
      "emailVerified": true,
      "providerUserInfo": [
        {
          "providerId": "google.com",
          "displayName": "なまえ",
          "photoUrl": "https://lh3.googleusercontent.com/xxxxxxxxxxxxxxxxxxxxxxxxxxx/photo.jpg",
          "federatedId": "000000000000000000",
          "email": "yu_yamazaki@bizocean.co.jp",
          "rawId": "xxxxxxxxxxxxx@example.co.jp"
        }
      ],
      "validSince": "1525917073",
      "lastLoginAt": "1526362431000",
      "createdAt": "1525917073000"
    }
  ]
}

上記の関数でjson.loadsまでやっているので、下記のような感じでemailを取得できるとこまで確認。
失敗したときの例外処理がもちろん必要になる。

user_info = get_userinfo(apikey, token)
user_email = user_info['users'][0]['email']

これでFirebaseで認証済みユーザーの情報をサーバー側で取得することができた。

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

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

2018/8/2 追記

Vue.jsを使ったPWA、yagish 履歴書リリースしました
rirekisho.yagish.jp
追記ここまで

クライアント側のコードはほぼそのまま流用し、機能も少なかったので、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で個人開発もやってみたいと思う。