Angular + Firebaseで作ったWEBサービスをリリースしたので、たまった知見を書き出してみる

# はじめに

以前からちょこちょこ新しいWEBサービスを作っているということをQiitaの記事に掲載していましたが、このたび[SCORERA（verβ）」を正式にリリースしました。（2019年3月にサービス終了）

サービス構築、リリースにあたって色々と知見がたまったので、作成過程で詰まったことや、リリースにかかる作業のうち技術的な側面のものを中心に公開しておこうと思います。

# 目次

**前提**

- [どんなサービス？](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#%E3%81%A9%E3%82%93%E3%81%AA%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9)
- [使用している技術](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E6%8A%80%E8%A1%93%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9)

**トピック**

- [SCORERAのディレクトリとモジュール構成](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#scorera%E3%81%AE%E3%83%87%E3%82%A3%E3%83%AC%E3%82%AF%E3%83%88%E3%83%AA%E3%81%A8%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E6%A7%8B%E6%88%90)
- [RxJSの状態管理](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#rxjs%E3%81%AE%E7%8A%B6%E6%85%8B%E7%AE%A1%E7%90%86)
- [外部ライブラリの選定](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#%E5%A4%96%E9%83%A8%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%81%AE%E9%81%B8%E5%AE%9A)
- [FirebaseのRealtime DatabaseとFirestore](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#firebase%E3%81%AErealtime-database%E3%81%A8firestore)
- [クライアントサイドのエラー取得](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E3%82%B5%E3%82%A4%E3%83%89%E3%81%AE%E3%82%A8%E3%83%A9%E3%83%BC%E5%8F%96%E5%BE%97)
- [AngularプロジェクトのSEO](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#angular%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AEseo)
- [技術面以外でやったこと、やること](https://qiita.com/Yamamoto0525/items/5a0459712deada850b85#%E6%8A%80%E8%A1%93%E9%9D%A2%E4%BB%A5%E5%A4%96%E3%81%A7%E3%82%84%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8%E3%82%84%E3%82%8B%E3%81%93%E3%81%A8)

# 前提

## どんなサービス？

「SCORERA（verβ）」はスポーツ競技や文化系競技（将棋、ゲームなど）といった大会の集客、エントリ管理、結果管理を行うことのできるサービスです。

これまで各競技の大会で、集客だけを行うサービス、結果の管理だけを行うサービスはありましたが、大会全般をWEB上で管理しようとするサービスはほとんどありませんでした。

SCORERAは「大会運営を簡単に、より良いものに」することをコンセプトに、大会運営のプラットフォームとなることを目指していきます。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="und" dir="ltr"><a href="https://t.co/6FcaEQvHsH">https://t.co/6FcaEQvHsH</a></p>&mdash; SCORERA (@SCORERA_TW) <a href="https://twitter.com/SCORERA_TW/status/989317355711836160?ref_src=twsrc%5Etfw">2018年4月26日</a></blockquote>


## 使用している技術、サービス

**サーバーサイド**

- Firebase Authentication（ユーザー管理）
- Cloud Firestore（データベース管理）
- Cloud Functions for Firebase（バックエンド処理）
- Cloud Storage for Firebase（ストレージ）
- Firebase Hosting（ホスティング）
- SendGrid（メール配信）

**クライアントサイド**

- Angular5（JSフレームワーク）
- Angularfire2（AngularとFirebaseのミドルウェア）
- Angular Service Worker（Service Worker管理）
- Angular2 materialize（デザインフレームワーク）
- Angulartics2（タグマネージャー管理）
- ngx-translate（多言語対応）
- Sentry（クラッシュ管理）

# トピック

## SCORERAのディレクトリとモジュール構成

SCORERAはAngularとFirebaseで作成していますが、この組み合わせでどのような構成がベストプラクティスなのか、といった情報は今のところあまり公開されていないので、かなり試行錯誤しながら設計しています。

2018年4月リリース時点で、SCORERAの大枠のディレクトリ構成は下記のようになっています。

```
プロジェクト
├ e2e
├ functions // Cloud Functions関連
│  ├　src // オリジナルの構成部分
│  │　 ├ api
│  │　 ├ db
│  │　 ├ storage
│  │　 ├ index.ts
│  │　 └ util.ts
│  ├　environment.ts
│  ├　package.json
│  └　tsconfig.json
├ src
│  ├ app // オリジナルの構成部分
│  │　 ├ account
│  │　 ├ competition
│  │　 ├ contact
│  │　 ├ core
│  │　 ├ dashboard
│  │　 ├ favorite
│  │　 ├ note
│  │　 ├ search
│  │　 ├ shared
│  │　 ├ site
│  │　 ├ sponsor
│  │　 ├ animations.ts
│  │　 ├ app-routing.module.ts
│  │　 ├ app.component.ts
│  │　 └ app.module.ts
│  ├ assets
│  ├ environments
│  ├ favicon.ico
│  ├ index.html
│  ├ main.ts
│  ├ manifest.json
│  ├ ngsw-config.json
│  ├ polyfills.ts
│  ├ test.ts
│  ├ tsconfig.app.json
│  ├ tsconfig.spec.json
│  └ typings.d.ts
├ .angular-cli.json
├ .editorconfig
/// 省略
├ tsconfig.json
└ tslint.json
```
### 基本はangular-cliとfirebase-toolの構成

SCORERAのディレクトリ構成は、angular-cliとfirebase-toolで自動作成されたものをベースにしています。
グローバルに上記ライブラリを入れた状態で下記コマンドを実行すると、「オリジナルの構成部分」と書かれたところ以外は大体同じ[^1]になるはずです。

```bash
// angular-cli
ng new project-name --service-worker

// firebase-tool
firebase init
```

### 機能モジュールを読み込む単位は、ユーザー体験（UX）に合わせて

[Angularの公式ガイド](https://angular.io/guide/styleguide)では、モジュールを**コアモジュール**、**共有モジュール**、**機能モジュール**の3種類に分類しています。

SCORERAではこのガイドにならってモジュールを配置し、「アカウント管理」「大会管理」といった単位で機能を切り出し、それぞれを`AccountModule`、`CompetitionModule`のような形で配置しています。

ただここで考慮しないといけないのが、Angularは基本的にモジュール単位でロードを行う[^2]ので、どこまでを初回ロードの対象とするか、といった視点からのモジュール構成です。

SCORERAは当初、ページ単位でモジュールの配置を考えたのですが、それだと必ずしもユーザーにとって得にならないと考え直し、**ユーザー体験（UX）**と**離脱シーン**をベースにしてモジュールを配置しました。

まず、ユーザーの属性とその目的を下記のように書き出しました。

- サイト訪問者（非会員）：サイトのことを知りたい
- サイト訪問者（非会員）：サイトのことを問合せたい
- 大会運営者（会員）：サイトの機能をフルに利用したい
- 大会参加者（非会員）：自分の参加する大会のみを見たい
- etc.

書き出しが終わったら、次はその優先度を考えます。
上記の場合で一番重視すべきは「大会参加者（非会員）」です。初めてサイトを訪問したときにある程度サイトの印象が決まり、サイトからの離脱へ繋がってしまうため、まずここを中心に設計を考えました。

「自分の参加する大会」、つまり`CompetitionModule`を初回ロード用の**上位モジュール**として組み込み、さらに下階層で「大会運営者（会員）」しか使わない機能（編集や管理画面など）は**下位モジュール**として切り出して初回ロードの対象から外しました。

## RxJSの状態管理

ここでいう状態管理とは、ReduxやFluxに代表されるデータアーキテクチャのことです。
Angularだと**ngrx**という公式ライブラリを使えばReduxのような状態管理をすることができるのですが、SCORERA設計時にはまだそのライブラリは公式となっていなかったので、Angularに標準で搭載されていたRxJSのみで設計を行いました。

RxJSを使えばWEBアプリ上で自由にデータの受け渡しを行うことができるのですが、逆に自由すぎて、**何も考えずに使っていると設計が破たんします**。

### `unsubscribe`を忘れずに！

RxJSはSubjectの作成から`unsubscribe`までを必ずセットで考える必要があります。
というのも、`unsubscribe`せずに他のコンポーネントにいってそのままにしておくと、そのストリームはその後もデータを受け続け、メモリを圧迫していきます。

もしそのような設計になっている場合は、ページ遷移を繰り返しているうちにパフォーマンスが飛躍的に落ちていくので注意してください。

なので、SCORERAでは次のようにしています。

```ts
  private subject: BehaviorSubject<string>;
  public state$: Observable<string>;

  // コンポーネント読み込み時
  ngOnInit(): void {
    this.subject= new BehaviorSubject<string>('data')
    this.state$ = this.subject.asObservable();
    this.Subscription = this.state$
      .subscribe((str: string) => {
        // 処理 
    });
  }

  // 離脱時
  ngOnDestroy(): void {
    if (this.Subscription) {
      this.Subscription.unsubscribe();
      this.Subscription = null;
    }
  })
```

コンポーネント読み込み時（ngOnInit）にBehaviorSubject（Subjectの拡張）を読み込んだときは、そのコンポーネント離脱時（ngOnDestroy）に`unsubscribe`を行っています。

### 状態管理は上位モジュール単位で

SCORERAは設計当初、モジュール単位でサーバーサイドであるFirestoreと接続していたのですが、その設計だと同じFirestoreのcollectionにたいして複数の接続を行ってしまうため、パフォーマンス低下につながっていました。

そこでFirestoreとの接続を上述した**上位モジュールだけに絞り込み**、それよりも下位のモジュールに対しては、上位モジュールからRxJSで流し込むという設計に修正しました。

![ストア設計.png](https://qiita-image-store.s3.amazonaws.com/0/112566/007430c8-2f8d-b0ca-8d32-9ec216bfe907.png)

これによりFirestoreとの接続回数が激減してパフォーマンスが向上したのですが、結局上位のストアから下位のコンポーネントにデータを流し込むというReduxの思想と変わらない設計になりました。

## 外部ライブラリの選定

SCORERAでは外部ライブラリを使用するにあたって、次のようなことに注意しました。

- 原則、公式のライブラリを使う
- 公式でない場合
    - 頻繁に更新されているライブラリを使う
    - 設計思想にあったライブラリを使う
    - こちらでオーバーラップできるくらいの小さなライブラリを使う

以前参加したプロジェクトでReactを扱ったときに、ライブラリ選定でかなり苦しめられたので、今回のプロジェクトではできるだけ安定したライブラリを使おうと思っていました。

ただ結局のところどのライブラリも不具合は起こるのですが、サポートがあるかないかで対応が大きく変わってくるので、そのライブラリが少しでも信頼できることが重要です。

### Angular2 materialize

SCORERAは基本的にメジャーなライブラリばかり使用していますが、その中で**Angular2 materialize**だけはちょっとマイナーなライブラリになっています。

このライブラリはデザインフレームワークである「Materialize」をAngular用にラップしたもので、Googleのマテリアルデザインというデザイン思想をベースに設計されています。

SCORERA設計当初から「**マテリアルデザインで作りたい！**」という気持ちが強かったのですが、マテリアルデザインの公式ライブラリである「Angular Material」はまだほとんど実装が進んでおらず、選択肢としては難しいものでした。

そこでその時頻繁に更新のあった**Angular2 materialize**を採用し、順調に実装を進めていたのですが、大元であった「Materialize」の新バージョンが出るとともに更新が途絶え、いくつかの不具合は放置されたまま音沙汰がなくなるという事態になりました。

今は不具合があった場合は自分でオーバーラップして対応していますが、個人で作成してもらっているライブラリの利用には常にこういったリスクがあるんだと肝に銘じています。

## FirebaseのRealtime DatabaseとFirestore

SCORERAを作り始めた当初、FiresbaseのRealtime Database（RTDB）を利用していました。
RTDBはデータ変更があると接続している端末すべてに即データを送ってくれるという優れものなのですが、接続したjsonツリーが更新されるとその配下のjsonツリーのデータがすべて送られてくるという仕様になっています。

RTDBには配下のjsonツリーと親子関係といったような形をとることができず、Relational Database（RDB）のようなリレーションを組みたい場合は全てのjsonツリーに関連するjsonツリーのパスを持たせなければ実現できません。

また、RTDBはクエリが１つしか設定できないので、検索のような機能を実装したい場合は１クエリだけ絞って残りのデータをすべてダウンロードし、それ以降はクライアント側で絞り込みを行う必要がありました。

RTDBはダウンロード量によって課金額が変わるので、大量のデータから絞り込みを行うようなアプリケーションには不向きといえます。

### Collection単位で接続ができるFirestore

そんなことを思っていた矢先に登場したのが、Firestore（ベータ版）です。
Firestoreには**Collection**という概念があり、第二階層以降に親子関係を構築することができるようになりました。

![RTDBとFirestore (1).png](https://qiita-image-store.s3.amazonaws.com/0/112566/6c443bbf-26d2-c6a6-44e9-539af16f0623.png)


これによって各jsonツリーごとに保有しなければなからかった他のjsonツリーへのリレーションパスを設定する必要がなくなり、データ追加毎に行っていた1対1リレーション接続のための更新をする必要がなくなりました。

また、クエリも複数指定することが可能になり、必要な条件で絞り込んだリクエストを送ることが可能になっています。

このほか、Firestoreに切り替えることで上記に挙げたRTDBの課題は解決されることになりましたが、特にRTDBでのリレーション処理は鬼門だったのでこれがある程度解決できた[^3]のは大きいです。

その他の比較は[公式ガイド](https://firebase.google.com/docs/firestore/rtdb-vs-firestore?hl=ja)に記載されていますので、そちらを参照してください。

## クライアントサイドのエラー取得

2017年に投稿された[この記事](https://qiita.com/norih/items/42182500630417559a25)にも記載されていましたが、SPA構築にあたってクライアントサイドのエラー情報を管理することは非常に重要だと思っています。

その記事では自前でエラーを収集する方法が記載されていましたが、私は「[Sentry](https://sentry.io/for/javascript/)」というサービスを利用することにしました。

### AngularにSentryを実装する

AngularでSentryを実装することはそれほど難しくなく、プロジェクト配下で下記コマンドを実行し、アカウントを作成後に発行されるAPIキーをモジュールに読み込ませるだけで使用できるようになります。

```bash
npm install raven-js --save
```

```ts
import Raven = require('raven-js');
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, ErrorHandler } from '@angular/core';
import { AppComponent } from './app.component';

Raven
  .config('https://<key>@sentry.io/<project>')
  .install();

export class RavenErrorHandler implements ErrorHandler {
  handleError(err:any) : void {
    Raven.captureException(err.originalError || err);
  }
}

@NgModule({
  imports: [ BrowserModule ],
  declarations: [ AppComponent ],
  bootstrap: [ AppComponent ],
  providers: [ { provide: ErrorHandler, useClass: RavenErrorHandler } ]
})
export class AppModule { }
```

このサービスを利用すると、クラッシュした端末の情報、エラーの内容、発生日時、同一エラーの発生回数といった情報を取得することができるようになります。

また、WEBアプリ側のリリースバージョンを仕込むこともできるので、どのバージョンのエラーかを特定することも可能です。

![scorera.png](https://qiita-image-store.s3.amazonaws.com/0/112566/09e65832-df29-4b09-1f52-66841307da84.png)
エラー管理画面

## AngularプロジェクトのSEO

昔からSPAのWEBアプリケーションはSEOが苦手でした。
botがJavaScriptを読み込んでくれず、内容のないindex.htmlの結果でインデックスされてしまうというのが常でしたので、今回もきっとそうだろうと思ってSEOを考えていました。

で、実際のところSearch ConsoleのFetch as Googleでレンダリングしてみた結果がこちらです。

![Search Console   Fetch as Google   https   app scorera net .png](https://qiita-image-store.s3.amazonaws.com/0/112566/9b88f182-a0b4-3d66-1d63-803b522a4eda.png)

あれ、もしかして**レンダリングされてる？**

今回、Angularのプロジェクトとしてやれること以外は特に何もしていなかったのですが、なんとJavaScript実行後の画面をbotが認識してくれるということがわかりました。

### polyfillを使って表示を直す

レンダリングされていることに浮かれていたのも束の間、よくよくレンダリング後画面を見てみると、文字情報がなにやらおかしい。
どうもDOMの非同期レンダリングにかかる部分がうまく表示されていないようです。

インデックスはされていたので公開当初はそのままでいいかと思っていたのですが、スパムのような状態でインデックスされてしまったため、検索順位は日が経つにつれてどんどん下がっていきました。
中途半端なレンダリングをインデックスされてしまうのは、SEO的にはむしろ逆効果になっていたようです。

そこでサーバサイドレンダリング（SSR）を含む対応策を色々と考えたのですが、それらによる工数を考えるとなかなか実行に踏み切れませんでした。

色々模索しているうちに[この記事](https://www.suzukikenichi.com/blog/googlebot-uses-a-web-rendering-service-that-is-based-on-chrome-41/)と出会い、googlebotのレンダリング機能はchromeの41と同程度のスペック[^4]だということがわかりました。
つまり、IEに対して行うような**レガシーブラウザ対応を行えば、きちんと表示してくれる**ということです。

angular-cliで作成したプロジェクトには、最初からIE向けのpolyfillがコメントアウトされているので、それらを有効化しました。

```ts:src/polyfills.ts
/** IE9, IE10 and IE11 requires all of the following polyfills. **/
import 'core-js/es6/symbol';
import 'core-js/es6/object';
import 'core-js/es6/function';
import 'core-js/es6/parse-int';
import 'core-js/es6/parse-float';
import 'core-js/es6/number';
import 'core-js/es6/math';
import 'core-js/es6/string';
import 'core-js/es6/date';
import 'core-js/es6/array';
import 'core-js/es6/regexp';
import 'core-js/es6/map';
import 'core-js/es6/weak-map';
import 'core-js/es6/set';
```

その後、再度Fetch as Googleで検証してみた結果がこちらです。

![Search Console   Fetch as Google   https   app scorera net .png](https://qiita-image-store.s3.amazonaws.com/0/112566/6c71156a-9a6a-e776-de59-d4abcc72d28a.png)

**問題なくレンダリングされるようになりました。**

『これでSSRも必要ないな』と思ったのですが、結局JavaScriptを実行してくれるのはgooglebotだけなので、[この記事](http://blog.notsobad.jp/entry/2018/04/03/235004)を参考にtitleとmetaタグだけはサーバーサイドで差し替えるようにしました。

### sitemap.xmlの対応

公開されている大会をインデックスしていきたかったので、SCORERAではsitemap.xmlを動的に作成することにしました。

[スプレッドシートでsitemapを作成](http://blog.notsobad.jp/entry/2018/04/03/235004)するという新たな選択肢もありましたが、ページの順位づけも行いたかったので、伝統的なsitemap.xmlの作成で対応することにしました。

Cloud Functionsでsitemap.jsというライブラリを読み込み、動的にsitemap.xmlが作成される環境を作ります。

`firebase.json`のrewritesに新しいsourceを追加し、Cloud Functionsとhostingを連携させます。

```json:firebase.json
    "rewrites": [
      {
        "source": "/sitemap.xml",
        "function": "returnSiteMap"
      },
```

次にfunctionsで`npm install sitemap --save`を実行して、sitemap.jsをインストールします。
あとはhttpsトリガーでfunctinsを起動させ、sitemap.xmlを作成します。

```ts
exports.returnSiteMap =
  functions.https.onRequest((req, res) => {

  const sitemap = sm.createSitemap({
    hostname: 'https://app.scorera.net/',
    cacheTime: 600000,        // 600 sec - cache purge period
    urls: [
      {url: '/', priority: 1.0},
      {url: '/search', priority: 0.6},
    ]
  });

  // ここに公開中の大会を取得するコード

  // sitemapの作成
  sitemap.toXML((error, xml) => {
    if (error) {
      return res.status(500).end(
    }
    res.header('Content-Type', 'application/xml');
    res.send(xml);
    });

});
```

これでbotが参照する都度、新しいsitemap.xmlを返すようになりました。

## 技術面以外でやったこと、やること

### 市場調査

SCORERAを開発したきっかけは、日本合気道協会が主催する『[国際合気道フェスティバル](http://aikido-kyokai.com/tournament/akita/main/)』の運営を楽にするため、ということだったのですが、いざやってみると色々な問題点がわかってきました。

そしてこの問題点を他の大会運営者にもヒアリングしてみたところ、やはり同じようなところで問題になることが多かったため、ある程度の需要があるのではないかと推測しています。

スポーツイベントの国内市場は約1.7兆円[^5]、東京オリンピックに向けて各団体が盛り上がっていることも踏まえて、チャレンジしてみる価値はあるという結論に至りました。

### ユーザーテスト

ここ2カ月ほど、クローズドβとして友人を対象にユーザーテストを行っていました。
実際にユーザーが使用している場面を横から見ていると、ユーザーは驚くほど文章を読みません。

判断基準の多くは直感で、色や形といった視覚的な情報がほとんどを占めていました。
正直このユーザービリティーの部分はまだまだ粗削な部分が多く、改善すべき点だらけですが、ユーザーの声を聴きながら正式版に向けてブラッシュアップをしていこうと思います。

### クラウドファンディング

いい機会なので、本サービスをクラウドファンディングに投稿させていただきました。
友人と一緒にCampfireの本社に飛び込み、担当していただいた方から多くのアドバイスいただきつつプロジェクトを作成しました。

正直マーケティングは門外漢で、わからないことばかりの中右往左往しています。
ここからはプロジェクトページやSNSで支援状況等の進捗を報告していく予定です。

# さいごに

制作開始から約１年半。正直まだまだ書き足らないくらいのハックがありますが、それらはまた逐次記事にしていこうと思います。
サービスそのものはこれからどんどん改良を加え、さらに使いやすく、良いものにしていきます！

[^1]: anglar-cli、firebase-toolのバージョンによって細かい構成は異なります。
[^2]: AnugalrのLazy Loadingという機能です。ルーティングの設定によっては、複数のモジュールを読み込むこともできます。
[^3]: 1対多、多対多のリレーションを作る場合はFirestoreでもパスを保有する必要があります。
[^4]: 2018年4月時点
[^5]: [http://www.meti.go.jp/press/2016/06/20160614004/20160614004-1.pdf](http://www.meti.go.jp/press/2016/06/20160614004/20160614004-1.pdf)