1. kurosame

    Posted

    kurosame
Changes in title
+Firebase を利用したプッシュ通知の実装
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,415 @@
+# はじめに
+
+仕事で解決したいことがあり、Web Push と Service Worker(以下 SW)の利用を検討しようかなと思っています
+調べると Firebase Cloud Messaging(以下 FCM)を使うと簡単に実装できそうだったので、サンプルアプリを作成してみようと思います
+
+今回実装したコードは以下です
+https://github.com/kurosame/glossary
+
+# FCM の設定(管理画面)
+
+以下から初期設定を行う
+
+<img width="1387" alt="glossary_Firebase_console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168197/b2ec5131-f241-74be-c6cb-94bfc9381b80.png">
+
+管理画面上での設定は以上で、その後実装する上で必要な情報は「プロジェクトの設定」で確認できます
+
+<img width="306" alt="glossary_Firebase_setting.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168197/f4205a08-1f75-e9e2-531d-f40410a6634e.png">
+
+# Firebase の初期設定
+
+以下の情報は「プロジェクトの設定」で見れます
+隠してますけど、これらの情報は公開してもセキュアです
+ただし、Firestore などのリソースにアクセスできてしまうと思うので、使っている場合は適切なルールを設定しておく必要はあります
+
+```ts:@/firebase/config.ts
+const config = {
+ apiKey: '...',
+ authDomain: '...',
+ databaseURL: '...',
+ projectId: '...',
+ storageBucket: '...',
+ messagingSenderId: '...',
+ appId: '...'
+}
+
+export default config
+```
+
+初期化後、messaging を export しておきます
+
+```ts:@/firebase/index.ts
+import firebase from 'firebase/app'
+import 'firebase/messaging'
+import config from '@/firebase/config'
+
+const firebaseApp = firebase.initializeApp(config)
+export const messaging = firebaseApp.messaging()
+```
+
+# FCM の設定(TS)
+
+ここからは、以下の公式ドキュメントに書かれている内容を見ながら実装します
+https://firebase.google.com/docs/cloud-messaging/js/client?authuser=0
+
+今回はトークンを管理しないので、onTokenRefresh コールバック関数の実装は省略してます
+
+また、VAPID も使いません
+cURL で FCM のサーバーキーを Authorization リクエストヘッダーに含めて FCM サーバーに POST して動作確認します
+
+通知のタイプに「通知メッセージ」と「データメッセージ」の 2 種類がありますが、今回は「通知メッセージ」を使います
+
+- 通知メッセージ
+
+ - アプリがフォアグラウンドでの通知は自前で表示させる実装が必要
+ - アプリがバックグラウンドでの通知は SDK を使用して自動表示される
+
+- データメッセージ
+ - フォアグラウンド、バックグラウンドに限らず通知は自前で表示させる実装が必要
+
+動作確認は Chrome のみで行っています
+
+## プッシュ通知許可のダイアログ表示
+
+以下の関数を実行するとブラウザ上にダイアログが出ます
+
+```ts:@/utils/messaging.ts
+export default function initialize(): void {
+ Notification.requestPermission().then(p => {
+ console.info(p)
+ })
+}
+```
+
+パラメーターの`p(permission)`はダイアログに対して行ったアクションのコールバックです
+型定義を見ると`type NotificationPermission = "default" | "denied" | "granted";`となっており、この 3 つのいずれかの文字列が入っています
+
+- granted
+ 許可した場合
+
+- denied
+ ブロックした場合
+
+- default
+ ダイアログの選択を無視した場合(ブラウザをリロードなど)
+
+default の場合のみダイアログが表示される仕様となっています
+
+## SW のデプロイ
+
+通知の許可を得られたら、次はトークンの取得を行います
+しかし、その前に SW を設定する必要があります
+FCM の getToken 関数内で以下の SW を登録している処理があるからです
+`navigator.serviceWorker.registe(DEFAULT_SW_PATH, { scope: ...})`
+
+DEFAULT_SW_PATH の値は`/firebase-messaging-sw.js`です
+よって、ドキュメントルートに`firebase-messaging-sw.js`ファイルを置く必要があります
+
+ローカルで動作確認中であれば、dist 等のパブリックディレクトリに置けばオッケーです
+
+ただ実用性を考えると、アプリは webpack を使ってバンドルしているので、webpack で SW をいい感じに処理してデプロイしてくれるやつがほしいです
+
+SW の後々の拡張性を考えて、Workbox を使うことにしました
+
+```sh
+npm i -D workbox-webpack-plugin
+```
+
+```js:webpack.config.js
+const WorkboxPlugin = require('workbox-webpack-plugin')
+
+module.exports = () => ({
+ plugins: [new WorkboxPlugin.GenerateSW({ swDest: 'messaging-sw.js' })]
+})
+```
+
+```ts:@/utils/messaging.ts
+import { messaging } from '@/firebase/index'
+
+export default async function initialize(): Promise<void> {
+ // ここから
+ if ('serviceWorker' in navigator) {
+ await navigator.serviceWorker
+ .register('/messaging-sw.js')
+ .then(reg => messaging.useServiceWorker(reg))
+ .catch(err => console.error(err))
+ }
+ // ここまで追加
+
+ Notification.requestPermission().then(p => {
+ console.info(p)
+ })
+}
+```
+
+## トークンの取得
+
+通知を許可すると currentPermission が granted となり、トークンが取得できるようになります
+currentPermission が denied の状態でトークンを取得するとエラーをスローします
+
+```ts:@/utils/messaging.ts
+import { messaging } from '@/firebase/index'
+
+export default async function initialize(): Promise<void> {
+ ...
+ Notification.requestPermission().then(p => {
+ // ここから
+ if (p === 'granted') {
+ messaging
+ .getToken()
+ .then(t => console.info(t))
+ .catch(err => console.error(err))
+ }
+ // ここまで追加
+ })
+}
+```
+
+実行するとコンソールにトークンが出力されます
+
+生成されたトークンはブラウザの IndexedDB に保存され、再利用されます
+IndexedDB からトークンオブジェクトを削除すると getToken 時に再度新しいトークンを生成します
+
+## 動作確認
+
+プッシュ通知が来るか確認
+アプリがバックグラウンド時のみ動作します
+フォアグラウンド時に通知を機能させるには onMessage イベントハンドラーの実装が必要になります(後述)
+
+```sh
+curl -X POST \
+-H "Authorization: key=${サーバーキー}" \
+-H Content-Type:"application/json" \
+-d '{
+ "notification": {
+ "title":"test",
+ "body":"testtest"
+ },
+ "to": "${上記で取得したトークン}"
+}' \
+https://fcm.googleapis.com/fcm/send
+```
+
+サーバーキーは管理画面の「プロジェクトの設定」で見れます
+
+また、管理画面上からでも通知を作成して送れます
+
+<img width="567" alt="glossary_–_Cloud_Messaging_–_Firebase_console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168197/7f035d73-8aa7-ed5a-2eb7-40c37c35aa1f.png">
+
+### もし通知が来なかった場合に確認すること
+
+- アプリがフォアグラウンドになっている
+- OS のシステム環境設定で Chrome の通知を ON にしているか
+
+# もっと使えるプッシュ通知にする
+
+現状だとあまり使い物にならないので、最低限以下は実装してみようと思います
+
+- アプリがフォアグラウンド時でもプッシュ通知を送る
+- プッシュ通知にクリックイベントを追加する
+- 複数のデバイスにプッシュ通知を送る
+
+## アプリがフォアグラウンド時でもプッシュ通知を送る
+
+以下のような自前の SW を使う必要があるので、用意します
+
+```js:@/firebase/messaging-sw.js
+importScripts('https://www.gstatic.com/firebasejs/6.2.4/firebase-app.js')
+importScripts('https://www.gstatic.com/firebasejs/6.2.4/firebase-messaging.js')
+
+firebase.initializeApp({ messagingSenderId: '...' })
+const messaging = firebase.messaging()
+```
+
+messagingSenderId は記事の上の方で`@/firebase/config.ts`に書いたやつと同じです
+
+webpack の WorkboxPlugin も修正します
+
+```js:webpack.config.js
+module.exports = () => ({
+ plugins: [
+ new WorkboxPlugin.InjectManifest({
+ swSrc: path.join(__dirname, 'src', 'firebase', 'messaging-sw.js'),
+ swDest: 'messaging-sw.js'
+ })
+ ]
+})
+```
+
+アプリがフォアグラウンド時にプッシュ通知を受信すると onMessage コールバック関数が呼ばれるので、その中で通知を表示する処理を行います
+
+```ts:@/utils/messaging.ts
+import { messaging } from '@/firebase/index'
+
+export default async function initialize(): Promise<void> {
+ ...
+ // ここから
+ messaging.onMessage(
+ (payload: { notification: { title: string; body: string } }) =>
+ navigator.serviceWorker.ready
+ .then(reg =>
+ reg.showNotification(`${payload.notification.title}(foreground)`, {
+ body: payload.notification.body
+ })
+ )
+ .catch(err => console.error(err))
+ )
+ // ここまで追加
+}
+```
+
+`serviceWorker.ready`を使うことで SW の activate を待って処理できます
+
+cURL で通知を POST するとアプリがフォアグラウンド時でもプッシュ通知が表示されると思います
+アプリがバックグラウンド時に POST した場合は、変わらず SDK によって自動生成された通知を表示します(onMessage は呼ばれません)
+
+## 通知にクリックイベントを追加する
+
+リクエストに click_action を追加します
+
+```sh
+curl -X POST \
+-H "Authorization: key=${サーバーキー}" \
+-H Content-Type:"application/json" \
+-d '{
+ "notification": {
+ "title":"test",
+ "body":"testtest",
+ "click_action":"${HTTPSのURL}"
+ },
+ "to": "${上記で取得したトークン}"
+}' \
+https://fcm.googleapis.com/fcm/send
+```
+
+アプリがバックグラウンド時の場合は、SDK を使用して、これだけでクリックイベントが発火します
+
+アプリがフォアグラウンド時の場合は、SW に notificationclick イベントハンドラーを実装して対応します
+
+```ts:@/utils/messaging.ts
+import { messaging } from '@/firebase/index'
+
+export default async function initialize(): Promise<void> {
+ ...
+ messaging.onMessage(
+ (payload: {
+ notification: {
+ title: string
+ body: string
+ click_action: string // これを追加
+ }
+ }) =>
+ navigator.serviceWorker.ready
+ .then(reg =>
+ reg.showNotification(`${payload.notification.title}(foreground)`, {
+ body: payload.notification.body,
+ data: payload.notification.click_action // これを追加
+ })
+ )
+ .catch(err => console.error(err))
+ )
+}
+```
+
+```js:@/firebase/messaging-sw.js
+importScripts('https://www.gstatic.com/firebasejs/6.2.4/firebase-app.js')
+importScripts('https://www.gstatic.com/firebasejs/6.2.4/firebase-messaging.js')
+
+firebase.initializeApp({ messagingSenderId: '...' })
+const messaging = firebase.messaging()
+
+// ここから
+self.addEventListener('notificationclick', e => {
+ e.notification.close()
+ e.waitUntil(clients.openWindow(e.notification.data))
+})
+// ここまで追加
+```
+
+## 複数のデバイスにプッシュ通知を送る
+
+トークンをトピックに紐付けることで実現可能です
+トピックには複数のトークンを紐付けることができます
+そして、トピックを指定してプッシュ通知を送り、複数デバイスへの通知を実現できます
+
+トピックへのトークン追加はリクエストヘッダーにサーバーキーを含むため、サーバー側で処理します
+今回はサーバーを用意してないので、cURL を使って追加します
+
+```sh
+curl -X POST \
+-H "Authorization: key=${サーバーキー}" \
+-H Content-Type:"application/json" \
+https://iid.googleapis.com/iid/v1/${トークン}/rel/topics/${トピック名}
+```
+
+Firebase プロジェクトに存在しないトピックを指定した場合は、そのトピックを新規作成します
+
+トークンが subscribe しているトピックは以下で確認できます
+
+```sh
+curl -X POST \
+-H "Authorization: key=${サーバーキー}" \
+https://iid.googleapis.com/iid/info/${トークン}?details=true
+```
+
+後は、`to`にトークンではなく、トピックを指定して POST すれば、通知が届くはずです
+
+```sh
+curl -X POST \
+-H "Authorization: key=${サーバーキー}" \
+-H Content-Type:"application/json" \
+-d '{
+ "notification": {
+ "title":"test",
+ "body":"testtest",
+ "click_action":"${HTTPSのURL}"
+ },
+ "to": "/topics/${トピック名}"
+}' \
+https://fcm.googleapis.com/fcm/send
+```
+
+### サーバーでトークンをトピックに追加する場合
+
+実際にサーバーで処理する場合は、Firebase Admin SDK が使えます
+今回はサーバーを用意してないので試してませんが、たとえば Node.js であれば以下のような実装になります
+
+```ts
+import admin from 'firebase-admin'
+
+admin.initializeApp({
+ credential: admin.credential.applicationDefault(),
+ databaseURL: 'https://<DATABASE_NAME>.firebaseio.com'
+})
+
+admin.messaging().subscribeToTopic(registrationTokens, topic)
+```
+
+- 参考
+ https://firebase.google.com/docs/admin/setup?hl=ja#initialize_the_sdk
+ https://firebase.google.com/docs/cloud-messaging/js/send-multiple#subscribe_the_client_app_to_a_topic
+
+# プッシュ通知の本番導入にあたり検討したいこと
+
+自分は PC でウェブサイトを見ようとして、いきなり以下のダイアログを出されると反射的にブロックしてしまいます
+
+<img width="316" alt=" 2020-01-16 18.18.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/168197/0b0c446d-dea0-fa2e-5344-6704c4347e34.png">
+
+ブロックする理由は以下です
+
+- 通知の目的が不明
+- ダイアログの位置が邪魔
+- (そもそも通知されるのが嫌な気持ちも少しある)
+
+通知が嫌な場合は無理ですが、通知の目的と位置の問題は工夫できると思います
+
+たとえば、Snackbar を活用して
+
+1. ウェブサイトを閲覧する上で邪魔でない位置に Snackbar を出す
+1. Snackbar に通知の目的の記載と同意ボタン的なのを設置
+1. 同意ボタンを押したユーザーのみ通知許可ダイアログを出す
+
+↑ 位置はサイトを閲覧する上で邪魔にならず、でも許可までの導線をアピールできる場所が良いです
+
+そもそもユーザーすべてが通知を許可するのは不可能なので、プッシュ通知を許可しないとアプリのメインの機能が使えないという状態は避けるべきですね
+あくまでプッシュ通知はプラス α で利用できる便利機能という位置付けだと思います