Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

【簡単】プッシュ通知をPWAで爆速実装する【firebase】

Posted at

はじめに

LINE Notify終了のお知らせです。
2025年3月31日にサ終ということで。

これを受けて
「自分のアプリからの通知をLINEで受け取るようにしていたのに...」
となった方は、ネイティブアプリをわざわざ作らなくても、Webアプリでもスマホにプッシュ通知を送れますのでぜひご一読あれ。

今回はPWAからプッシュ通知を受け取る仕組みを、firebaseを使って作成していきます。

Cloud Messaging API(Legacy HTTP API)は2024年6月20日をもって廃止となったので
最新の手法でPWAを用い、なるべくバニラなjsでの実装をしていきます。

※なお、当たり前ですが投稿時点で筆者が実際に動作を確認しており、なんなら筆者のアプリから毎日プッシュ通知を受け取っています。

目次

  • イメージ
  • スタート
  • 鍵を2つ取得
  • アプリを作成
  • コード用意しておきました
  • CSP設定

イメージ

通知の仕組みの全体像は次のようになります。
全体像.png

スタート

割と面倒だったので、firebase側の設定手順もしっかり乗せておきます。
まずはfirebaseでプロジェクトを作成します。firebaseコンソールにアクセスし、「プロジェクトを作成」から誘導に従って進めます

1.png
2.png
3.png
4.png

ちょっと時間かかりますが、作成されるとこうなります
5.png
6.png

鍵を2つ取得

2つの鍵が必要になります。

  1. vapiKey
  2. サービスアカウントキー

コードを書く前に、プロジェクトの設定で鍵系統のものを準備しちゃいましょう。
7.png

Cloud Messagingの下にある
8.png
9.png

Generate key pairを押して、表示された文字をコピーしてください。
これは「vapikey」としてあとで使います。

また、サービスアカウントを選択して
10.png

新しい秘密鍵を作成します。
11.png

ボタンを押すと、「dummy-25310-firebase-adminsdk-ln9XXXXXXX.json」のようなファイルがダウンロードされます。
これもあとサービスアカウントキーとして、バックエンドからの通知送信時に使いますので
お好きなファイル名にリネームして持っておきます。


これは余談ですが、サービスアカウントと聞いてGoogleCloudに慣れている方は、似たようなことをGCPコンソールからよくやったと思いますが、firebaseはGooglgeに買収されたとはいえ、ここでのサービスアカウントにOAuth認証同意画面の設定等は必要ありません。安心してください。

アプリを作成

次に、「プロジェクトの設定 > 全般」からfirebaseプロジェクトの中に、アプリ作成ということをしていきますが
ここでいうアプリとはjsなどを使ったWebアプリではなく、firebaseが通知を送るためのアプリを作るというイメージです。

これを実行することでfirebase SDKというものを使うための設定値が作成されるので、設定値がほしいからやるだけです。
変にアプリをホスティングしなきゃいけないとかはないです。

12.png

今回はバニラなjsで軽く実装していくので
</> みたいなボタンを押します。

13.png

前述したfirebase SDKとはこういうもので、要はライブラリです。

importして使うfirebaseが用意した、通知のためのライブラリが各言語ごとにあり
firebase SDKを使うために、アプリ作成で作成した設定値をinitializeApp関数に入れてフロントのコードが動くので
通知が飛ぶ、といった仕組みです。

14.png

ここはあとでコピペできるので、下部の「コンソールに進む」を押します

15.png

こんな感じになったら、firebaseConfigをjsから呼びたいのでコピペしておきます。


通知の仕組みは

  1. フロントのコード上から、端末ごとにFCMトークンというものを発行
  2. あなたのサーバに送り、DBに入れるなりしておく
  3. FCMトークンは端末ごとの目的地になるようなイメージ
  4. FCMトークンめがけて通知を飛ばす = 端末ごとに通知を送る

フロント: 「firebaseSDK+アプリ設定値」でFCMトークン発行しバックエンドに送る
バックエンド: 「サービスアカウントキー」で、FCMトークン宛てに通知を送る

といった理解で問題ないです。

コード用意しておきました

さて

  • 2つの鍵
  • firebaseSDKを使うための設定

が準備できましたので、いよいよコードを作ります。

フロント

まずはindex.htmlとPWAのためにmanifest.jsonです
icon.jpgとかは適宜用意してください。

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="manifest" href="manifest.json">
    <link rel="icon" href="icon.jpg">
    <title>お好きなタイトル</title>
    <script>
        if ('serviceWorker' in navigator) {
            navigator.serviceWorker.register('firebase-messaging-sw.js')
                .then((registration) => registration.update())
                .catch((error) => console.error(error));
        }
    </script>
</head>

<body>
</body>

<script type="module" src="notice.js"></script>

</html>
{
    "manifest_version": 2,
    "version": "1",
    "short_name": "お好きな名前",
    "name": "お好きなタイトル",
    "display": "standalone",
    "start_url": "index.html",
    "background_color": "#000000",
    "icons": [
        {
            "src": "icon.jpg",
            "sizes": "512x512",
            "type": "image/jpg"
        }
    ]
}

次にServiceWorkerと、通知処理のためのjsを作ります。
さて、firebase-messaging-sw.jsについて注意点です。

  1. ファイル名は今回は「firebase-messaging-sw.js」固定
  2. 公開サーバのドメイン直下に置く必要がある

nodejsとかだとconfigファイルを追加すればいろいろ変更できるみたいですが、今回はそんな高尚なことはせずにfirebaseSDKのデフォルト設定をそのまま使う必要があるためこうなります。
理由は

です。(私の試した感じたぶんそう、ってだけですが)

firebase-messaging-sw.js
// バージョン8.10.1にしていますが
// バージョン9以上だと何かimportの方法が変更されていたりするらしいので
// このバージョンがあんぱい
importScripts("https://www.gstatic.com/firebasejs/8.10.1/firebase-app.js");
importScripts("https://www.gstatic.com/firebasejs/8.10.1/firebase-messaging.js");

// アプリ作成でコピペしたConfig
const firebaseConfig = {
    apiKey: "XXX",
    authDomain: "XXX",
    projectId: "XXX",
    storageBucket: "XXX",
    messagingSenderId: "XXX",
    appId: "XXX",
    measurementId: "XXX"
};

// 取得しておいた鍵のひとつ、vapiKey
const vapidKey = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";

firebase.initializeApp(firebaseConfig);
const messaging = firebase.messaging();

// ServiceWorker登録時に、FCMトークンを取得します
// TODO 後述の理由から、ほんとはここでやらないです!
messaging.getToken({vapidKey: vapidKey}).then(async (currentToken) => {
    if (currentToken) {
        console.debug("[firebase-sw] FCMトークン取得に成功");
        console.debug(currentToken);
        await fetch("https://あなたのサーバのトークン保存API", {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({token: currentToken}),
        })
    } else {
        console.debug('[firebase-sw] FCMトークン取得に失敗');
    }
}).catch((err) => {
    console.debug(err);
    console.debug('[firebase-sw] 通信エラー FCMトークン取得に失敗');
});

// PWAアプリがバックグラウンド状態でfirebaseから通知を受け取ったときの処理
// XXX 注意!ここではJSのNotificationAPIで通知処理を書いていますが
// 実際は書かなくても勝手にプッシュ通知されます
// ここに書いたままだと2重に通知が来ます
messaging.onBackgroundMessage((payload) => {
    console.debug('[Background] Message received. ', payload);
    if (Notification.permission === 'granted') {
        // タイトルやbody意外にもいろいろオプションがあります
        self.registration.showNotification(payload.notification.title, {
            body: payload.notification.body,
            icon: payload.notification.image,
        });
    }
});

※なお、ServiceWorker JSからはlocalStorageを使えないので、確実にfetch等で送信しておく必要があります。
それかIndexedDBに持っておいてください。


一応補足しておきますが
ここで使っている技術は以下だけで、バニラなJSしか使っていません。

  1. とにかく設定した値と、firebaseが用意しているライブラリをimportしている(CDNで持ってきています)
  2. JSのfetchで、FCMトークンをあなたのサーバに保存する(なにやらユーザID的なものと紐付けておくといいと思いますよ)
  3. JSのNotificationAPIで、プッシュ通知処理をする(コード中にもありますが、firebaseが勝手にプッシュ通知までしてるっぽくてこのままだと2重です。)

2重に受け取らないためには
self.registration.showNotificationをしなければいいだけなんですが
例えば、通知の未読件数をアプリ画面に出したい!というために

ここで受け取った際に、メッセージ文をindexedDBに保存しておき
アプリ画面からも同じindexedDBを見て、件数や未読メッセージ一覧を取得などできます。


衝撃の事実なんですが iOSでは明示的なクリックなどのアクションでないと、通知許可のダイアログがでません。

ServiceWorker内でやっているmessaging.getTokenですが、これは内部で*Notification.requestPermission()*を呼んでいるそうです。
しかし、初回ロード時にこのコードが実行されているので、確認ダイアログはでません。

よって、最初の通知トークン取得は、ボタン押下時の関数にしなきゃダメです

window.onloadとか、画面開いた瞬間とかにrequestPermissionを実行しても何
しかしなにもおこらなかった!です。


また、アプリがフォアグラウンド(いま開いてるってこと)の状態や
通知の許可をONにするためのjsも作ります。
先ほどのindex.htmlから読んでいるnotice.jsです

// こっちのimportは最新バージョンで問題なかったです
import {initializeApp} from "https://www.gstatic.com/firebasejs/10.12.5/firebase-app.js";
import {getMessaging, onMessage, getToken} from 'https://www.gstatic.com/firebasejs/10.12.5/firebase-messaging.js';

// アプリ作成でコピペしたConfig
const firebaseConfig = {
    apiKey: "XXX",
    authDomain: "XXX",
    projectId: "XXX",
    storageBucket: "XXX",
    messagingSenderId: "XXX",
    appId: "XXX",
    measurementId: "XXX"
};

// 取得しておいた鍵のひとつ、vapiKey
const vapidKey = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";

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

// FCMトークン発行はservice workderで1回行う
// ここではアプリ(ブラウザ)の通知を受け取るための設定を促す
(() => {
    if (!('Notification' in window)) {
        alert('通知が未対応のブラウザです');
    } else if (Notification.permission === "granted") {
        // すでに通知の許可がでている
    } else if (Notification.permission !== "denied") {
        // 通知許可を求める
        // iOSのPWAだとここはダイアログがでないので無意味
        Notification.requestPermission().then((permission) => {
            if (permission === 'granted') alert('通知の許可がもらえました');
            if (permission === 'denied') alert('通知の許可がもらえませんでした');
        });
    }
})();

// 通知の許可の確認ダイアログを出し、許可がもらえたらFCMトークンを取得する関数を作成し
// ボタンクリックとかのイベントから呼びだす!
window.getFcmToken = async () => {
    await getToken(messaging, {vapidKey: vapidKey}).then(async (currentToken) => {
        if (currentToken) {
            // FCMトークンを一時保存
            alert("get FCM token")
            console.log("[info] FCMトークン取得に成功");
            console.log(currentToken);
            // TODO サーバに送ってDB登録するなど
        } else {
            alert("miss FCM token")
            console.log('[info] FCMトークン取得に失敗');
        }
    }).catch((err) => {
        console.log(err);
        console.log('[info] 通信エラー FCMトークン取得に失敗');
        alert("miss FCM token (connection error)")
    });
};

// フォアグラウンド
// ここも2重になるので、通知は不要
// なお、iOSのSafariでのPWAの場合、フォアグラウンド判定にならないことがとても多くて実は困ってます ;w;
onMessage(messaging, (payload) => {
    console.log('[Foreground] Message received. ', payload);
    if (Notification.permission === 'granted') {
        navigator.serviceWorker.ready.then(registration => {
            registration.showNotification(payload.notification.title, {
                body: payload.notification.body,
                icon: payload.notification.image,

            });
        });
    }
});

ファイルの配置は以下のようなイメージだと
ドメイン直下のページに今回のPWAが配置されます。
firebase-messaging-sw.js以外はお好きな階層で構いません。

サーバの/var/www/html直下
.
├── index.html
├── notice.js
├── manifest.json
└── firebase-messaging-sw.js

また、完全に余談ですが
ここまでCDNメインで作成してきたので、Reactがお好きでしたら
本番稼働には向きませんが、個人向けでしたらいっそ全部CDNでやったれということで

    <!--react-->
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>
    <!--tailwind-->
    <script src="https://cdn.tailwindcss.com"></script>
    <!--google icon-->
    <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0"
        rel="stylesheet" />

こんな感じでReactやtailwindを使いつつ、Google Material Iconで装飾していきましょう。

バックエンド

定時とかで通知を送る処理を書きます。今回はPythonで書きます。

pip install firebase-admin
import firebase_admin
from firebase_admin import credentials
from firebase_admin import messaging
from pathlib import Path
import json

# 取得しておいた2つの鍵のひとつ、サービスアカウントキーのダウンロードしたjsonを指定します
key_path = Path(__file__).resolve().parent.joinpath('service_account_key.json')
cred = credentials.Certificate(key_path)
firebase_admin.initialize_app(cred)

def getTokenList():
    # TODO DBとかからFCMトークンを取得する
    # 手動で動作確認したければ、フロントのconsole.debugで確認したFCMトークンをコピペ
    #return ["フロントで作成されたFCMトークン"]
    return tokenlist

# 通知を送っていきます
# 複数のトークンへ通知を送る関数もあるらしいですが今回はforでいいや
# https://firebase.google.com/docs/cloud-messaging/send-message?hl=ja#send-messages-to-multiple-devices
for token in getTokenList():
    msg = messaging.Message(
        notification=messaging.Notification(
            title="タイトル",
            body="本文",
            image="通知アイコン画像URL"
        ),
        token=token,
    )
    res = messaging.send(msg)

これを

python main.py

などのように実行すると、通知が飛びます

フロントからFCMトークンをバックに送る部分は同じ言語で書くとすると
pythonでfastapiを使ったとしたらこんな感じになります

pip install uvicorn fastapi
import json
from pathlib import Path
import uvicorn
from fastapi import FastAPI, Request, HTTPException
from starlette.middleware.cors import CORSMiddleware

app = FastAPI()
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:8000"], # フロントのホストをCORS許可
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"])

@app.post("/fcmtoken")
async def postToken(req: Request):
    r = json.loads(await req.body())
    token = r["token"]  # type:ignore
    # TODO DBに書き込み
    return {"status": "regist token"}

if __name__ == "__main__":
    uvicorn.run("app:app", host="0.0.0.0", port=8080, reload=True)
python app.py

などで起動して確認していきましょう。

また、バックエンドはDockerで動かすことが多いと思いますので
cron想定のDockerfileもおいておきますね

FROM python:3.11.4-slim-bullseye
RUN apt update \
    && apt upgrade -y \
    && apt install -y cron \
    && export TZ=Asia/Tokyo \
    && echo $TZ > /etc/timezone

# 適宜、資材配置
COPY src src

RUN cd src \
    && python -m pip install -U pip \
    && python -m pip install -r requirements.txt

COPY src/crontab /var/spool/crontab/root
RUN crontab /var/spool/crontab/root

CMD ["sh", "/src/init.sh"]

これはsrcフォルダ配下が以下のようになっている想定です

src
├── app.py # FastAPIのスクリプト
├── main.py # 通知送信のスクリプト
├── requirements.txt # 下で説明します
├── crontab # 下で説明します
└── init.sh # 下で説明します

コンテナ起動時にfastapi起動とcron起動をしたいので
CMDには2つコマンドは書けないので、shellにまとめておきます

init.sh

cron start
python /src/app.py

また、必要なライブラリのためにrequirements.txtは

firebase-admin
fastapi
uvicorn

としておき、crontabはコンテナ内で「/var/spool/crontab/root」にコピー後に

crontab <ファイル名>コマンドで登録し、起動時にcron startで起動させます。

srcで作っておくcrontabファイルの中身は定時で通知スクリプトを送るようにしておきます

5 * * * * /usr/local/bin/python main.py

python main.pyでなく/usr/local/bin/pythonとしているのは
docker exec -it コンテナ名 sh等でコンテナにログインしてwhich pythonするとわかるのですが、cronからのpythonコマンド実行時にパスが通っていないので
/bin/sh python not foundみたいなエラーになります。そのためパスを通すか、python実行バイナリまでのフルパスを指定するかというわけです。
なお、cronの文法はこちらやChatGPTで確認してください。

CSP設定

私はwebサーバでCSPを設定しているため、フロントから自身のドメイン以外のリソースへのアクセスを制限しています。
CSP設定している場合、firebaseのsw.jsのライブラリやFCMトークン取得のための通信のために、一部ドメインをホワイトリスト登録する必要がありますので
以下のような追加が必要です

対象のドメインはこの2つです

  • firebaseinstallations.googleapis.com
  • fcmregistrations.googleapis.com
    Header set Content-Security-Policy "\
        default-src 'self'\
            ...中略
            firebaseinstallations.googleapis.com\
            fcmregistrations.googleapis.com\
            ...

おわりに

この記事の投稿時点でfirebaseの通知関連の記事は多くありますが
古くて参考にならなかったり、公式と言っていることが違ったりするので、最終的には公式を参考にするのがよいです。

記事中のコードでiOSでのPWAで通知がくる動作を確認済みですので、参考にしていただければと思います。

0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?