
九保すこひです(フリーランスのITコンサルタント、エンジニア)
さてさて、世の中には「うーん、僕が生きている間はムリなんじゃない」と思うことがいくつかありますよね。
例えば、「サクラダファミリア」は「完成しない」で有名なものの一つですね。
では、我ら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 でウェブプッシュ機能をつくる手順をステップ・バイ・ステップで教えてくださいますでしょうか。
※ ホントか嘘か、丁寧に接すると回答のクオリティが上がるらしいですね
すると、以下のように回答してくれました。
- プッシュ通知のライブラリをインストールする
- VAPIDキーを生成する
- プッシュ通知用のマイグレーションを作成する
- プッシュ通知を送信するための通知クラスを作成する
- プッシュ通知を送信するルートを作成する
- ウェブページにサービスワーカーを登録する
- サービスワーカーを登録する
※ ただ、残念なことにこの手順は正しいと思うものの、各説明文は間違っている部分が多くあったため、途中で言われたとおり作業するのはムリだと判断しました。
ということで、この手順に従いながら(でも、作業内容は従わないように)作業を進めていきましょう
プッシュ通知のライブラリをインストールする
では、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
を使わないのでShouldQueue
とQueueable
を削除し、最終的には以下のようになりました。
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
なのに本当にウェブプッシュが届きました
すべて成功です
企業様へのご提案
これまで、iOS
はGoogle
との競争を考えて「ウェブによるプッシュ通知」を実装してきませんでした。
しかし、バージョン16.4
以降からはAndroid
と同じくウェブプッシュが使えるようになったため、「ウェブサイト」からでも自由に通知ができるようになっています。
これはどういうことかというと、Android
&iPhone
のアプリを2つ開発しなくてもウェブサイト1つだけで通知ができるようになることを意味します。
もし今お持ちのウェブサイトに「プッシュ通知」をご用意したい場合はぜひお問い合わせからご連絡ください。
お待ちしてます
おわりに
ということで、今回は「Laravel + React + ChatGPT」でWebPush
機能を作ってみました。
ちなみに、正直なところChatGPT
は「うーん、むしろ時間をより使ってしまったかも…」という印象でした。
例えば、コードを提案してもらうとエラーが発生し、結局本家のサイトをチェックしにいくということが多発し、2倍の労力を使うことになりました。
また、これまでは本家のコードをコピペして(つまり間違いはほぼ皆無)改良していましたが、ChatGPT
を使うと、提案されたコードのすべてを詳しくチェックしないといけないため、これまでは「コードを書く」という作業だけだったのが「コードを読み解く+コードを修正する」というこれも労力がかかることになりました。
ツイッターで誰かが、ChatGPT
を使ったプログラミングは「NFT
がガーンと来た時の異常な熱狂とおんなじ感じするけどねー」とツイートしてた気持ちが少しわかりました。
(私のプロンプトが下手くそ、というのは重々承知してます…)
なので、当分はChatGPT
は、今回のように手順だったり企画、要件だったりとした初期の「ざっくりした部分」や「ドラフト」みたいなところが使いどころじゃないかと感じました。(この部分は確かにめちゃくちゃ助けになると思っています。)
みなさんも使いどころを研究してみてくださいね。
ではでは〜
「たまにいくバーの
マスターの生き様が
超かっこよかったです」