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

## はじめに

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

## 作ったもの

- [https://喫煙所.net](https://xn--71rs60ajxl.net/)
    - 見たまんまですが、喫煙所の共有サービスです
    - 喫煙者の皆様は是非使ってみてくださいｗ

## 使ったもの

- Firebase
    - Hosting
        - Reactでbuildした静的ページの配信
    - Authentication
        - 非ログイン時は匿名認証
        - SNS認証使ってログイン時にグレードアップ
    - Cloud Firestore
        - 喫煙所情報の保存
        - いいね情報の保存
        - 公開用のユーザ名の保存
    - Cloud Functions for Firebase
        - Firestoreの更新をトリガーにAlgoliaのインデックス更新を実行
- [Algolia](https://www.algolia.com/)
    - 主に喫煙所の検索に利用
    - 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](https://material-ui.com/)
        - なうい感じのUIが作れます
- Bitbucket
    - Githubのプライベートリポジトリにお金払えない人向け
    - masterへのマージをフックしてCircleCI経由でビルド実行
- CircleCI
    - Bitbucketから自動デプロイを実行

### ※[Algolia](https://www.algolia.com/)とは？

- サイト内検索用の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の方から事前に渡しておきます](https://firebase.google.com/docs/functions/config-env?hl=ja)

```js: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でログイン/ログアウト機能を実装する](https://qiita.com/k5trismegistus/items/2b82c9721e5d245b54ca)の記事を参考に実装させて頂きました
- 以下にはaction creatorの部分だけ転記していますが、`componentDidMount`のタイミングで`watchAuthState`を発火させて、`loginByGoogle`とか`loginByFacebook`とかを実行する流れになります
- 基本的な動作は以下になります
    - 初回来訪時　→　`signInAnonymously` → 匿名認証
    - ログアウト時 → `signInAnonymously` → 匿名認証
    - Google/Facebook認証時 → `loginByProvider`　→ 匿名ユーザのアップグレード or 既存アカウントへのログイン
- 匿名認証をかけるタイミングは悩んだのですが、一旦、サイトにきたユーザには全員に匿名認証を行うような仕様にしています
    - ここらへんはノウハウがない領域なので一般的にはこう、みたいなのがあればどなたか教えてください
    - やたら匿名ユーザが増えちゃうのが気になるのと、下記の匿名認証①と②で別ユーザになっちゃうのが気にはなっています（匿名なのでそれが正しい説もありますが）
        - ①匿名認証→Google認証→ログアウト→②匿名認証

```js: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`をセットしています

```js: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 install`で`node-gyp`周りのエラーが出て苦しんだのですが、`--unsafe`というオプションをつければいい[らしい](https://github.com/nodejs/node-gyp/issues/1236#issuecomment-327668264)です

```yml:.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)

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

<img width="482" alt="ログイン_Google_アカウント.png" src="https://qiita-image-store.s3.amazonaws.com/0/279869/b8a3d24d-4ded-0779-8cbf-a9f257bc50c7.png">

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

### PWA的なものへの対応

- オフライン動作しないので厳密にはPWA(ProgressiveWebApp)ではないのですが、雛形を`create-react-app`で作っていたので`manifest.json`設定するだけで、ホームスクリーンに追加するとそれっぽく動きました
- ただ、残念ながらiOSだと以下のような既知の問題があって、Authenticationの認証の部分がうまく動かなかったです
    - [Google as an Auth provider doesn't work on iOS saved to homescreen](https://github.com/firebase/firebase-js-sdk/issues/77)


## さいごに

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

## 追記

- 続編書いたのでよければこちらもご覧ください！
    - [firebaseでサーバレスなSPAアプリを作った話②](https://qiita.com/g0e/items/75429eb805f7879a6fba)