Chapter 04

Firebase編

たつきち
たつきち
2020.09.21に更新

ここまでで、ある程度見た目の美しいTodoアプリが出来上がりましたが、肝心のTodoタスクのデータがどこにも永続化されていないという致命的な問題があります。

というわけで、このチュートリアルの締め括りとして、FirebaseCloud Firestore を使ってデータを永続化できるようにしていきます💪

1. Firebaseプロジェクトを作成する

まずは以下の手順でFirebaseプロジェクトを作成しましょう。

  1. https://console.firebase.google.com/プロジェクトを追加 をクリック
  2. プロジェクト名に angular-todo と入力して 続行
  3. このプロジェクトでGoogleアナリティクスを有効にする は今回はOFFにして プロジェクトを作成
  4. 1分ほど待って、 新しいプロジェクトの準備ができました と表示されたら、 続行

以下のような画面まで来たらプロジェクト作成完了です。

2. Firestoreのデータベースを作成する

以下の手順で、Firebaseプロジェクト内にFirestoreのデータベースを作成しましょう。

  1. Firebaseプロジェクトページ(上記スクリーンショットの画面)の左サイドメニューから Database をクリック
  2. データベースの作成 をクリック
  3. テストモードで開始 を選択して 次へ
  4. Cloud Firestoreのロケーションで asia-northeast1 (東京リージョン)を選択して 完了
  5. 1分ほど待つ

以下のような画面になったらデータベースの準備は完了です。

※注意
テストモード でデータベースを作ると、作成から30日間は誰でも読み書きが可能な権限設定になります。重要な秘匿情報や個人情報などを保存してしまわないように注意してください。

3. アプリからFirestoreを利用するための準備をする

データベースをアプリから利用するための準備が必要です。以下の手順を実施してください。

  1. Firebaseプロジェクトページの左サイドメニューから プロジェクトの概要 をクリック
  2. </> アイコン(Webアプリ)のボタンをクリック
  3. アプリのニックネームに angular-todo と入力し、 このアプリのFirebase Hostingも設定します には チェックせずに アプリを登録 をクリック
  4. 表示されたコードスニペットのうち以下の部分をどこかにコピーしておいた上で、 コンソールに進む をクリック
    apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    authDomain: "angular-todo-xxxxx.firebaseapp.com",
    databaseURL: "https://angular-todo-xxxxx.firebaseio.com",
    projectId: "angular-todo-xxxxx",
    storageBucket: "angular-todo-xxxxx.appspot.com",
    messagingSenderId: "xxxxxxxxxxxxx",
    appId: "1:xxxxxxxxxxxxx:web:xxxxxxxxxxxxxxxxxxxxxx",
    measurementId: "G-xxxxxxxxxx"
    

このコードスニペットは後ほどAngularアプリからFirestoreに接続するために必要になります。

このコードスニペットは、 左サイドメニューの歯車アイコン > プロジェクトを設定 > 全般 の画面でいつでも見られます👌

4. アプリからFirestoreを利用できるようにする

AngularアプリでFirebaseを利用するには、angularfire というAngular公式のFirebase SDKを利用します。

こちらのドキュメント を参考に、以下の手順でアプリにインストールしましょう。

まず前提としてFirebaseのSDK本体が必要です。

npm i -S firebase
# または
yarn add firebase

続いてangularfireをインストールします。

ng add @angular/fire

途中 ? Please select a project: と聞かれるので angular-todo を選択してください。

インストールが完了したら、Firebaseプロジェクトと接続するための情報を環境設定ファイルに記述します。

src/environments/environments.ts を開いて、以下のように environment.firebase に先ほどコピーしておいたコードスニペットをそのままセットしてください。

export const environment = {
- production: false
+ production: false,
+ firebase: {
+   apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
+   authDomain: "angular-todo-xxxxx.firebaseapp.com",
+   databaseURL: "https://angular-todo-xxxxx.firebaseio.com",
+   projectId: "angular-todo-xxxxx",
+   storageBucket: "angular-todo-xxxxx.appspot.com",
+   messagingSenderId: "xxxxxxxxxxxxx",
+   appId: "1:xxxxxxxxxxxxx:web:xxxxxxxxxxxxxxxxxxxxxx",
+   measurementId: "G-xxxxxxxxxx",
+ },
};

最後に、 src/app/app.module.ts に以下のように AngularFireModuleAngularFirestoreModule を追加します。

+ import { AngularFireModule } from '@angular/fire';
+ import { AngularFirestoreModule } from '@angular/fire/firestore';
+ import { environment } from '../environments/environment';

registerLocaleData(en);

@NgModule({
  declarations: [
    // 略
  ],
  imports: [
    // 略
+   AngularFireModule.initializeApp(environment.firebase),
+   AngularFirestoreModule,
  ],
  // 略
})
export class AppModule { }

5. 実際にアプリからFirestoreにデータを登録してみる

これで準備は完了です!

それでは早速、実際にFirestoreにデータを登録してみましょう💪

src/app/task-list/task-list.component.ts に以下のようなコードを追記します。

  import { Component, OnInit } from '@angular/core';
  import { Task } from '../../models/task';
+ import { AngularFirestore } from '@angular/fire/firestore';

  @Component({
    selector: 'app-task-list',
    templateUrl: './task-list.component.html',
    styleUrls: ['./task-list.component.scss']
  })
  export class TaskListComponent implements OnInit {
 
-   constructor() { }
+   constructor(
+     private firestore: AngularFirestore,
+   ) { }

    tasks: Task[] = [
      {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
      {title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
      {title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
    ];

    ngOnInit(): void {
    }

    addTask(task: Task): void {
      this.tasks.push(task);
+     this.firestore.collection('tasks').add(task);
    }
  }

angularfireが持っている AngularFirestore というサービスを、 TaskListComponent にインジェクトして利用しています。

Angularでは、コンストラクタの引数に型注釈を書くだけでサービスのインジェクトができます。

AngularFirestore サービスの collection() メソッドで 'tasks' という名前のコレクションを指定し、 add() メソッドでそこに task オブジェクトをドキュメントとして追加しています。

この状態で実際に画面から適当なタスクを追加してみて、FirestorのWeb UIをリロードしてみてください。

こんなふうにデータが登録されているはずです👍

5. Firestoreから読み込んだデータを表示する

Firestoreにデータを保存できるようになったので、ダミーデータではなく実際のFirestore上のデータを画面に表示するようにしましょう。

src/app/task-list/task-list.component.ts に手を加えていきます。

まずはタスクリストの初期データをダミーデータではなく空の配列に変更しましょう。

- tasks: Task[] = [
-   {title: '牛乳を買う', done: false, deadline: new Date('2021-01-01')},
-   {title: '可燃ゴミを出す', done: true, deadline: new Date('2020-01-02')},
-   {title: '銀行に行く', done: false, deadline: new Date('2020-01-03')},
- ];
+ tasks: Task[] = [];

そして、 ngOnInit() メソッド内でFirestoreからデータを取得して this.tasks を上書きします。

  ngOnInit(): void {
+   this.firestore.collection('tasks').valueChanges().subscribe(tasks => {
+     this.tasks = tasks as Task[];
+   });
  }

ngOninit() はAngularコンポーネントの ライフサイクル・フック メソッドの一つで、コンポーネントが初期化された直後に実行されるメソッドです。

this.firestore.collection('tasks') に生えている valueChanges() メソッドは、Firestore上の指定したコレクションまたはドキュメントの変更を検知して rxjsObservable に変換してストリームとして流してくれる働きをします。これを subscribe() することで、変更が検知される度に特定の処理を実行することができます。

Angularにおいて、非同期処理は基本的に Promise ではなくrxjsの Observable を使って処理します。このチュートリアルでは Observable の詳しい使い方については説明しませんが、本格的にAngularを使っていくなら避けては通れない存在なので、ぜひ学んでみてください💪

少し難しいですが、まとめると、 ngOnInit() 内で valueChanges() メソッドが流してくれるストリームを subscribe() することで、コンポーネントが初期化されて以降ずっとFirestore上の tasks コレクションの変更を検知し続けて、変更がある度に this.tasks を更新する処理が実行されるようにしている、というわけですね。

なお、Firestorから取得されるデータは、ローカルで定義した Task インターフェースと完全に一致はしていないため、ひとまず as Task[]型アサーション を付けてコンパイルを通しています。

それから、Firestoreにタスクが追加されれば自動で変更が検知されて this.tasks が丸ごと更新されるようになったので、 addTask() メソッド内でオンメモリの this.tasks に新しいタスクをpushする処理はもはや不要ですね。削除してしまいましょう✋

  addTask(task: Task): void {
-   this.tasks.push(task);
    this.firestore.collection('tasks').add(task);
  }

さて、ひとまずこの段階で一旦動かしてみてください。実際に動かしてみるとコンソールに以下のようなエラーが出力されます😓

ERROR TypeError: task.deadline.getTime is not a function

task.deadline.getTime という関数がないと言われていますね。

どうやらFirestorから取得した taskdeadline プロパティの中身が Date オブジェクトではないために getTime() メソッドを持っていないのが原因のようです。

実際には datetime プロパティには firebase.firestore.Timestamp という型のオブジェクトが入っています。

このオブジェクトは toDate() というメソッドを持っていて、 Date 型に変換することができます。

なので、例えば以下のようにコードを修正することで、とりあえず動かすことが可能です。

- this.firestore.collection('tasks').valueChanges().subscribe(tasks => {
+ this.firestore.collection('tasks').valueChanges().subscribe((tasks: any) => {
-   this.tasks = tasks as Task[];
+   this.tasks = tasks.map(task => {
+     task.deadline = task.deadline ? task.deadline.toDate() : null;
+     return task;
+   }) as Task[];
  });

これで、特にエラーが発生することもなく正常にFirestore上のデータを表示することができたかと思います👍

6. メモリリークを解消する

さて、実は今の実装には重大なバグがあります😱

ngOnInit()valueChanges()subscribe() する処理を書きましたが、これの意味は

少し難しいですが、まとめると、 ngOnInit() 内で valueChanges() メソッドが流してくれるストリームを subscribe() することで、コンポーネントが初期化されて以降ずっとFirestore上の tasks コレクションの変更を検知し続けて、変更がある度に this.tasks を更新する処理が実行されるようにしている、というわけですね。

ということでしたよね。実は今のままだと、コンポーネントが破棄されたあともメモリ上にリスナー関数が残り続けてしまい、メモリリーク が発生します。

より詳しく理解したい方は Observableのライフサイクル - Angular After Tutorial などをご参照ください。ただ、書かれている内容が今の時点で読むには少し難しいと思うので、もう少し慣れてきてから理解を深めるでも全然大丈夫です👍

メモリリークが発生しないようにするには、 コンポーネントを破棄するタイミングで、 subscribe() による購読を解除する という処理を追記する必要があります。

具体的には以下のようにコードを修正します。

- import { Component, OnInit } from '@angular/core';
+ import { Component, OnDestroy, OnInit } from '@angular/core';
  import { Task } from '../../models/task';
  import { AngularFirestore } from '@angular/fire/firestore';
+ import { Subscription } from 'rxjs';

  @Component({
    selector: 'app-task-list',
    templateUrl: './task-list.component.html',
    styleUrls: ['./task-list.component.scss']
  })
- export class TaskListComponent implements OnInit {
+ export class TaskListComponent implements OnInit, OnDestroy {

    constructor(
      private firestore: AngularFirestore,
    ) { }

    tasks: Task[] = [];
 
+   subscription: Subscription;
+
    ngOnInit(): void {
-     this.firestore.collection('tasks').valueChanges().subscribe((tasks: any[]) => {
+     this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: any[]) => {
        this.tasks = tasks.map(task => {
          task.deadline = task.deadline ? task.deadline.toDate() : null;
          return task;
        }) as Task[];
      });
    }
 
+   ngOnDestroy(): void {
+     this.subscription.unsubscribe();
+   }
+
    addTask(task: Task): void {
      this.firestore.collection('tasks').add(task);
    }
  }
  • クラスの宣言を変更し、今まで OnInit インターフェースだけを実装していたところを、加えて OnDestroy インターフェースも実装するように
    • これにより、コンポーネントが破棄される直前に呼ばれるライフサイクル・メソッド ngOnDestroy() を使えるようになる
  • valueChanges().subscribe() の戻り値( Subscription 型)をクラス変数に保存しておいて、 ngOnDestroy() 内で unsubscribe() を実行することで、購読を解除するように

ということをしました。

まだ Observable に慣れていないので、多分「分かったような分からないような」という感覚だと思います😅今の段階では、「 subscribe() したまま unsubscribe() しないコードを書くとメモリリークの原因になりうる」という事実だけを頭の片隅で覚えておけば十分です!💪

7. Firestoreから読み込んだデータにも型を持たせる

ちょっと難しかったですが、一応これでメモリリークもなく動くものが作れました👍

ただ、この辺りのコードが

this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: any[]) => {
   this.tasks = tasks.map(task => {
     task.deadline = task.deadline ? task.deadline.toDate() : null;
     return task;
   }) as Task[];
 });

any as のオンパレードでちっとも型の力を活かせていませんね🤔

やはりTypeScriptにおいて anyas はできるだけ使うべきではないので、もう少し型安全なコードに直してみましょう💪

まず、Firestoreドキュメントとして取得したデータをきちんと型を付けて扱えるように、 src/models/task.ts に以下のようなコードを追加します。

+ import * as firebase from 'firebase';
+ import Timestamp = firebase.firestore.Timestamp;
+
  export interface Task {
    title: string;
    done: boolean;
    deadline?: Date;
  }
+
+ export interface TaskDocument {
+   title: string;
+   done: boolean;
+   deadline?: Timestamp;
+ }
+
+ export function fromDocument(doc: TaskDocument): Task {
+   return {
+     title: doc.title,
+     done: doc.done,
+     deadline: doc.deadline ? doc.deadline.toDate() : null,
+   };
+ }

Firestorドキュメントとしてのタスクを TaskDocument インターフェースとして定義し、さらに TaskDocument 型のデータを Task 型に変換するユーティリティを fromDocument 関数として定義しました。

これらを使って、 ngOnInit() の中身を以下のように変更できます。

  import { Component, OnDestroy, OnInit } from '@angular/core';
- import { Task } from '../../models/task';
+ import { fromDocument, Task, TaskDocument } from '../../models/task';
  import { AngularFirestore } from '@angular/fire/firestore';
  import { Subscription } from 'rxjs';

  // ...

    ngOnInit(): void {
-     this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: any[]) => {
-       this.tasks = tasks.map(task => {
-         task.deadline = task.deadline ? task.deadline.toDate() : null;
-         return task;
-       }) as Task[];
-     });
+     this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: TaskDocument[]) => {
+       this.tasks = tasks.map(fromDocument);
+     });
    }

型安全かつとてもシンプルで可読性の高いコードになりましたね👍

8. チェックボックスのクリックでFirestore上のデータを更新するように

Firestoreの読み書きができるようになりましたが、現状では既存のタスクの完了状態を変更することができませんので、これに対応していきたいと思います。

Firestoreからのデータ読み込み時にドキュメントIDも取得する

まず、既存のFirestoreドキュメントを更新するためには、ドキュメントIDを知る必要があります。まずはFirestoreからデータを読み込んだときにドキュメントIDも一緒に取得するように修正していきましょう。

まずは Task 型と TaskDocument 型を修正して、 id を持てるようにします。

  export interface Task {
+   id?: string;
    title: string;
    done: boolean;
    deadline?: Date;
  }
  export interface TaskDocument {
+   id: string;
    title: string;
    done: boolean;
    deadline?: Timestamp;
  }
  
  export function fromDocument(doc: TaskDocument): Task {
    return {
+     id: doc.id,
      title: doc.title,
      done: doc.done,
      deadline: doc.deadline ? doc.deadline.toDate() : null,
    };
  }

次に、valueChanges() メソッドに引数を渡してドキュメントIDも取得してくれるようにします。

引数として {idField: '何というキー名として取得したいか'} を渡すことで、任意のキー名でドキュメントIDを取得できます。(参考

  ngOnInit(): void {
-   this.subscription = this.firestore.collection('tasks').valueChanges().subscribe((tasks: TaskDocument[]) => {
+   this.subscription = this.firestore.collection('tasks').valueChanges({idField: 'id'}).subscribe((tasks: TaskDocument[]) => {
      this.tasks = tasks.map(fromDocument);
    });
  }

これで、Firestoreから読み込んだタスクが id を持った状態になりました。このままの状態でタスクの追加操作をしてしまうと、 id というプロパティを持ったオブジェクトとして保存されてしまうので(実害はありませんが)、 addTask() の処理を少し修正して、 id プロパティは取り除いた状態で保存するようにしておきましょう。

  addTask(task: Task): void {
-   this.firestore.collection('tasks').add(task);
+   const clone = Object.assign({}, task);
+   delete clone.id;
+ 
+   this.firestore.collection('tasks').add(clone);
  }

チェックボックスがクリックされたらFirestore上のデータを更新する

「チェックボックスがクリックされたこと」を知っているのは TaskListItemComponent ですが、Firestoreを操作する処理は TaskListComponent に集約しておきたいので、 TaskFormComponent から TaskListComponent へイベント経由でタスクを渡したときと同じように、クリックされたタスクを TaskListComponent に渡すような実装にしてみましょう✋

src/app/task-list/task-list.component.ts

  addTask(task: Task): void {
    const clone = Object.assign({}, task);
    delete clone.id;

    this.firestore.collection('tasks').add(clone);
  }
+
+ updateTask(task: Task): void {
+   const clone = Object.assign({}, task);
+   delete clone.id;
+
+   this.firestore.collection('tasks').doc(task.id).update(clone);
+ }

src/app/task-list-item/task-list-item.component.ts

- import { Component, Input, OnInit } from '@angular/core';
+ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
  import { Task } from '../../models/task';

    // ...

    @Input() task: Task;
+   @Output() updateTask = new EventEmitter<Task>();

    // ...

    onToggleDone(task: Task): void {
      this.updateTask.emit(task);
    }

src/app/task-list-item/task-list-item.component.html

  <div class="left">
-   <label nz-checkbox [(ngModel)]="task.done" class="{{ task.done ? 'done' : '' }}">
+   <label nz-checkbox [(ngModel)]="task.done" class="{{ task.done ? 'done' : '' }}" (click)="onToggleDone(task)">
      {{ task.title }}
    </label>
  </div>

(click)="onToggleDone(task)" と書きましたが、厳密には (ngModelChange)="onToggleDone(task)" とすべきです。ここでは説明は割愛します。なぜそうすべきなのかぜひ考えてみてください👍

src/app/task-list/task-list.component.html

  <nz-list-item *ngFor="let task of tasks">
-   <app-task-list-item [task]="task"></app-task-list-item>
+   <app-task-list-item [task]="task" (updateTask)="updateTask($event)"></app-task-list-item>
  </nz-list-item>

9. タスクリストが作成日時順で並ぶようにする

いよいよ大詰めです。

現時点でタスクの読み込み・追加・編集ともFirestoreをデータストアとして実行できるようになりましたが、実際に操作してみると違和感を覚えたはずです。

そう、 タスクリストの並び順がバラバラ ですね。

Firestoreから取得したリストをそのまま表示しているので、Firestoreが気まぐれに並べた順番(おそらくドキュメントID昇順)で表示されてしまっているのです。

リストをオンメモリで保持していたときと同様に、タスクの作成日時昇順で並ぶように修正してみましょう💪

src/models/task.ts

  export interface Task {
    id?: string;
    title: string;
    done: boolean;
    deadline?: Date;
+   createdAt: Date;
  }
  export interface TaskDocument {
    id: string;
    title: string;
    done: boolean;
    deadline?: Timestamp;
+   createdAt: Timestamp;
  }
  
  export function fromDocument(doc: TaskDocument): Task {
    return {
      id: doc.id,
      title: doc.title,
      done: doc.done,
      deadline: doc.deadline ? doc.deadline.toDate() : null,
+     createdAt: doc.createdAt.toDate(),
    };
  }

src/app/task-list/task-list.component.ts

  ngOnInit(): void {
    this.subscription = this.firestore.collection('tasks').valueChanges({idField: 'id'}).subscribe((tasks: TaskDocument[]) => {
-     this.tasks = tasks.map(fromDocument);
+     this.tasks = tasks.map(fromDocument).sort((a: Task, b: Task) => a.createdAt.getTime() - b.createdAt.getTime());
    });
  }

src/app/task-form/task-form.component.ts

  submit(): void {
    this.addTask.emit({
      title: this.newTask.title,
      done: false,
      deadline: this.newTask.deadline ? new Date(this.newTask.deadline) : null,
+     createdAt: new Date(),
    });
    this.newTask = {
      title: '',
      deadline: null,
    };
  }

型に createdAt プロパティを追加して、タスク作成時に現在日時を入れて保存するようにした上で、Firestoreから読み込んだタスクリストを sort()createdAt 昇順でソートしているだけです👍

createdAt なしのタスクがすでにFirestore上に保存されている場合は、一度それらをFirestore上から削除した上で動作確認してみてください。

現状の動作はこんな感じです。画面をリロードしてもちゃんと状態が保持されていますね🙌

10. 追加課題

ここまでできたあなたに、最後に追加課題です!

各タスクに削除ボタンを追加し、クリックすると画面からもFirestore上からもタスクが削除されるようにしてみましょう。

チェックボックスのクリックでタスクを更新したときと同じ流れでできるはずです💪

Firestoreからドキュメントを削除する方法は以下のとおりです。

this.firestore.collection('tasks').doc(task.id).delete();

動作例はこんな感じです。

頑張ってみてください!

筆者の回答例が こちら にあります。ぜひ、まずは自力で頑張ってみた上で、答え合わせをしてみてください。

お疲れさまでした!

このチュートリアルが役に立ったと思っていただけた方は、こちらのトップページ をSNS等でシェアしていただけるととても嬉しいです!😆