Angular + Firebase + PWAでPush通知機能を実装する

# はじめに

こんにちは！
[去年](https://qiita.com/advent-calendar/2017/fusic)に引き続き、今年も書きます。
[Fusic Advent Calendar 2018](https://qiita.com/advent-calendar/2018/fusic) 19日目の記事です！
今回は、「Angular + Firebase + PWAでPush通知機能を実装」についてまとめてみました 👏


# やりたいこと

PWAでPush機能実装したい！

※ PWAって何？的な説明は、以下を見てください
- [https://developers.google.com/web/fundamentals/codelabs/your-first-pwapp/?hl=ja](https://developers.google.com/web/fundamentals/codelabs/your-first-pwapp/?hl=ja)
- [https://developers.google.com/web/progressive-web-apps/](https://developers.google.com/web/progressive-web-apps/)

# 開発環境

- Angular 6
- Firebase
    - Realtime Database
    - Functions

# さっそく実装に入りましょう 🤩

実装については、以下の流れで説明していきたいと思います！

1. 必要なモジュールなどをインストール
2. Push機能実装：アプリ側
3. Push機能実装：Firebase Functions側

## 1. 必要なモジュールなどをインストール

**Firebase CLIをインストール**

    yarn global add firebase-tools@latest

Firebase CLIの詳細については、[こちら](https://firebase.google.com/docs/cli/?hl=ja)を見て下さい！

**Firebaseモジュールをインストール**

    yarn add firebase --save

https://www.npmjs.com/package/firebase

## 2. Push機能実装：アプリ側

### 2-1. manifest.jsonを修正

ハードコード値 `gcm_sender_id` を指定するウェブアプリ マニフェストを追加する必要があります。

> 「ブラウザ送信者 ID」と、Firebase プロジェクト設定に示されるプロジェクト固有の送信者 ID 値とを混同しないでください。
> manifest.json のブラウザ送信者 ID は固定値で、すべての FCM JavaScript クライアントで共通です。

 `gcm_sender_id`は、固定値ですので、変更しないでください！

```json:src/manifest.json
{
  ... 省略 ...
  "gcm_sender_id": "103953800507" // ← gcm_sender_idを追加
  ... 省略 ...
}
```

### 2-2. メッセージサービスワーカーを作成

Pushメッセージには、 `service worker` が必要になります。
まず、Firebaseで `messagingSenderId`をコピーしておきましょう。
![Untitled-4bc6eea9-3ccd-4c4e-a383-40bcef7df765.png](https://qiita-image-store.s3.amazonaws.com/0/128271/c569929f-1157-1642-4300-c259ade8730b.png)
次は、`src`ディレクトリの配下に `firebase-messaging-sw.js` を作成します。
そして、↑でコピーした`messagingSenderId` を、以下の `firebase.initializeApp()` に貼りつけます！

```typescript:src/firebase-messaging-sw.js
// Give the service worker access to Firebase Messaging.
// Note that you can only use Firebase Messaging here, other Firebase libraries
// are not available in the service worker.
importScripts('https://www.gstatic.com/firebasejs/5.5.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/5.5.0/firebase-messaging.js');
    
// Initialize the Firebase app in the service worker by passing in the
// messagingSenderId.
firebase.initializeApp({
  messagingSenderId: '' // ↑でコピーしたmessagingSenderIdを追加
});
    
// Retrieve an instance of Firebase Messaging so that it can handle background
// messages.
const messaging = firebase.messaging();

messaging.setBackgroundMessageHandler(function(payload) {
  const notificationTitle = payload.notification.title;
  const notificationOptions = {
    body: payload.notification.body,
    icon: payload.notification.icon
  };

  return self.registration.showNotification(notificationTitle, notificationOptions);
});

self.addEventListener('install', function(event) {
  console.log('Service Worker installing.');
});

self.addEventListener('activate', function(event) {
  console.log('Service Worker activating.');
});

```
これで、ユーザーがアプリを閉じても、アプリが新しいメッセージを検知することができます！

### 2-3. angular.jsonを修正

angular.jsonに`manifest.json`, `firebase-messaging-sw.js` を追加

```json:angular.json
{
  ... 省略 ...
  "projects": {
    "architect": {
      "build": {
        "options": {
          "assets": [
            "src/favicon.ico",
            "src/assets",
            "src/manifest.json", // ←追加
            "src/firebase-messaging-sw.js" // ←追加
          ]
        }
      }
    }
  }
}
```

### 2-4. メッセージサービスを作成
まずは、Firebaseの「鍵ペア」を生成する必要があります。

#### 2-4-1. Firebaseで「鍵ペア」を生成

「プロジェクトの設定＞クラウドメッセージング」に入って、鍵ペアを生成してください。
![Untitled-7f78e992-b0c6-4fe3-8555-8167e9ff30eb.png](https://qiita-image-store.s3.amazonaws.com/0/128271/f0de2d3d-3b0d-b8bf-5b8a-3509f9fc426e.png)
生成した鍵ペアをコピーしておいてください⭐️

#### 2-4-2. Service作成

メッセージサービスは、Push通知メッセージを受け取るユーザーの許可を得る役割をします。

    ng g service messaging

ざっくり以下のような構成になります！

##### getPermission()
- ユーザーからPush通知を行う許可を取得

##### updateToken()
- FirebaseにFCMトークンを保存
- ユーザーにPush通知を送る度にこのトークンを参照するので、Firebaseに保存する

##### receiveMessage()
- メッセージを受信

```typescript:src/app/service/messaging.service.ts
import { Injectable } from '@angular/core';
import { AngularFireDatabase } from 'angularfire2/database';
import { AngularFireAuth } from 'angularfire2/auth';
import { BehaviorSubject } from 'rxjs';
import * as firebase from 'firebase';
import 'firebase/messaging';

@Injectable({
  providedIn: 'root',
})
export class MessagingService {
  messaging = firebase.messaging();
  currentMessage = new BehaviorSubject(null);
  constructor(
    private db: AngularFireDatabase,
    private afAuth: AngularFireAuth
  ) {
    // Add the public key generated from the console here.
    this.messaging.usePublicVapidKey(
      '' // <- ここに、上記で生成した鍵ペアを貼りつけてください
    );
  }

  updateToken(token) {
    this.afAuth.authState.subscribe((user) => {
      if (!user) {
        return;
      }

      const data = { [user.uid]: token };
      this.db.object('fcmTokens/').update(data);
    });
  }

  getPermission() {
    this.messaging
      .requestPermission()
      .then(() => {
        console.log('Notification permission granted.');
        return this.messaging.getToken();
      })
      .then((token) => {
        console.log(token);
        this.updateToken(token);
      })
      .catch((err) => {
        console.log('Unable to get permission to notify.', err);
      });
  }

  receiveMessage() {
    this.messaging.onMessage((payload) => {
      console.log('Message received. ', payload);
      this.currentMessage.next(payload);
    });
  }
}
```

#### 2-4-3. app.module.tsを修正

```typescript:src/app/app.module.ts
// ... 省略 ...
import { MessagingService } from './service/messaging.service';

@NgModule({
  // ... 省略 ...
  providers: [
    MessagingService, // ← を追加
  ]
})
export class AppModule {}
```

#### 2-4-4. app.component.tsを修正

```typescript:src/app/app.component.ts
// ... 省略 ...
import { MessagingService } from './service/messaging.service';

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

  constructor(private msgService: MessagingService) {}

  ngOnInit() {
    this.msgService.getPermission();
    this.msgService.receiveMessage();
    this.message = this.msgService.currentMessage;
  }
  // ... 省略 ...
}
```
これで、以下のようにアプリにアクセスすると、「通知を許可しますか？」という確認アラートが表示されるようになります！🎉
<img src="https://qiita-image-store.s3.amazonaws.com/0/128271/73bb1add-2101-7a4a-1baa-9a20059dc938.png" width=30%>



## 3. Push機能実装：Firebase Functions側

### 3-1. Firebaseのfunctionsを初期化し、関数を作成する

    firebase init functions

※ 作成の詳細手順については、[こちら](https://firebase.google.com/docs/functions/get-started?hl=ja)を見てください！

<br> 

初期化すると、以下の構成になります！

    myproject
     +- .firebaserc    # Hidden file that helps you quickly switch between
     |                 # projects with `firebase use`
     |
     +- firebase.json  # Describes properties for your project
     |
     +- functions/     # Directory containing all your functions code
          |
          +- package.json  # npm package file describing your Cloud Functions code.
          |
          +- tsconfig.json # Config file containing compiler options.
          |
          +- tslint.json   # Optional file containing rules for TypeScript linting.
          |
          +- src/     # Directory containing TypeScript source
          |   |
          |   +- index.ts     # main source file for your Cloud Functions code
          |
          +- lib/
              |
              +- index.js     # JavaScript output from TypeScript source.
              |
              +- index.js.map # Sourcemap file for TypeScript source.

ここからは、functions/src/index.ts を修正していきますー！


### 3-2. index.tsを修正

DBに更新がある度に、トリガーによって `onCreate()` が実行されます。
また、DBに保存されているFCMトークンを取得し、メッセージデータを用いてユーザーに送信するペイロードが作られます。

```typescript:functions/src/index.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
admin.initializeApp();

export const fcmSend = functions.database
  .ref('') // ←ここは、各自のDB構造に合わせて定義してください（例：/article/{articleId}）
  .onCreate((snapshot, context) => {
    const articleInfo = snapshot.val();

    const payload = {
      notification: {
        title: '', // Pushメッセージのタイトル
        body: articleInfo.title + 'が登録されました 🎉', // Pushメッセージ本文
        clickAction: '', // Push通知をタップした時に、飛ばすURLを指定
        icon: '', // Push通知で使うロゴ
      },
    };

    admin
      .database()
      .ref('/fcmTokens/')
      .once('value')
      .then((token) => {
        const tokenList = token.val() || '';

        Object.keys(tokenList).forEach(function(key, index) {
          console.log(tokenList[key]);
          admin
            .messaging()
            .sendToDevice(tokenList[key], payload)
            .then((res) => {
              console.log('Sent Successfully', res);
            })
            .catch((err) => {
              console.log(err);
            });
        });
      })
      .catch((err) => {
        console.log(err);
      });
  });
```
これで実装完了です！
実際に、こんな感じでPush通知が飛んできます。
<img src="https://qiita-image-store.s3.amazonaws.com/0/128271/9a5062ac-19e2-53ed-f152-720c0ba59efe.png" width=30%>
今回は、特定のユーザー一人一人にPush通知を送信していますが、複数ユーザーにメッセージを送信することもできるらしいので、[こちら](https://firebase.google.com/docs/cloud-messaging/js/send-multiple?hl=ja)もぜひ参考にしてください！

# まとめ

Firebase本当最高！👍👍👍

# おまけ

今回の実装を含めた個人プロジェクトを開発し、運用しています。

このテーマで、[FRONTEND CONFERENCE FUKUOKA2018](https://frontend-conf.fukuoka.jp/)でお話させていただきました。
スライドなどを公開してますので、興味ある方は是非見てください！

・テーマ：AngularとFirebaseで作ったPWA対応アプリで家庭内の課題を解決した話」
・公式プロフィール ：[https://frontend-conf.fukuoka.jp/sessions#c-6](https://frontend-conf.fukuoka.jp/sessions#c-6)
・スライド：[https://slides.com/lighthouse-dev/fec_fukuoka/#/](https://slides.com/lighthouse-dev/fec_fukuoka/#/)

# 参考URL

- [https://angularfirebase.com/lessons/send-push-notifications-in-angular-with-firebase-cloud-messaging/#manifest-json](https://angularfirebase.com/lessons/send-push-notifications-in-angular-with-firebase-cloud-messaging/#manifest-json)
- [https://firebase.google.com/docs/cloud-messaging/js/client?hl=ja](https://firebase.google.com/docs/cloud-messaging/js/client?hl=ja)
- [https://firebase.google.com/docs/functions/get-started?hl=ja](https://firebase.google.com/docs/functions/get-started?hl=ja)
- [https://firebase.google.com/docs/cloud-messaging/js/send-multiple?hl=ja](https://firebase.google.com/docs/cloud-messaging/js/send-multiple?hl=ja)