Firebase
React
redux
algolia

firebaseでサーバレスなSPAアプリを作った話

はじめに

  • Firebase使ってみて便利さに感動したのでポイントまとめてみました
  • Reactとかフロントエンドのフレームワーク使ってSPA作る分には結構戦えますね
    • とはいえ最終的に細かい作り込みをすると辛いところは出てきそう…

作ったもの

  • https://喫煙所.net
    • 見たまんまですが、喫煙所の共有サービスです
    • 喫煙者の皆様は是非使ってみてくださいw

使ったもの

  • Firebase
    • Hosting
      • Reactでbuildした静的ページの配信
    • Authentication
      • 非ログイン時は匿名認証
      • SNS認証使ってログイン時にグレードアップ
    • Cloud Firestore
      • 喫煙所情報の保存
      • いいね情報の保存
      • 公開用のユーザ名の保存
    • Cloud Functions for Firebase
      • Firestoreの更新をトリガーにAlgoliaのインデックス更新を実行
  • Algolia
    • 主に喫煙所の検索に利用
    • Firestoreでは2018/08時点ではGeoSearchできなかったので
    • (詳細後述)
  • React
    • react-redux
      • (ry
    • redux-first-router
      • react-routerの仕様がしっくり来なかったの選択
      • ページ遷移とactionを連動できていい感じ
    • redux-thunk
      • API叩いてからactionをdispatchみたいな非同期なaction creatorに利用
    • redux-subscriber
      • 非同期のAuthenticationの結果を待ってからAPI叩くみたいな時の利用
    • material-ui
      • なうい感じのUIが作れます
  • Bitbucket
    • Githubのプライベートリポジトリにお金払えない人向け
    • masterへのマージをフックしてCircleCI経由でビルド実行
  • CircleCI
    • Bitbucketから自動デプロイを実行

Algoliaとは?

  • サイト内検索用のASP。イメージ的にはelasticsearchのASP版
  • 無料プラン(クレジット表記必須)がある
  • elasticsearchと比べた時の印象としては
    • プラグイン入れずにGUIからインデックスの設定変更・再構築ができるのは便利
    • ノードとかシャードとか下のレイヤーはよしなにやってくれるので意識する必要なさそう
    • 日本語の検索も特別な設定をしなくても普通に動くのはすごい
    • ただし、検索系の機能はelasticsearchと比べるとかなり貧弱
      • aggregation(facets)の機能はかなり貧弱で、ヒット数じゃなくていいね数の合計を計算したいとかは無理
      • GeoSearchの機能は今回のユースケース的には便利だったのですが、GeoSearchしたい場合はフィールド名を_geolocに固定する必要があるらしく、複数フィールドをよしなにGeoSearchするとかは現時点では無理そう
      • ソート方法を複数使いたい場合はindex replicaを作る必要がありそう(ソート=ランキングの設定は1indexに1つまでっぽい)

ポイント

【Firestore】 → 【CloudFuntions】 → 【Algolia】への同期

  • 以下のコードで自動的に下記の処理が実行されるようになります
    • firestoreのデータを更新 → algoliaのインデックスを更新
    • firestoreのデータを削除 → algoaliaのインデックスから削除
  • CloudFunctionsがGoogle外のNW(Algolia)と通信する必要があるので無料プランのままだと動かないはず
  • algoliaのAPI KEYはCLIの方から事前に渡しておきます
functions/index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

const algoliasearch = require('algoliasearch');
const algolia = algoliasearch(functions.config().algolia.app_id, functions.config().algolia.api_key);

const indexSpots = algolia.initIndex('spots');
exports.syncSpots = functions.firestore.document('/spots/{spotId}').onWrite(
  (change, context) => {
    const spotId = context.params.spotId;
    const spot = change.after.exists ? change.after.data() : null;
    if(!spot){
      indexSpots.deleteObject(spotId, (err) => {
        if (err) throw err;
        console.log(`Spot ${spotId} removed from algolia spots index`);
      });
    }else{
      spot['objectID'] = spotId;
      indexSpots.saveObject(spot, (err, content) => {
        if (err) throw err;
        console.log(`Spot ${spotId} updated in algolia spots index`);
      });
    }
    return 0;
  }
);
  • 開発途中でfirebase-functionsのバージョンを2.0.2に上げたところ、以下のようなエラーが出るようになったのですが、firebase-adminのバージョンを5.13.1に上げることで解消できました
TypeError: firestoreInstance.settings is not a function
    at beforeSnapshotConstructor (/user_code/node_modules/firebase-functions/lib/providers/firestore.js:136:27)
    at changeConstructor (/user_code/node_modules/firebase-functions/lib/providers/firestore.js:144:49)
    at cloudFunctionNewSignature (/user_code/node_modules/firebase-functions/lib/cloud-functions.js:108:28)
    at cloudFunction (/user_code/node_modules/firebase-functions/lib/cloud-functions.js:139:20)
    at /var/tmp/worker/worker.js:730:24
    at process._tickDomainCallback (internal/process/next_tick.js:135:7)
  • 最後のreturn 0;Function returned undefined, expected Promise or valueというエラーの対策で入れてみました

Firebase Authentication関連

  • React+Redux+Firebase Authenticationでログイン/ログアウト機能を実装するの記事を参考に実装させて頂きました
  • 以下にはaction creatorの部分だけ転記していますが、componentDidMountのタイミングでwatchAuthStateを発火させて、loginByGoogleとかloginByFacebookとかを実行する流れになります
  • 基本的な動作は以下になります
    • 初回来訪時 → signInAnonymously → 匿名認証
    • ログアウト時 → signInAnonymously → 匿名認証
    • Google/Facebook認証時 → loginByProvider → 匿名ユーザのアップグレード or 既存アカウントへのログイン
  • 匿名認証をかけるタイミングは悩んだのですが、一旦、サイトにきたユーザには全員に匿名認証を行うような仕様にしています
    • ここらへんはノウハウがない領域なので一般的にはこう、みたいなのがあればどなたか教えてください
    • やたら匿名ユーザが増えちゃうのが気になるのと、下記の匿名認証①と②で別ユーザになっちゃうのが気にはなっています(匿名なのでそれが正しい説もありますが)
      • ①匿名認証→Google認証→ログアウト→②匿名認証
actions/Auth.js
import firebase from 'firebase/app'
import { firebaseApp } from '../firebase'  // eslint-disable-line

export function watchAuthState() {
  return (dispatch) => {
    firebase.auth().onAuthStateChanged((user) => {
      if(!user) {
        // ログインしてなかったら匿名ユーザとしてログイン
        firebase.auth().signInAnonymously().catch(function(error) {
          /* エラーハンドリング */
        });
        return;
      }
      dispatch({ type: 'LOGIN_SUCCESS', payload: user });
    })
  }
}

export function loginByGoogle() {
  const provider = new firebase.auth.GoogleAuthProvider();
  return loginByProvider(provider);
}

export function loginByFacebook() {
  var provider = new firebase.auth.FacebookAuthProvider();
  return loginByProvider(provider);
}


function loginByProvider(provider) {
  return (dispatch) => {
    dispatch(startLoading('loginAuth'));
    // まずは匿名ユーザのアップグレードを試みる
    firebase.auth().currentUser.linkWithPopup(provider).then(function(result) {
      dispatch({ type: 'LOGIN_SUCCESS', payload: result.user });
      /* ログイン成功後の後続処理 */
    }).catch(function(error){
      if(error.code === 'auth/credential-already-in-use'){
        // 既にリンク済のアカウントがあればそっちでログインしなおす
        firebase.auth().signInAndRetrieveDataWithCredential(error.credential).then(function(result){
          dispatch({ type: 'LOGIN_SUCCESS', payload: result.user });
          // ログイン成功後の後続処理
        }).catch(function(error){
          /* エラーハンドリング */
        });
      }else if(error.code === 'auth/email-already-in-use'){
        // 認証されたemailが別アカウントに既に紐付ている場合
        /* エラーハンドリング */
      }else{
        /* エラーハンドリング */
      }
    });
  }
}

export function logout() {
  return (dispatch) => {
    firebase.auth().signOut().then(function() {
      /* ログアウトの通知 */
    });
  }
}
  • 認証処理は非同期で実行されるので、APIを叩きたいタイミングで認証が終わっていないというケースがあり、実装を悩んだのですがredux-subscriberというライブラリを使って以下のように実装しています
    • state.auth.userIdには上記のLOGIN_SUCCESSというactionを受け取ってreducer側でuserIdをセットしています
actions/AccountView.js
import { subscribe } from 'redux-subscriber';

export function pageAccountView(){
  return (dispatch, getState) => {
    const userId = getState().auth.userId;  // 認証完了前はuserIdにnullが入る
    if(userId){
      loadAccountInfo(userId, dispatch);  // この中でuserIdを使ってAPIを非同期実行
    }else{
      // まだuserIdが読み込み前の場合はそれを待ってから実行する
      const unsubscribe = subscribe('auth.userId', (state) => {
        const userId = state.auth.userId;
        if(userId){
          unsubscribe();
          loadAccountInfo(userId, dispatch);  // この中でuserIdを使ってAPIを非同期実行
        }
      });
    }
  }
}

【CircleCI】 → 【Firebase】の自動デプロイ

  • BitbucketからCircleCIをキックして自動的にdeployされるようにしてみました
  • npm installnode-gyp周りのエラーが出て苦しんだのですが、--unsafeというオプションをつければいいらしいです
.circleci/config.yml
version: 2
jobs:
  deploy:
    docker:
      - image: devillex/docker-firebase:latest
    working_directory: ~/workspace
    steps:
      - checkout
      - run:
          name: npm install
          command: npm install --unsafe
      - run:
          name: npm install for functions
          command: cd functions;npm install --unsafe;cd ..
      - run:
          name: build react
          command: npm run build
      - run:
          name: deploy firebase
          command: firebase deploy --token=$FIREBASE_DEPLOY_TOKEN

workflows:
  version: 2
  deploy:
    jobs:
      - deploy:
          filters:
            branches:
              only: master

Firebaseで日本語ドメイン(IDN)

  • 他のカスタムドメインの設定と同様で問題なく動きました
  • が…

ログイン_Google_アカウント.png

  • configauthDomainの設定を変えてGoogle認証使うと、Oauthで戻り先のドメインの表示が残念ながら日本語表記にならないようです…残念

PWA的なものへの対応

  • オフライン動作しないので厳密にはPWA(ProgressiveWebApp)ではないのですが、雛形をcreate-react-appで作っていたのでmanifest.json設定するだけで、ホームスクリーンに追加するとそれっぽく動きました
  • ただ、残念ながらiOSだと以下のような既知の問題があって、Authenticationの認証の部分がうまく動かなかったです

さいごに

  • Qiita初投稿でした
  • 要望等あれば詳細とか続編書こうと思いますので、お気軽にフィードバック頂ければ!

追記