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

## はじめに

- この記事は[firebaseでサーバレスなSPAアプリを作った話](https://qiita.com/g0e/items/d911effa49fdac5eada2)の続編になっています
- 前の記事がQiita初投稿だったのですが、思いのほか「いいね」をたくさん頂いてトレンド入りしたので調子にのって続編を書いてみます
- 今回の内容は**本番・開発環境の分離**とか**SEO対策**とかリリース後に気になって修正したポイントを中心にまとめてみようと思います

## 作ったもの(再掲)

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

## 使ったもの(再掲+加筆)

- ***※前回からの加筆部分を強調しています***
- Firebase
    - Hosting
        - Reactでbuildした静的ページの配信
    - Authentication
        - 非ログイン時は匿名認証
        - SNS認証使ってログイン時にグレードアップ
    - Cloud Firestore
        - 喫煙所情報の保存
        - いいね情報の保存
        - 公開用のユーザ名の保存
    - Cloud Functions for Firebase
        - Firestoreの更新をトリガーにAlgoliaのインデックス更新を実行
        - ***SEO対策としてタイトルとかメタデータを動的挿入***
        - ***サイトマップの動的生成***
- [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が作れます
    - ***babel-polyfill***
        - ***GooglebotのJSが古い仕様らしく、ちゃんとクロールしてもらうために追加***
    - ***react-ga***
        - ***GoogleAnalyticsによる計測で利用***
    - ***react-firebase-file-uploader***
        - ***写真のfirebaseへのアップロード機能で利用***
- Bitbucket
    - Githubのプライベートリポジトリにお金払えない人向け
    - masterへのマージをフックしてCircleCI経由でビルド実行
- CircleCI
    - Bitbucketから自動デプロイを実行


## 開発環境と本番環境の分離

### 設計方針

- 以下の方針で設計しています
    - 本番環境への投入前に本番環境と同等の環境で動作確認をしたい
    - 開発環境を本番環境と完全に切り離したい
        - 開発時に本番用のFirestoreにデータを投げたくない
        - Cloud Functionsはアップロードしてみないと十分動作確認できないので、開発環境以外で動かせるようにしておきたい
- 環境の分離では、`Firebaseのプロジェクトで分離(下表横軸)`、`ビルド時の環境設定で分離(下表縦軸)`の2つの要素を使っています
    - ビルド時の環境設定についてですが、create-react-appをejectせずに使っていたので、[create-react-appを使用時の、Firebaseのdevelopment/test/production環境切り替え
](https://qiita.com/babie/items/a90f479298fb3997549c)を参考にさせて頂きました
    - Firebaseのプロジェクトで分離では、単純に本番環境用以外のプロジェクトを新しく作成してcliコマンドから叩いて切り替えています

|                  | 本番用プロジェクト     | 開発用プロジェクト      |
|:-----------------|:------------------|:-------------------|
| NODE_ENV=production | 本番環境 | ステージング環境 |
| NODE_ENV=development| (未利用) | 開発環境 |

### 実装方法

- 実際に切り替えが必要なのは下記の2点でした
    - JSファイル内の設定情報をビルド時に切り替え
    - `firebase deploy`を実行する時のCurrent Projectの切り替え

#### ビルド時の設定切り替え

- ビルド時の設定切り替えは`process.env.NODE_ENV`と外部から環境変数で引き渡すプロジェクトIDによって切り替えています
    - ビルド時に外部から環境変数で渡す方法については[Create-react-app environments
](https://medium.com/@tacomanator/environments-with-create-react-app-7b645312c09d)を参考に実装しています

```js:firebase/config.js
export const firebaseConfig = ((nodeEnv, projectId) => {
  if(nodeEnv === 'production' && projectId === '{本番用プロジェクトID}'){
    return {
      apiKey: "...",
      authDomain: "{本番用プロジェクトID}.firebaseapp.com",
      databaseURL: "https://{本番用プロジェクトID}.firebaseio.com",
      projectId: "{本番用プロジェクトID}",
      storageBucket: "{本番用プロジェクトID}.appspot.com",
      messagingSenderId: "..."
    }
  }else{
    return {
      apiKey: "...",
      authDomain: "{開発用プロジェクトID}.firebaseapp.com",
      databaseURL: "https://{開発用プロジェクトID}.firebaseio.com",
      projectId: "{開発用プロジェクトID}",
      storageBucket: "{開発用プロジェクトID}.appspot.com",
      messagingSenderId: "..."
    }
  }
})(process.env.NODE_ENV, process.env.REACT_APP_PROJECT_ID);
```

#### CircleCIからのデプロイ

- `deploy-xxx`のジョブ定義の重複感が非常にイケていないので恥ずかしいですが、使っているコードを下記に貼っています
- ポイントは下記の2点です
    - 前述のビルド内容の切り替えのために`REACT_APP_PROJECT_ID`という環境変数にプロジェクトIDを引き渡す
    - `firebase deploy`の前に`firebase use {プロジェクトID}`でデプロイ先のプロジェクトをスイッチする
        - 事前に開発用プロジェクトもfirebase cliから追加しておく必要があります

```yml:.circleci/config.yml
version: 2
jobs:
  deploy-production:
    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: REACT_APP_PROJECT_ID={本番用プロジェクトID} npm run build
      - run:
          name: copy index.html for functions
          command: cp build/index.html functions/index.html
      - run:
          name: select project
          command: firebase use {本番用プロジェクトID} --token=$FIREBASE_DEPLOY_TOKEN
      - run:
          name: deploy firebase
          command: firebase deploy --token=$FIREBASE_DEPLOY_TOKEN
  deploy-staging:
    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: REACT_APP_PROJECT_ID={開発用プロジェクトID} npm run build
      - run:
          name: copy index.html for functions
          command: cp build/index.html functions/index.html
      - run:
          name: select project
          command: firebase use {開発用プロジェクトID} --token=$FIREBASE_DEPLOY_TOKEN
      - run:
          name: deploy firebase
          command: firebase deploy --token=$FIREBASE_DEPLOY_TOKEN_DEV


workflows:
  version: 2
  deploy:
    jobs:
      - deploy-production:
          filters:
            branches:
              only: master
      - deploy-staging:
          filters:
            branches:
              only: develop
```

### 所感

- 開発用プロジェクトを別途して初期設定をするのはやや手間ですが、初期設定さえしてしまえば、`firebase deploy`で必要な設定は反映できるのですごく楽ですね
- ここでは書いていませんが、Algoliaも本番環境と開発環境とを以下の方法で分離しています
    - JS側は上述の方法でビルド時に参照するAPPを切り替え
    - Cloud Functionsを使ったFirestoreからの同期は、プロジェクト毎に環境変数(=参照先APP)を切り替え

## Googlebotの動作確認と対応

### Fetch as Googleで動作確認

- まずGooglebotがSPAのページを正しく実行できているかを確認します
- Googleの[Search Console](https://www.google.com/webmasters/tools/home?hl=ja)から「プロパティを追加」を選択してサイトを追加します
    - 詳細は省略しますが、サイト所有権の確認作業が必要になります
- 追加したサイトを選択して、画面左側のメニューから「クロール」 > 「Fetch as Google」を選択します
- 動作確認をしたいページのURLを入力して「取得してレンダリング」を実行します

<img width="850" alt="search_console.png" src="https://qiita-image-store.s3.amazonaws.com/0/279869/e0e037e0-736f-6ded-9207-87a2207636d5.png">

- しばらく待っているとステータスが「完了」か「一部」となるので、選択すると以下のようなページで、Googlebotが認識しているページが確認できます
- このページの結果を見ることでGooglebotがJSを想定通り実行できているかを確認することができそうです

<img width="481" alt="fetch_as_google.png" src="https://qiita-image-store.s3.amazonaws.com/0/279869/2253f9b5-4d33-e0b0-8a0f-8c4282c5ffc7.png">

### babel-polyfillを追加

- もしJSの実行に失敗しているようであれば、[React Fetch as Google した結果が 空ページになる](https://www.monotalk.xyz/blog/react-fetch-as-google-%E3%81%97%E3%81%9F%E7%B5%90%E6%9E%9C%E3%81%8C-%E7%A9%BA%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AB%E3%81%AA%E3%82%8B/)に記載の通り`babel-polyfill`を追加してみると良いかと思います

## Cloud FunctionsでSEO用のタイトル・メタタグ挿入

### SPAでのSEO的な課題

- Googlebotの動作確認まで済ませて安心していたのですが…、***Googleで検索した時の結果が以下のようにまったく同じ見た目になってしまっていました***
- SPAなのでtitleタグやmetaタグのdescriptionがどのページでもまったく同じなので、仕方ないかもなのですが、流石にお粗末な感じなので対応することにしました

<img width="648" alt="SEO的にダメな例.png" src="https://qiita-image-store.s3.amazonaws.com/0/279869/d257d372-3967-f701-1d04-f234ca7f08e8.png">

### Cloud Functionsでindex.htmlを修正する

- 上記の課題に対応するためSEO的に重要なページ（今回のケースでは登録されている喫煙所のページ）については、以下の対応を入れてみました
    1. Cloud Functionsでリクエストを受けるように設定変更
    2. Cloud Functions内でリクエスト中のURLから喫煙所ID(spotId)を取り出してFirestoreから対応する喫煙所のデータを取得
    3. 生成されているindex.htmlのtitleタグやmetaタグをで書き換えてレスポンスとして返却

#### ルーティングの設定

- SEO対策したいページのURLが`spot/{喫煙所ID}`なので、以下のようにして`pageSpotView`というFunctionを向くようにします
    - sitemapについては後述するのですが、コードはまとめてここで記載しておきます

```js:firebase.json
{
   ...
  "hosting": {
     ...
    "rewrites": [
      {
        "source": "spot/*",
        "function": "pageSpotView"
      },
      {
        "source": "sitemap.txt",
        "function": "sitemap"
      },
      {
        "source": "**",
        "destination": "/index.html"
      }
    ]
  },
   ...
}
```

#### CloudFunctionsの設定

- 事前にFirebase Hostingで配信しているindex.htmlを、Cloud Functions用のディレクトリ(`functions/`)にコピーしておいてそれをCloud Functionsの中で読み取っています
    - 上述のCircleCIの設定中にも出てきますが、`firebase deploy`の前にセットで実行するようにしています
- HTML中の要素の書き換えは最初はちゃんとparseしようとしたのですが、処理コストかさみそうだったので正規表現で置換しています

```js:functions/index.js
const fs = require('fs');
const path = require('path');
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

exports.pageSpotView = functions.https.onRequest((req, res) => {
  fs.readFile(path.join(__dirname, 'index.html'), 'utf8', (e, html) => {
    // req.path = '/spot/:spotId'
    const spotId = req.path.split("/")[2];
    const firestore = admin.firestore();
    // https://stackoverflow.com/questions/51441263/admin-sdk-cannot-set-settings-for-firestore
    try{
      firestore.settings({timestampsInSnapshots: true});
    }catch(error){
      // ignore
    }
    firestore.doc(`/spots/${spotId}/`).get().then((spotDoc) => {
      const spot = spotDoc.exists? spotDoc.data() : null;
      if(spot){
        html = html.replace(
          html.match(/<title>.*<\/title>/),
          `<title>喫煙所.net - ${spot.name}</title>`
        );
        html = html.replace(
          html.match(/<meta name="description"[^>]*>/),
          `<meta name="description" content="「${spot.name} ${spot.address}」にある喫煙所の情報 ${spot.description} ${spot.tags.join(',')}">`
        );
        res.set('Cache-Control', 'public, max-age=3600, s-maxage=3600');
      }
      res.status(200).send(html);
      return spot;
    }).catch((e) => {
      res.status(200).send(html);
      throw e;
    });
  });
  return 0;
});
```

#### 対応結果

- ↓みたいな感じで喫煙所名がヒットするようになりました

<img width="626" alt="SEO的にOK例.png" src="https://qiita-image-store.s3.amazonaws.com/0/279869/3e353226-9ba2-f60b-1d7f-1f01cfac5e10.png">

#### 所感

- Cloud Functionsがしばらく利用されていないとsleepのような感じになるのもあって、レスポンスに秒単位でかかっているようなので、ユーザの初期離脱がかなり気になるところではあります
- この機能実装し終わって別の調べ物をしていた時にたまたま見つけたのですが、ほぼ同じ実装をされている方がいました
    - [FirebaseでSPAするときのSEO/OGP対応もうこれでいいんじゃないですか2018暫定版](https://speakerdeck.com/tomomichi/ogpdui-ying-moukoreteiinsiyanaitesuka2018zan-ding-ban)

## Cloud Functionsでsitemapを自動生成

- 上記のタイトル・メタタグの動的挿入の話とほぼ同じ内容になっちゃうのですが、ちゃんと検索エンジンにクロールしてもらうようにCloud Functions側で生成するようにしてみました
- 件数が増えてくると都度全喫煙所の情報を読みに行くとコストが気になるな、と思っていたのですが、[CDNにキャッシュできる](https://firebase.google.com/docs/hosting/functions?hl=ja#manage_cache_behavior)らしいのでこの設定入れています
- 最初は`sitemap.xml`にしようかと思ったのですが、テキスト形式(改行区切りURL)の`sitemap.txt`でも問題無いようだったので、負荷的にも軽いテキスト形式を採用しています
- 余談ですが、日本語ドメインのままテキスト出力するか、Punycode変換した後のものを出力するか悩んだのですが、後者の方が良さそうな気がします（GoogleのSearchConsoleで正しいsitemapとして認識されたので）


```js:functions/index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

exports.sitemap = functions.https.onRequest((req, res) => {
  res.set('Content-Type', 'text/plain');
  let txt = `https://xn--71rs60ajxl.net/\n`;
  const firestore = admin.firestore();
  // https://stackoverflow.com/questions/51441263/admin-sdk-cannot-set-settings-for-firestore
  try{
    firestore.settings({timestampsInSnapshots: true});
  }catch(error){
    // ignore
  }
  const MAX_SITEMAP_SITE = 1000;
  firestore.collection("spots").orderBy('updatedAt', 'desc').limit(MAX_SITEMAP_SITE).get().then((spotsQuery) => {
    const spotIds = spotsQuery.docs.map((spotDoc) => {
      return spotDoc.id;
    });
    spotIds.forEach((spotId) => {
      txt += `https://xn--71rs60ajxl.net/spot/${spotId}\n`;
    });
    res.set('Cache-Control', 'public, max-age=86400, s-maxage=86400');
    res.status(200).send(txt);
    return 0;
  }).catch((e) => {
    res.status(200).send(txt);
    throw e;
  });
});
```

## GoogleAnalyticsを追加

- アクセス解析の定番ツールであるGoogleAnalyticsを[react-ga](https://github.com/react-ga/react-ga)を使って追加しました
- 使い方的には、下記のようにトラッキングIDをセットして

```js:common/Analytics.js
import ReactGA from 'react-ga';
import { GA_TRACKING_ID } from './../common/Settings';

ReactGA.initialize(GA_TRACKING_ID);
export default ReactGA;
```

- pageviewをJSが読み込まれたタイミングと、ページ遷移時に発火させるために以下のように設定しています

```js
import ReactGA from './common/Analytics';

const history = createHistory()

ReactGA.pageview(window.location.pathname + window.location.search);
history.listen((location, action) => {
  ReactGA.pageview(location.pathname + location.search);
});
```

- また、ページ遷移以外に喫煙所の登録や更新、アカウント作成のタイミングで、下記のように追加でイベント情報も送るようにしています
    - ここのあたりはまだどう活用するか未定ですが…

```js
import ReactGA from './../common/Analytics';

// ユーザの行動に応じて下記のようにイベント情報を送信
ReactGA.event({category: 'Spot', action: 'Create'});
```

## さいごに

- 最後まで読んで頂いてありがとうございます！
- そろそろネタ切れなので続編は厳しいかもですが、Qiitaからサービスへの流入がかなりあったので、そこらへんの数値的なインパクトを気が向いたらまとめてみたいな、とは思っています
    - 感覚としてはトレンド(1日)からの流入はかなりありそうですが、トレンド(週間)の方だけになった途端がくっと落ちそうですね
- あとReactNativeにも挑戦中なので、こちらもリリースできたら(Webの人間なのでかなりハードル高そうですが…)いつかまとめたいと思います。