Laravel + Reactで実現するWebプッシュ通知!ChatGPTと共にiOS 16.4で試してみた結果

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、世の中には「うーん、僕が生きている間はムリなんじゃない😅」と思うことがいくつかありますよね。

例えば、「サクラダファミリア」は「完成しない」で有名なものの一つですね。

では、我らITエンジニアの中では何と言うと・・・・・・

iOSがウェブプッシュを実装することはないんじゃないか😅

です。

というのも、一節によるとAppleはウェブの世界をほぼ独占しているGoogleに対抗するためにモバイル内で完結するアプリを主軸にしていて、となると結局は敵が喜ぶことはしないんじゃないだろうか…という事のようです。

しかし、この間ニュースでこのウェブプッシュがiOS 16.4でサポートされたと聞きました!(トムとジェリーが仲良くスタバでカフェしてる、しかもペアルック、ぐらいの衝撃でした😂)

📝 参考記事: 「iOS 16.4」の配信がスタート、ウェブプッシュ通知や通話の音声分離などが可能に

そこで❗

今回は「Laravel + React」でiOSのウェブプッシュを使う機能を開発してみます。(とはいえAndroidでも同じコードです)

ぜひ何かの参考になりましたら嬉しいです。

※ なお、時代の流れにアップデートする意味もこめて、そして(ここ重要なのですが)裏テーマとして「少し複雑な開発でホントに役立つのか」の検証のためにChatGPTを使ってみます。というのも、ChatGPTを併用した開発の動画を見たことがありますが、CRUDなどシンプルなものしかなかったからです。

(誰でも使いやすいように3.5を使います。なお、まだどこにバグが入っているのかわからず、怖いのでお仕事の開発でChatGPTは使っていません。m(_ _)m)

「自分に似た性格の
ビジネス系 YouTuber 探して
その動画をチェックしてます👍」

開発環境: Laravel 10.x、React、ChatGPT 3.5、Inertia.js、TailwindCSS

前提として

今回の作業は以下の前提があります。

  • Laravel Breeze などでログイン機能がインストールされている
  • さらにテストユーザーが最低でも1人は存在している

手順を聞いてみる

では、ChatGPTに今回の機能を実装するための手順を聞いてみましょう。

ChatGPT:
あなたは優秀なプログラマとして回答してください。
Laravel 10.x でウェブプッシュ機能をつくる手順をステップ・バイ・ステップで教えてくださいますでしょうか。

※ ホントか嘘か、丁寧に接すると回答のクオリティが上がるらしいですね😄

すると、以下のように回答してくれました。

  1. プッシュ通知のライブラリをインストールする
  2. VAPIDキーを生成する
  3. プッシュ通知用のマイグレーションを作成する
  4. プッシュ通知を送信するための通知クラスを作成する
  5. プッシュ通知を送信するルートを作成する
  6. ウェブページにサービスワーカーを登録する
  7. サービスワーカーを登録する

※ ただ、残念なことにこの手順は正しいと思うものの、各説明文は間違っている部分が多くあったため、途中で言われたとおり作業するのはムリだと判断しました。

ということで、この手順に従いながら(でも、作業内容は従わないように)作業を進めていきましょう😂

プッシュ通知のライブラリをインストールする

では、Laravel Notification Channelsのウェブプッシュ用のパッケージをインストールします。

composer require laravel-notification-channels/webpush

VAPIDキーを生成する

続いて、ウェブプッシュに必要になるVAPIDキーを作ります。
以下のコマンドを実行してください。

php artisan webpush:vapid

すると、.envにキーが追加されることになりますので確認しておきましょう。

.env

VAPID_PUBLIC_KEY=(あなたのパブリックキー)
VAPID_PRIVATE_KEY=(あなたのプライベートキー)

プッシュ通知用のマイグレーションを作成する

では、DBテーブルの作成です。
ChatGPTは以下のコマンドを提案してきました。

// ⚠注意: これは正しくありません

php artisan make:migration create_web_push_notifications_table

しかし、本家のサイトではそうではなくパッケージからパブリッシュ(コピー)してねとなっていますので、ここは無視して以下のコマンドを実行します。

php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="migrations"

すると、マイグレーションファイルがdatabase/migrationsフォルダに作成されます。

続いて、本家サイトにしたがって以下のコマンドを実行します。

php artisan migrate

すると、実際のテーブルはこうなりました。

プッシュ通知を送信するための通知クラスを作成する

続いて、Notificationを継承した専用クラスを作ります。
以下のコマンドを実行してください。

php artisan make:notification WebPushNotification

なお、先ほどChatGPTが提案してきた手順にはクラスのコードはありませんでしたので、以下の質問で提案してもらい、間違った部分を変更することにしました。

ChatGPT:
あなたは優秀なプログラマとして回答してください。
laravel-notification-channels/webpushのために必要な通知クラスを作ってくださいますでしょうか。
対象はLaravel 10.xです。

すると、以下のコードを提案されました。

// ⚠注意: このコードは正しくありません。

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\{MailMessage, WebPushMessage};
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushMessage as PushMessage;
use NotificationChannels\WebPush\WebPushChannel;

class WebPushNotification extends Notification
{
    use Queueable;

    public function via($notifiable)
    {
        return [WebPushChannel::class];
    }

    public function toWebPush($notifiable, $notification)
    {
        return (new PushMessage)
            ->title('New Notification')
            ->body('This is a test notification')
            ->action('View', 'view_notification')
            ->data(['id' => $notification->id]);
    }
}

ただし、このコードには間違いと変更点が入っていました。

まず「WebPushMessage」のネームスペースの部分です。
ここはphpstormが「このクラスは存在していません」と教えてくれました。

use Illuminate\Notifications\Messages\{MailMessage, WebPushMessage};

また、今回はQueueを使わないのでShouldQueueQueueableを削除し、最終的には以下のようになりました。

app/Notifications/WebPushNotification.php

<?php

namespace App\Notifications;

use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushMessage as PushMessage;
use NotificationChannels\WebPush\WebPushChannel;

class WebPushNotification extends Notification
{
    public function via($notifiable)
    {
        return [WebPushChannel::class];
    }

    public function toWebPush($notifiable, $notification)
    {
        return (new PushMessage)
            ->title('New Notification')
            ->body('This is a test notification')
            ->action('View', 'view_notification')
            ->data(['id' => $notification->id]);
    }
}

プッシュ通知を送信するルートを作成する

提案されたのは、正しくなかったためここは自力で作成しました。

まず、以下のコマンドでコントローラーを作成します。

php artisan make:controller WebPushController

そして中身を以下のようにします。

app/Http/Controllers/WebPushController.php

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Notifications\WebPushNotification;

class WebPushController extends Controller
{
    public function index()
    {
        return Inertia::render('WebPush', [
            'vapidPublicKey' => env('VAPID_PUBLIC_KEY')
        ]);
    }

    public function store(Request $request)
    {
        // バリデーションは省略しています

        $endpoint = $request->endpoint;
        $token = $request->keys['auth'];
        $key = $request->keys['p256dh'];
        $user = User::find(1); // テストのため、特定のユーザーをつかう
        $user->updatePushSubscription($endpoint, $key, $token);

        return redirect()->route('web_push.index');
    }

    public function test()
    {
        $user = User::find(1); // テストのため、特定のユーザーをつかう
        $user->notify(new WebPushNotification());
    }
}

なお、index()はウェブプッシュを登録するためのReactページです。

そして、これに対応するビューは次のとおりです。

resources/js/Pages/WebPush.jsx

import PushNotificationButton from "@/Components/PushNotificationButton";
import {Head} from "@inertiajs/react";

export default function WebPush(props) {

    return (
        <div>
            <Head>
                <link rel="manifest" href="/manifest.json" />
            </Head>
            <h1>Web Push のテスト</h1>
            <PushNotificationButton vapidPublicKey={props.vapidPublicKey} />
        </div>
    );

}

なお、この中で必要なmanifest.jsonは後で作成します。(実は、これがないとiOSではウェブプッシュが効きません😫)

また、ルートは以下のとおりです。

routes/web.php

use App\Http\Controllers\WebPushController;

// 省略

Route::get('web_push', [WebPushController::class, 'index'])->name('web_push.index');
Route::get('web_push/test', [WebPushController::class, 'test'])->name('web_push.index');
Route::post('web_push', [WebPushController::class, 'store'])->name('web_push.store');

また、Userモデルからウェブプッシュの登録ができるようにHasPushSubscriptionsトレイトが使えるようにします。

// 省略

use NotificationChannels\WebPush\HasPushSubscriptions;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable, HasPushSubscriptions;

// 省略

サービスワーカー本体をつくる

※ ChatGPTは「ウェブページにサービスワーカーを登録する」として提案していますが、それは次の項目なので、タイトルを変更しています。

では、JavaScript側の部分になります。

以下のコードを保存してこれをJavaScript側から呼び出せるようにしていきます。(なお、これはChatGPTが提案したものそのままです。スバラシイ!)

public/sw.js

self.addEventListener('push', event => {
    const data = event.data.json();
    event.waitUntil(
        self.registration.showNotification(data.title, {
            body: data.body,
            icon: data.icon,
            data: {
                url: data.url
            }
        })
    );
});

self.addEventListener('notificationclick', event => {
    event.notification.close();
    event.waitUntil(
        clients.openWindow(event.notification.data.url)
    );
});

サービスワーカーを登録する

次に、サービスワーカーを登録するためReact部分をつくっていきましょう。
ここもコードは一部分だけだったので、ChatGPTに提案してもらいました。

ChatGPT:
あなたは優秀なプログラマとして回答してください。
Laravel 10.x、Inertia.js、Reactを使ってウェブプッシュの登録ができるボタンを作ってくださいますでしょうか。

すると以下のコードを提案してくれました。

resources/js/Components/PushNotificationButton.jsx

// ⚠注意: このコードは正しくありません

import { useState } from 'react';
import { Inertia } from '@inertiajs/inertia';

function PushNotificationButton() {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState('');

    const handleClick = async () => {
        setIsLoading(true);
        setError('');

        try {
            // Service Worker が利用可能かどうかを確認する
            if ('serviceWorker' in navigator) {
                const registration = await navigator.serviceWorker.getRegistration();

                // Service Worker が登録されているかどうかを確認する
                if (registration) {
                    // Push 通知の許可を求める
                    const permission = await Notification.requestPermission();

                    // 許可が得られた場合
                    if (permission === 'granted') {
                        // プッシュ通知の登録を行う
                        const subscription = await registration.pushManager.subscribe({
                            userVisibleOnly: true,
                            applicationServerKey: 'application_server_public_key'
                        });

                        // サーバーに登録情報を送信する
                        await Inertia.post('/push-notification-subscriptions', {
                            subscription: JSON.stringify(subscription)
                        });

                        alert('プッシュ通知の登録が完了しました。');
                    }
                } else {
                    setError('Service Worker が登録されていません。');
                }
            } else {
                setError('Service Worker が利用できません。');
            }
        } catch (e) {
            setError(e.message);
        }

        setIsLoading(false);
    };

    return (
        <button onClick={handleClick} disabled={isLoading}>
            {isLoading ? '処理中...' : 'プッシュ通知を登録する'}
            {error && <div className="text-red-500">{error}</div>}
        </button>
    );
}

export default PushNotificationButton;

しかし、これもエラーが出てうまくいかないので、以下のように変更しました。

import {useEffect, useRef, useState} from 'react'
import {router} from "@inertiajs/react";

function PushNotificationButton(props) {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState('');
    const vapidPublicKey = props.vapidPublicKey;

    // ServiceWorker
    useEffect(() => {

        // Service Workerをブラウザにインストールする
        navigator.serviceWorker.register('/sw.js')
            .then(registration => {

                registration.pushManager.getSubscription();

            });

    }, []);

    // Server public key
    const base64toUint8 = (base64String) => {

        const padding = '='.repeat((4 - base64String.length % 4) % 4);
        const base64 = (base64String + padding)
            .replace(/\-/g, '+')
            .replace(/_/g, '/');

        const rawData = window.atob(base64);
        const outputArray = new Uint8Array(rawData.length);

        for (let i = 0; i < rawData.length; ++i) {
            outputArray[i] = rawData.charCodeAt(i);
        }

        return outputArray;

    }

    const handleClick = async () => {
        setIsLoading(true);
        setError('');

        try {
            // Service Worker が利用可能かどうかを確認する
            if ('serviceWorker' in navigator) {
                const registration = await navigator.serviceWorker.getRegistration();

                // Service Worker が登録されているかどうかを確認する
                if (registration) {
                    // Push 通知の許可を求める
                    const permission = await Notification.requestPermission();

                    // 許可が得られた場合
                    if (permission === 'granted') {

                        // プッシュ通知の登録を行う
                        const subscription = await registration.pushManager.subscribe({
                            userVisibleOnly: true,
                            applicationServerKey: base64toUint8(vapidPublicKey)
                        });

                        // サーバーに登録情報を送信する
                        const url = route('web_push.store');
                        router.post(url, subscription);

                        alert('プッシュ通知の登録が完了しました。');
                    }
                } else {
                    setError('Service Worker が登録されていません。');
                }
            } else {
                setError('Service Worker が利用できません。');
            }
        } catch (e) {
            setError(e.message);
        }

        setIsLoading(false);
    };

    return (
        <button onClick={handleClick} disabled={isLoading}>
            {isLoading ? '処理中...' : 'プッシュ通知を登録する'}
            {error && <div className="text-red-500">{error}</div>}
        </button>
    );
}

export default PushNotificationButton;

なお、変更した部分は以下の3つです。

パッケージの読み込み部分

まず、パッケージの読み込み部分が間違っていましたので変更しました。

import {useEffect, useRef, useState} from 'react'
import {router} from "@inertiajs/react";

また、データ送信部分がInertia.postではエラーになるようだったので、routerを使うように書き換えました。

// サーバーに登録情報を送信する
const url = route('web_push.store');
router.post(url, subscription);

ServiceWorkerの登録部分がなかった

はじめ、「ServiceWorkerが登録されていません」というエラーメッセージが表示されました。

そもそも登録部分は省略されていたからです。(別ページで読み込んでいる前提だったのでしょうか🤔)

そこで、以下のようにページが読み込まれたらすぐに先ほどつくったsw.jsが登録されるようにしました。

// ServiceWorker
useEffect(() => {

    // Service Workerをブラウザにインストールする
    navigator.serviceWorker.register('/sw.js')
        .then(registration => {

            registration.pushManager.getSubscription();

        });

}, []);

applicationServerKey(vapidのパブリックキー)が違うよというエラー

次に出たエラーは「vapidのパブリックキーが違うよ❗」というものでした。

Failed to execute 'subscribe' on 'PushManager': The provided applicationServerKey is not encoded as base64url without padding.

これは、もともと提案されたコードには以下のように「application_server_public_key」と書かれている部分があるので、ChatGPTは何も悪くないですね。(ただ、知識がない人にはわからないだろうな…)

applicationServerKey: 'application_server_public_key'

そこで、propsからキーを取得できるようにし、さらにこれをUnit8型に変換する関数を作りました。(というかここは関数名をつけただけで、Copilotがマルマル提案してくれました)

const vapidPublicKey = props.vapidPublicKey;

// 省略

// Server public key
const base64toUint8 = (base64String) => {

    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
        .replace(/\-/g, '+')
        .replace(/_/g, '/');

    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);

    for (let i = 0; i < rawData.length; ++i) {
        outputArray[i] = rawData.charCodeAt(i);
    }

    return outputArray;

}
// プッシュ通知の登録を行う
const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: base64toUint8(vapidPublicKey)
});

manifest.jsonをつくる

これは、iOSの仕様(というかやはりGoogleへの対抗策のよう)なのですが、iOSでウェブプッシュが効くようにするには、以下の条件が必要になってきます。

  • ホーム画面へ登録している
  • manifest.json を用意し、displayに「standalone」がセットされている

つまり、結局のところAndroidのようにシンプルにボタンだけ押せばウェブプッシュが使える…というわけではないのですね😅

とはいえ、そのmanifest.jsonを用意しましょう。

public/manifest.json

{
    "name": "Web push for iOS",
    "short_name": "webpush-ios",
    "theme_color": "#2196f3",
    "background_color": "#2196f3",
    "display": "standalone",
    "scope": "/",
    "start_url": "/web_push"
}

その他の名前などは適宜変更してください。
なお、私は Web App Manifest Generator という便利なサイトを使わせていただきました。

これで作業は完了です!
お疲れ様でした😄

テストしてみる

では、実際にうまくいくかテストしてみましょう❗
といっても、ローカル環境ではテストしにくいのでビルドして、実際にサーバーへアップして確認しました。

まず、iPhoneを使って、「https://******/web_push」へSafariでアクセスします。

すると、以下のようにページが表示されますので、ページ下部にある赤枠のボタンをタップします。

モーダルが表示されるので、中から「ホーム画面に追加」をタップ。

続けて「追加」をタップ。

すると、ホーム画面にアイコンが登録されますので、このアイコンをアップしてください。

先ほどのページが再度表示される(ただし、今回はスタンドアローンとして起動してます)ので、「プッシュ通知を登録する」をタップすると・・・・・・

ウェブプッシュを許可するかどうか聞かれるので、もちろん「許可」をタップ。
これで、ウェブプッシュの登録が完了しました。

それでは、iPhoneはそのままにして、ブラウザで「https://******/web_push/test」へアクセスします。(つまりiPhoneとは関係のないトリガーということですね)

どうなったでしょうか・・・・・・

はい❗
iOSなのに本当にウェブプッシュが届きました😄

すべて成功です✨

企業様へのご提案

これまで、iOSGoogleとの競争を考えて「ウェブによるプッシュ通知」を実装してきませんでした。

しかし、バージョン16.4以降からはAndroidと同じくウェブプッシュが使えるようになったため、「ウェブサイト」からでも自由に通知ができるようになっています。

これはどういうことかというと、AndroidiPhoneのアプリを2つ開発しなくてもウェブサイト1つだけで通知ができるようになることを意味します。

もし今お持ちのウェブサイトに「プッシュ通知」をご用意したい場合はぜひお問い合わせからご連絡ください。

お待ちしてます❗

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回は「Laravel + React + ChatGPT」でWebPush機能を作ってみました。

ちなみに、正直なところChatGPTは「うーん、むしろ時間をより使ってしまったかも…😅」という印象でした。

例えば、コードを提案してもらうとエラーが発生し、結局本家のサイトをチェックしにいくということが多発し、2倍の労力を使うことになりました。

また、これまでは本家のコードをコピペして(つまり間違いはほぼ皆無)改良していましたが、ChatGPTを使うと、提案されたコードのすべてを詳しくチェックしないといけないため、これまでは「コードを書く」という作業だけだったのが「コードを読み解く+コードを修正する」というこれも労力がかかることになりました。

ツイッターで誰かが、ChatGPTを使ったプログラミングは「NFTがガーンと来た時の異常な熱狂とおんなじ感じするけどねー」とツイートしてた気持ちが少しわかりました。

(私のプロンプトが下手くそ、というのは重々承知してます…😅)

なので、当分はChatGPTは、今回のように手順だったり企画、要件だったりとした初期の「ざっくりした部分」や「ドラフト」みたいなところが使いどころじゃないかと感じました。(この部分は確かにめちゃくちゃ助けになると思っています。)

みなさんも使いどころを研究してみてくださいね。

ではでは〜❗

「たまにいくバーの
マスターの生き様が
超かっこよかったです❗」

このエントリーをはてなブックマークに追加       follow us in feedly