Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

はじめに

先日、初のWebサービスを開発・公開してみました。
OneDay,OneGood
一日にあった出来事を投稿できる日記みたいなサービスです。
主な使用技術は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というライブラリを使用します。

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

あとは公式のセットアップ手順を参考にすれば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の外部でイベントが発生した時の変更検知の方法
AngularとZone.jsとテストの話
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に対して直接追加しようとしてました…。

データの変更・削除

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

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

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

リストからデータを取得するにはいくつかの方法があり、それぞれ以下のような特徴があります。詳しくは公式を参照してください。

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

データの変更をするにはsnapShotChangesを利用するのですが、以下のような方法でキーを取得する必要があります。
以下のソースは公式を参考にしています。

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)。

こうすることによって、

{
  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公式ページfirebase公式ページを参考にしてください。

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リファレンス
Firebase Cloud Functionsで定期実行してみる

終わりに

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

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away