FCM (Firebase Cloud Messaging) を使ってWebプッシュ通知を実装してみる

# はじめに
今更感はありますが、今までWebプッシュ通知を実装した事がなかったので、Firebase Cloud Messagingを使って実装してみました。

:::note info
Firebase Cloud Messaging(FCM)は、Googleが提供するプッシュ通知サービスです。

https://firebase.google.com/docs/cloud-messaging?hl=ja
:::

# プッシュ通知とは？
プッシュ通知は、iOS、Android、Webなどのデバイスからユーザーに対して通知する機能です。プッシュ通知の種類は、大きく２つに分けられます。
| 種類 | 説明 | 例 |
|--------|------|-----|
| A2P<br>(Application to Person) | アプリケーションからユーザーへのプッシュ通知 | ・ニュースアプリの最新記事通知<br>・ECサイトのセール通知<br>・天気予報の警報通知 |
| P2P<br>(Person to Person) | ユーザーからユーザーへのプッシュ通知 | ・メッセージアプリの新着通知<br>・SNSの投稿通知<br>・チャットの返信通知 |

## プッシュ通知の仕組み
プッシュ通知の仕組みは、任意のトリガーによりサーバーからクライアントアプリにプッシュ通知送信します。

| アクター | 説明 |
|---------|------|
| ユーザー | アプリケーションを利用するエンドユーザー |
| クライアントアプリ | ユーザーが使用するアプリケーション<br>（iOS/AndroidアプリやWebアプリ） |
| プッシュ通知サービス | プッシュ通知の配信を管理するサービス<br>（Firebase Cloud Messagingなどのサービス） |
| バックエンド | アプリケーションのサーバーサイドシステム |

```mermaid
 sequenceDiagram
    actor User as ユーザー
    participant ClientApp as クライアントアプリ
    participant PushServer as プッシュ通知サービス<br>(FCM)
    participant Backend as バックエンド
    
    opt 1.プッシュ通知を受信するための準備
        User->>ClientApp: アプリを開く
        ClientApp->>User: 通知の許可
        ClientApp->>PushServer: トークンを取得
        PushServer->>ClientApp: トークンを返す
        ClientApp->>Backend: トークンを保存
    end

    opt 2.プッシュ通知
        Note over Backend: イベント発火
        Backend->>PushServer: プッシュ通知を配信
        PushServer->>ClientApp: プッシュ通知を配信
        ClientApp->>User: 通知を表示
    end
```

:::note info
#### ユーザーに対して通知の許可が必要
ブラウザでサイトを開いていると、下記のようなポップアップの表示されることがあります。Webアプリでプッシュ通知を表示しようとすると、ブラウザを介してユーザーの許可をもらう必要があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/0c08450f-bce8-4eb4-af44-a1656b0f80cb.png)
:::

:::note info
#### トークンについて
FCMが返すトークンは、ユーザーのデバイスやブラウザを一意に識別するためのものです。
実際にプッシュ通知を送信する際の送り先として利用します。

トークンを取得する際は、VAPID（Voluntary Application Server Identification）という公開鍵と秘密鍵のペアを使用した認証を通す必要があります。

Webプッシュ通知の認証に使用されるです。公開鍵を取得する際にクライアントからFCMに送付し、認証を行います。認証が通るとトークンが発行されます。
:::

### ブラウザがバックグラウンド時の動作について
ブラウザがバックグラウンド時は、ServiceWorkerを使用して、プッシュ通知を受信します。

:::note info
#### フォアグラウンド/バックグラウンドの補足
- フォアグラウンド：ブラウザのタブがアクティブな状態
- バックグラウンド：ブラウザのタブが非アクティブな状態、またはブラウザが最小化されている状態
:::

#### 前提
JavaScriptは、シングルスレッドで実行される言語となります。

#### WebWorkerについて
WebWokerは、メインスレッドとは別スレッドでJavaScriptを動作させることができます。
なので、重たい処理などでメインスレッドに影響ある場合は、WebWokerに重たい処理を逃がすなどの使い方になります。

```mermaid
graph TB
    subgraph "Web Worker"
        A[メインスレッド] <-->|"データのやり取り"| B[WebWorker]
        B -->|"重い処理実行"| B
    end
```
参考：https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API

#### ServiceWorkerについて
ServiceWorkerは、WebWokerの中の1種類にあたります。ブラウザを閉じてもバックグラウンドでスレッドを常駐することができます。
ブラウザをアクティブでないときでもプッシュ通知を受け取りたい場合は必要な機能になります。

```mermaid
graph TB
    subgraph クライアントアプリ
        subgraph "メインスレッド"
            A[Webページ]
        end
    
        subgraph "バックグラウンド"
            B[Service Worker] -->|"通知表示"| A
    
        end
    end
    C[プッシュ通知サービス] -->|"プッシュ通知"| B
    A -->|"登録"| B
```

参考：https://developer.mozilla.org/ja/docs/Web/API/ServiceWorker

# 試してみる
実際にWebプッシュ通知を実装してみます。

### 試す内容
今回は、プッシュ通知を受けたら、下記の表示を行うように実装します。
- フォアグラウンド時：トースト表示
- バックグラウンド時：Windows通知表示

対象の通知先は以下とします。
- Windows11のPC
- Chromeブラウザ

## Step1. Firebase Cloud Messagingの設定
<details><summary>準備：Firebaseにプロジェクトを作成する</summary>

1. Firebaseのサイトにログインします
https://firebase.google.com/?hl=ja

2. 画面右上の「Go to console」を押します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/3b999c58-0398-4898-aa69-1cf4b32252e2.png)

3. 「Firebase プロジェクトを使ってみる」を押します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/1931a5db-9274-4e80-8a7c-1a172357731b.png)

4. 「プロジェクト名」に任意の名前を入力します。「規約」などにチェックを入れ、画面右下の「続行」ボタンを押します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/60acf3a2-90a0-4bce-8109-30323a6b871f.png)

5. 途中、AIを有効にするかなど聞かれますが、任意の選択にして「プロジェクトを作成」ボタンを押します

6. 少し待つとプロジェクトの準備ができるので、「続行」ボタンを押します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/a7ca8eb6-719a-4737-8fd3-4f0d74bb4d39.png)

7. これで準備ができました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/65b4a13b-f852-4f3b-9fd7-8e92e38b5e0c.png)
</details>

### Firebaseにアプリを設定する
1. 歯車アイコンを押して、プロジェクトの設定画面を開きます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/d3994de9-d7c9-4c6c-88a7-c77a8e062c84.png)

2. プロジェクトの設定画面で下までスクロールして、Webのアイコンを押します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/243ab82c-752d-42cd-a7e4-cf8f86448c32.png)

3. 任意のニックネームを入力して、「アプリを登録」ボタンを押します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/74bc828a-854c-46c2-b9fa-87f2de53440f.png)

4. アプリへのSDKの組み込み方が表示されますが、そのまま進みます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/5dcf705c-531b-462d-98b9-0e18ee6120ab.png)

5. ウェブアプリが追加されました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/139c649a-9fc5-4768-8940-15cc01d636cf.png)

### FCMのキーペアを作成する
1. プロジェクト設定から「Cloud Messaging」タブを押します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/e98b9f96-762c-47ac-b132-4b1a7657f6df.png)

2. 下までスクロールして「GEnerate key pair」ボタンを押します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/25a0de99-a9fc-4f46-b126-3c54927e70f0.png)

3. 作成できました。(キーペアは後ほど使います)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/f47030f7-fdb6-4098-9f56-64a1f434128a.png)

## Step2. Webアプリの実装
### プロジェクトを作成する
1.Viteを使用し、 React と TypeScript のプロジェクトを作成します
```
npm create vite@latest

√ Project name: ... push_notification_app
√ Select a framework: » React
√ Select a variant: » TypeScript
```
2.必要な依存パッケージをインストールします
```
cd <プロジェクト名>
npm install
```

### トークン取得処理を実装
Firebase Cloud Messagingとフロントエンドがやり取りするため、SDKをインストールします

```
npm install firebase
```

### バックグラウンドの受信処理
Webブラウザのバックグラウンドでプッシュ通知を受けるため、ServiceWorkerを利用します。
```javascript:./public/firebase-messaging-sw.js
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js');

// 本ファイルはビルドを通らないので、バックグラウンド用の設定が必要
firebase.initializeApp({
  apiKey: "API_KEY",
  authDomain: "push-notification-20a64.firebaseapp.com",
  projectId: "push-notification-20a64",
  storageBucket: "push-notification-20a64.firebasestorage.app",
  messagingSenderId: "417934552820",
  appId: "1:417934552820:web:71827d1886afd79b2678a1"
});

const messaging = firebase.messaging();

messaging.onBackgroundMessage((payload) => {
  // ブラウザがバックグラウンド中にプッシュ通知を受けると呼ばれる
  // Firebase SDKが自動でshowNotificationを実行する
  // https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerRegistration/showNotification
  console.log('[firebase-messaging-sw.js] Received background message ', payload);
});

```

:::note info
**firebase-messaging-sw.jsをルートに置けば登録処理は不要**
firebase-messaging-sw.jsは、Firebase用のServiceWorkerファイルで、ルートフォルダに置くとSDKが自動的に読み取って、ServiceWorkerに登録してくれるので、登録処理は不要です。
今回は、publicフォルダに配置することで対応しています。
:::

### フォアグランドの受信処理
トースト表示に必要なライブラリをインストールします。
```
npm install @mui/material @emotion/react @emotion/styled
```

設定ファイルを新規作成して、FCMと接続するために必要な設定を記述します。
```ts:./src/config.ts
export const firebaseConfig = {
  apiKey: 'API_KEY',
  authDomain: 'PROJECT_ID.firebaseapp.com',
  databaseURL: 'https://PROJECT_ID.firebaseio.com',
  projectId: 'PROJECT_ID',
  storageBucket: 'PROJECT_ID.appspot.com',
  messagingSenderId: 'SENDER_ID',
  appId: 'APP_ID',
  measurementId: 'G-MEASUREMENT_ID'
};

export const vapidKey = '<YOUR_PUBLIC_VAPID_KEY_HERE>';
```

Viteのデフォルトコードをベースに、フォアグラウンドでプッシュ通知を受信した際の処理を記載します。
```diff_tsx:./src/App.tsx
import { useState, useEffect } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
+ import { type NotificationPayload } from 'firebase/messaging';
+ import { initializeApp } from "firebase/app";
+ import { getToken, getMessaging, onMessage } from "firebase/messaging";
+ import { firebaseConfig, vapiKey } from './config';
+ import { Snackbar, Alert, AlertTitle } from '@mui/material';

+ const app = initializeApp(firebaseConfig);
+ const messaging = getMessaging(app);

+ function requestPermission() {
+   console.log('Requesting permission...');
+   Notification.requestPermission()
+   .then((permission) => {
+     if (permission === 'granted') {
+       console.log('Notification permission granted.');
+     }
+   })
+ };

function App() {
  const [count, setCount] = useState(0);
+ const [message, setMessage] = useState<NotificationPayload | undefined>(undefined);
+ const [open, setOpen] = useState(false);

+ const handleClose = () => {
+   setOpen(false);
+ };

+ useEffect(() => {
+   getToken(messaging, {vapidKey: vapiKey})
+     .then((currentToken) => {
+       if (currentToken) {
+         console.log('トークンを取得しました。', currentToken);
+       } else {
+         console.log('トークンが利用できません。');
+         requestPermission();
+       }
+     })
+     .catch((err) => {
+       console.log('トークンの取得に失敗しました。', err);
+     });
+ }, []);

+ onMessage(messaging, (payload) => {
+   console.log('Message received. ', payload);
+   if (payload.notification) {
+     setMessage(payload.notification);
+     setOpen(true);
+   }
+ });

  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count is {count}
        </button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
+     <Snackbar 
+       open={open}
+       autoHideDuration={3000}
+       onClose={handleClose}
+       anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
+     >
+       <Alert onClose={handleClose} severity="info" sx={{ width: '100%' }}>
+         <AlertTitle>{message?.title}</AlertTitle>
+         {message?.body}
+       </Alert>
+     </Snackbar>
  </>
  )
}

export default App

```

3.　ローカル環境でアプリケーションを起動してみます。
```
npm run dev
```

## Step3. FCMからテストメッセージを送ってみる

1. Firebaseのが画面からMessagingのメニューを開き、「最初のキャンペーン作成」ボタンを押す
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/8e9df96e-945e-4aeb-b37a-b099764aa55b.png)

2. ダイアログが表示されるので、`Firebase Notification メッセージ`のまま、「作成」ボタンを押す
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/b38158b5-3260-4272-88cf-913e133d03e9.png)

3. 任意の通知タイトルと通知テキストを入力して、「テストメッセージを送信」ボタンを押す
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/ef0059b6-c77f-4db7-8106-393c107ed5cf.png)

4. 今回実装したフロントエンドで取得したトークンを入力する 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/3517e299-0bd9-4ba5-b3f8-eae7d4aed0b6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/24e8269f-90c9-4ee4-80ff-f5bbd8be4eac.png)
(※ 今回は、取得したトークンをコンソールログに表示してたので、開発者ツールを開いてトークンを確認しました。)


#### フォアグラウンドの場合...
でました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/54f831a8-b0af-44df-b413-cb4b7b0e047a.png)

#### バックグラウンドの場合...
でました (Windowsの通知として受信しました)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/560123/76125901-4635-42b7-a5ee-ab1dfb17839c.png)

これでひとまずWindows11のChromeブラウザで表示することが出来るようになりました。

# 所感
今回、Firebase Cloud Messagingを使ってWebプッシュ通知を実装してみました。
よくある技術ではありますが、改め実装しようとすると、意外と難しいなというのが率直な感想です。
(ServiceWorkerとFirebaseのSDKの絡みの実装に手こずりました。)

次回は、AWS End User Messaging(Amazon Pinpoint)と絡めて、今回作ったWebプッシュ通知をリッチにしていきたいと思います。