【AngularFire2】Angular + Firebaseで詰まったことまとめ

# はじめに

先日、初のWebサービスを開発・公開してみました。
[OneDay,OneGood](https://oneday-onegood.firebaseapp.com)
一日にあった出来事を投稿できる日記みたいなサービスです。
主な使用技術はAngularとFirebaseです。

基本的な機能しか利用していませんが、開発の中でいくつか引っかかったこと・時間がかかったことがあり、まとめておこうと思います。

# 環境

- Angular CLI: 7.0.4
- Node: 8.11.1
- Angular: 7.0.2
- @angular/fire: 5.1.0
- @ngtools/webpack: 7.0.4
- rxjs: 6.3.3
- typescript: 3.1.6
- webpack: 4.19.1

# 前提:Angular・Firebase連携

AngularとFirebaseの連携には、[angularfire](https://github.com/angular/angularfire2)というライブラリを使用します。

`npm install firebase @angular/fire --save`
でインストールできます。

あとは[公式のセットアップ手順](https://github.com/angular/angularfire2/blob/master/docs/install-and-setup.md)を参考にすればOKです。

# FirebaseAuth

様々な認証の方法を提供してくれる機能です。
実装するとなると相当の手間と注意が必要になるので、とても便利な機能だと思います。

## 詰まったところ

### 外部イベントの取得
今回、FirebaseAuthのGoogleログイン機能を使用して、ログイン成功ならナビゲーションしようとしましたが、以下のようなソースだと上手くいきません。

```sample-login.compoent.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AngularFireAuth } from '@angular/fire/auth';

@Component({
  selector: 'app-sample-login',
  templateUrl: './sample-login.component.html',
  styleUrls: ['./sample-login.component.scss']
})
export class SampleLoginComponent implements OnInit {

  constructor(private afAuth: AngularFireAuth, private router: Router) { }

  ngOnInit() {
  }

  // Googleアカウントでのログイン
  signInGoogle() {
    this.afAuth.auth.signInWithPopup(new auth.GoogleAuthProvider()).then(() => {
      // ログイン成功ならユーザーページへ
      this.router.navigate(['user-page']);
    }).catch((error) => {
      console.error(error);
    });
  }
```
コンソールには以下のようなメッセージが出ます。

```
Navigation triggered outside Angular zone, did you forget to call 'ngZone.run()'?
```

これは、外部でのイベント(Googleログイン)をAngularが検知できていないことから起きるミスです。
メッセージに書いてある`ngZone.run()`を使用すれば、外部でのイベント完了を検知できるようになります。
実際に使用すると以下のようになります。

```sample-login.compoent.ts
import { Component, OnInit, NgZone } from '@angular/core';
import { Router } from '@angular/router';
import { AngularFireAuth } from '@angular/fire/auth';

@Component({
  selector: 'app-sample-login',
  templateUrl: './sample-login.component.html',
  styleUrls: ['./sample-login.component.scss']
})
export class SampleLoginComponent implements OnInit {

  constructor(private afAuth: AngularFireAuth, private router: Router, private ngZone: NgZone) { }

  ngOnInit() {
  }

  // Googleアカウントでのログイン
  signInGoogle() {
    this.afAuth.auth.signInWithPopup(new auth.GoogleAuthProvider()).then(() => {
      // 外部イベントの検知
      this.ngZone.run(() => {
        // ログイン成功ならユーザーページへ
        this.router.navigate(['user-page']);
      });
    }).catch((error) => {
      console.error(error);
    });
  }
```

## 参考
[Angularの外部でイベントが発生した時の変更検知の方法](https://qiita.com/maechabin/items/a10811bbb470bc490b80)
[AngularとZone.jsとテストの話](https://qiita.com/Quramy/items/83f4fbc6755309f78ad2)
[ngZone.run()](https://angular.io/api/core/NgZone#run)

# RealTimeDatabase
NoSQLデータベースへの保存・同期ができるサービスです。
RealTimeの名前の通り、追加・削除・更新が即座に反映されます。
詰まったのはデータの扱いでした。NoSQL触ったことがなかったから？

## 詰まったことろ

### 一件追加
RDBでテーブルに対してデータを追加する、ということが、RealTimeDatabaseだとオブジェクトのリストに対してデータを追加する、ということに相応します。

例えば、Commentという項目に複数のデータがある、というDB構造を想定します。
ハイフンから始まる文字列がキーで、データとしてはmessageを持ちます。

```DB.json
  "Comment" : {
    "-LRxntFpj3BIqjB6kYhd" : {
      "message" : "何かに対するコメント1"
    },
    "-LRxqLSiWnjB63O7vKTN" : {
      "message" : "何かに対するコメント2",
    }
  }
```

Commentにデータを追加するには、

- Comment以下をリストとして取得
- 取得したリストに対して新しいデータを追加する

というステップが必要になります。
具体的には以下のようになります。

```sample.component.ts
import { Component, OnInit } from '@angular/core';
import { AngularFireDatabase } from '@angular/fire/database';


@Component({
  selector: 'app-sample',
  templateUrl: './sample.component.html',
  styleUrls: ['./sample.component.scss']
})
export class SampleComponent implements OnInit{

  // サービスの注入
  constructor(private db: AngularFireDatabase) { }

  ngOnInit() {
  }

  addComment(message: string) {
    // DBからComment以下のデータをリストとして取得、そこに対して新たなデータを追加
    this.db.list('Comment').push({message: message});
  }

```
キー値を指定しなくても、この方法なら自動で一意なキーを振ってくれます。
RDBのイメージしかなかったので、Commentに対して直接追加しようとしてました…。

### データの変更・削除
それぞれのデータの変更・削除にはキー値が必要です。

- キーによってデータを取得
- そのデータを変更、削除

という流れですが、キーの取得にちょっと癖がありました。

リストからデータを取得するにはいくつかの方法があり、それぞれ以下のような特徴があります。詳しくは[公式](https://github.com/angular/angularfire2/blob/master/docs/rtdb/lists.md#angularfireaction---action-based-api)を参照してください。

| | valueChanges | snapshotChanges | stateChanges | auditTrail | 
|:-:|:-:|:-:|:-:|:-:|
|戻り値(全てObservable)|any(JSONオブジェクト)|AngularFireAction\<DatabaseSnapshot>[]|AngularFireAction|AngularFireAction[]|
|使い所|値の変更をせず、ただデータのリストが欲しいとき|個々のデータに対して操作を行う時|データに対する変更を検知したい時|データに対する変更が配列で欲しい時|

データの変更をするにはsnapShotChangesを利用するのですが、以下のような方法でキーを取得する必要があります。
以下のソースは[公式](https://github.com/angular/angularfire2/blob/master/docs/rtdb/lists.md#example)を参考にしています。

```sample.component.ts
export class SampleComponent implements OnInit{
  comments : Observable<any[]>; 

  // サービスの注入
  constructor(private db: AngularFireDatabase) { }

  ngOnInit() {
    // Comment以下のデータ取得
    // snapshotChanges()でメタデータ含む値を取得
    // pipeでmap処理を行う
    this.comments = this.db.list('Comment').snapshotChanges().pipe(
      // changes = AngularFireAction<DatabaseSnapshot>[]
      map(changes => 
        // c = AngularFireAction<DatabaseSnapshot>
        // それぞれのデータにkey:値を設定して返す
        changes.map(c => ({ key: c.payload.key, ...c.payload.val() }))
      )
    );
  }
```
ちなみに`...c.payload.val()` はc.payload.val()の値(元データ。今回なら`{message: message}`)をそのまま設定するという意味です([Spread operator](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax))。

こうすることによって、

```
{
  message: "message",
  key: "some-data-key" 
}
```
それぞれのデータにkey値が設定されるので、その値を利用して変更・削除を行います。

```sample.component.ts

  // keyは先ほど設定したそれぞれのデータのキー値
  // 変更
  updateComment(key: string, newMessage: string) {
    this.db.list('Comment').update(key, { message: newMessage });
  }

  // 削除
  deleteItem(key: string) {
    this.db.list('Comment').remove(key);
  }
```

### クエリ
リストを取得する際、取得数やプロパティの値によってクエリをかけることができますが、複数のプロパティをまたがってクエリはできません。
クエリの流れとしては、

- データの並び替え(order-by)
- フィルタリング

という流れになります。
例によって[angularfire公式ページ](https://github.com/angular/angularfire2/blob/master/docs/rtdb/querying-lists.md)と[firebase公式ページ](https://firebase.google.com/docs/database/web/lists-of-data#sorting_and_filtering_data)を参考にしてください。

#### order-by
データの並び替えの方法を指定します。
order-byメソッドは一度しか使えません。
つまり、データの並び替えは一種類についてしか行えません。

|orderByChild|orderByKey|orderByValue|
|:-:|:-:|:-:|
|指定したプロパティでソートする|キー値でソートする|ノードの値でソートする|


```sample.ts
  // Comment以下にあるデータを、some-keyの値によってソートする
  this.db.list('Comment', ref => ref.orderByChild('some-key'));

  // Comment以下にあるデータを、それぞれのキーでソートする
  this.db.list('Comment', ref => ref.orderByKey());

  // Comment以下にあるデータを、それぞれの値(ノード)でソートする
  this.db.list('Comment', ref => ref.orderByValue());
```

#### フィルタリング
データの取得範囲を絞ります。
フィルタリングは組み合わせることが可能です。

|limitToFirst|limitToLast|equalTo|startAt|endAt|
|:-:|:-:|:-:|:-:|:-:|:-:|
|リストの先頭から取得するデータの最大数|リストの末尾から取得するデータの最大数|order-byで指定したキーの値が等しいもの|order-byで指定したキーの値以上のもの|order-byで指定したキーの値以下のもの|


```sample.ts
 // Comment以下にある、some-keyの値がsome-dataのデータを取得する
  this.db.list('Comment', ref => ref.orderByChild('some-key').equalTo('some-data'));

 // Comment以下にある、some-keyの値が10以上15以下のデータを取得する
  this.db.list('Comment', ref => ref.orderByChild('some-key').startAt(10).endAt(15));

  // キーで並び替えたComment以下にあるデータを、先頭15件分取得
  this.db.list('Comment', ref => ref.orderByKey().limitToFirst(15));

  // キーで並び替えたComment以下にあるデータを、末尾15件分取得
  this.db.list('Comment', ref => ref.orderByKey().limitToLast(15));
```

二種類のプロパティを指定したソート・フィルタリングはできない、ということに詰まりました。

# CloudFunction

何らかのトリガー(DBへのアクセス、特定エンドポイントへのリクエストetc)によって、プログラムを実行してくれるサービスです。
今回のサービスでは投稿に保存期限を設けたので、バッチ処理で定期的に削除を行わせています。

## セットアップ
`firebase init functions`でプロジェクトが作成されます。
途中、依存関係と使用言語について聞かれます。今回は「インストール」「TypeScript」を選択しました。

## 詰まったところ

### キーの取得
Angularで使用していたサービス(AngularFireDatabase)と微妙に使い方が違い、引っかかりました。

やりたかったことは以下の2点です。

- 全投稿の取得
- 各投稿で、保存期限が過ぎているかチェックし、期限切れの投稿を削除

```index.ts
// モジュールのインポート
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

// アプリの初期化
admin.initializeApp();

const db = admin.database();

// httpsリクエストをトリガーに実行
export const deleteLimitedComment = functions.https.onRequest((request, response) => {
  const today = new Date().getTime();
  const res = [];

  // Comment以下を取得
  // once('value')を呼ぶことでsnapshotを取得できる
  // snapshotはComment以下全体のデータ
  db.ref('Comment').orderByKey().once('value').then((snapshot) => {
    snapshot.forEach((childsnap) => {
      // childsnapがそれぞれのデータ
      const comment = childsnap.val();
      if (comment.limit && comment.limit < today) {
        // Comment + childsnap.keyでデータを指定、削除
        db.ref('Comment/' + childsnap.key).remove().then(() => {
            res.push(childsnap.key);                    
          }).catch((error) => {
            response.send(error);
          });
        }
      // trueを返すとforEach終了、falseで継続
      return false;
     });
   }).then(() => {
     response.send(res.join(','));
   }).catch((error) => {
     response.send(error);
  });
});
```

キーを取得するために`once('value')`と`forEach`を使用する必要がある、という点に詰まりました。
## 参考
[公式APIリファレンス](https://firebase.google.com/docs/reference/admin/node/admin.database)
[Firebase Cloud Functionsで定期実行してみる](https://qiita.com/jun1009/items/4145072dbb243c3edf9f)

# 終わりに
以上、自分が初めてFirebaseを触る上で引っかかった点のまとめです。
基本的な機能しか触っていませんが、これから触る方の助けになれば幸いです。
また、もっと良い解決法や間違い等があればご指摘の方よろしくお願いします。