こんにちはみなさん
ブログとか記事サイトとか、はてはPWAとか、Webアプリを利用する際に、プッシュ通知を許可するかどうか、みたいなダイアログが出てきて、巷ではこれをブロックするのがバッドノウハウとして浸透していたりします。
とはいえ、プッシュ通知を使ってPCなりスマホなりに通知を送れたほうが、メールで通知したりするより、カジュアルで済むように思います。
また、プッシュ通知が鬱陶しいと忌避する気持ちもありますが、では自分が実装したらどういうふうに作れるのか、知らないままでいるというのも気持ちの悪い話です。
そんなわけで、プッシュ通知の良し悪しはともかく、とりあえずプッシュ通知の仕組みを習得するために、自前でプッシュ通知の仕組みを作っていきましょう。
TL;DR
- Laravelでプッシュ通知をする仕組みを作ったよ
- サーバサイド、フロントサイドとともにサービスワーカーが特殊な位置にいるのでちょっと大変
今回の記事のために作ったリポジトリは公開しているので、一部省略しているところも見たいとかであれば、
そちらも参照してください。
https://github.com/niisan-tokyo/laravel-webpush-sample
概要
ユーザーに新着情報があったときなどにプッシュ通知するというアプリを考えましょう。
今回はそのアプリをLaravelを使って実装します。
プッシュ通知する部分を全部自前で作るのは流石に大変そうなので、Laravelのサードパーティ製のライブラリを利用します。
プロジェクト準備
プロジェクト作成
なにはともあれ、まずはプロジェクトを作っていきましょう。
プロジェクト名とかは適当で。
composer create-project --prefer-dist laravel/laravel push_sw
今回のバージョン情報としては
- Laravel 5.7
- PHP 7.2.4
となっています。
動作環境の整備
ここから、大体の作業や環境構築はdockerにやらせることにします。 ( 手っ取り早いので )
私の場合、単一リポジトリで動くシステムの場合はリポジトリ直下にcomposeディレクトリを掘って、そこにdocker-composeの設定を全部押し込んじゃいます。
cd push_sw
mkdir compose
次に、以下のdocker-compose.ymlファイルを作ります。
例によって nginx + php-fpm のテンプレサーバと、DBとしてはmysql, mysqlの中を覗くためのphpmyadminを入れています。
version: '3'
services:
  workspace:
    build: workspace/
    depends_on:
      - db
    volumes:
      - ../:/var/www
  nginx:
    build: nginx/
    depends_on:
      - fpm
      - phpmyadmin
    volumes:
      - ../:/var/www
      - ./nginx/sites:/etc/nginx/sites-available
    ports:
      - "8080:80"
  fpm:
    build: fpm/
    depends_on:
      - db
    expose:
      - "9000"
    volumes:
      - ../:/var/www
  db:
    image: mysql:5.7
    environment:
      - MYSQL_DATABASE=homestead
      - MYSQL_USER=homestead
      - MYSQL_PASSWORD=secret
      - MYSQL_ROOT_PASSWORD=root
    volumes:
      - mysql:/var/lib/mysql
  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    environment:
      - PMA_HOST=db
      - PMA_USER=root
      - PMA_PASSWORD=root
    ports:
      - "3333:80"
volumes:
    mysql:
        driver: "local"
Dockerfileとか設定ファイルとか、全部のせていると冗長すぎるので、そのあたりはこちらを参照してください。
docker-compose.yml の中の workspace は作業をしたり、push通知を実行したりするコンテナですが、gmpというエクステンションが必要になっており、そのため、コンテナイメージは以下のようになっています。
FROM php:alpine
MAINTAINER Niikura Rtyota <ryota.niikura@nijibox.co.jp>
RUN apk add --update --no-cache --virtual .build_deps unzip zlib-dev gmp-dev $PHPIZE_DEPS && \
    apk add libpng-dev gmp bzip2-dev && \
    docker-php-ext-install mysqli pdo_mysql gd zip gmp && \
    pecl install xdebug && docker-php-ext-enable xdebug && \
    apk del .build_deps
RUN docker-php-ext-install bz2 && \
    curl -sS https://getcomposer.org/installer | php; mv composer.phar /usr/local/bin/composer ; mkdir /var/dev
RUN curl -L -o /usr/bin/phpmd http://static.phpmd.org/php/latest/phpmd.phar && chmod +x /usr/bin/phpmd
RUN mkdir /var/www
WORKDIR /var/www
CMD ["sh"]
とりあえず、これで準備完了です。
サーバ側実装
ワークスペース
以降、php artisanやcomposerなどのコマンドは、基本的にはワークスペース上で実施します。
ワークスペースは
cd compose
docker-compose run --rm workspace
で起動します。
認証の仕組みを導入
今回は登録されたユーザーに向けてpush通知を送るので、認証があったほうがいいでしょう。
簡単に入れておきます。
php artisan migrate
php artisan make:auth
プッシュ通知用のライブラリを導入
laravel-notification-channels/webpushというライブラリが、自前でプッシュ通知をlaravelに入れる上では便利なので、こいつを使っていきましょう。
https://github.com/laravel-notification-channels/webpush
composer require laravel-notification-channels/webpush
公式に従って、プロバイダを追加します
+ NotificationChannels\WebPush\WebPushServiceProvider::class,
プッシュ通知用に、通知先のエンドポイントなどを格納するテーブルが必要なので、migration テーブル作ります。
php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="migrations"
php artisan migrate
次に、configファイルを生成します。
これも、vendor:publishで作ってくれます
php artisan vendor:publish --provider="NotificationChannels\WebPush\WebPushServiceProvider" --tag="config"
最後にVAPIDを作ります。
https://qiita.com/tomoyukilabs/items/9346eb44b5a48b294762
https://adiary.adiary.jp/0391
php artisan webpush:vapid
envファイルに以下の項目が追加されている(毎回違います)
VAPID_PUBLIC_KEY=BIK3yj6UjKe3H4qjBOnzmKlc1nSSPOpiyKgpbhqLSnGxpP5Dfzl0Z9AqUNFwsIP6AxW31Sy6YC+UEpS7mrdsoUY=
VAPID_PRIVATE_KEY=w99aYrgdvb/GbJCfMSOFjgHTXqtzMnrlujYRZDGWzl8=
ユーザとプッシュ通知の endpoint 登録 ( subscribe )
まず、ユーザモデルがプッシュ通知を該当ユーザに送れるよう、trait を追加します。
/** 前略 */
use NotificationChannels\WebPush\HasPushSubscriptions;
class User extends Authenticatable
{
    use Notifiable, HasPushSubscriptions;
/** 後略 */
これで、ユーザモデルはプッシュ通知を送れるようになりました。
次に、ユーザのエンドポイントや認証情報を、サーバ側に通知し、登録するための処理が必要になります。
コントローラを作って、ユーザーから届いた情報をDBに突っ込みます。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class NewsController extends Controller
{
    public function subscription(Request $request)
    {
        $user = \Auth::user();
        $endpoint = $request->endpoint;
        $key = $request->key;
        $token = $request->token;
        $user->updatePushSubscription($endpoint, $key, $token);
        return ['result' => true];
    }
}
ルーティングにも追加
Route::post('/news/subscription', 'NewsController@subscription')->name('subscription');
一旦、サーバ側はここまでとして、フロント側の実装をしていきます。
フロント側実装
今回はログインしていることを前提にしているので、ログイン直後に入るトップページのビューに、サービスワーカーの登録やプッシュ通知の許可を仕込んでいきます。
<!-- 前略 -->
<script>
// サービスワーカーが使えない系では何もしない
if ('serviceWorker' in navigator) {
  console.log('Service Worker and Push is supported');
  
  // サービスワーカーとして、public/sw.js を登録する
  navigator.serviceWorker.register('sw.js')
  .then(function (swReg) {
    console.log('Service Worker is registered', swReg)
    initialiseServiceWorker()
  })
  .catch(function(error) {
    console.error('Service Worker Error', error)
  })
}
コードの真ん中で、サービスワーカーを登録しています。
サービスワーカーは(私の環境だと)ルートディレクトリの直下に置かないと、うまく登録されなかったので、こんな感じになっています。
サービスワーカーが登録されると次の処理に移行します。
/** 
 * サービスワーカーを初期化する
 * 初期化では、プッシュ通知用の情報をサーバに送ることになる
 */
function initialiseServiceWorker() {
  if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
    console.log('cant use notification')
    return
  }
  if (Notification.permission === 'denied') {
    console.log('user block notification')
    return
  }
  if (!('PushManager' in window)) {
    console.log('push messaging not supported')
    return
  }
  // プッシュ通知使えるので
  navigator.serviceWorker.ready.then(registration => {
    console.log(registration)
    registration.pushManager.getSubscription()
      .then(subscription => {
        if (! subscription) {
          subscribe()
        }
      })
  })
}
はじめの分岐処理はすべてプッシュ通知が使えるかどうかの確認部分です。
後半部分のnavigator.serviceWorker.ready.thenで、サービスワーカーの準備が完了したら、購読できる状態にします。
if (! subscription)でsubscriptionが取得できていない状況であれば、あらためてsubscribeにて購読できる状態にしています。
/** 
 * サーバに自身の情報を送付し、プッシュ通知を送れるようにする
 */
function subscribe() {
  var options = { userVisibleOnly: true }
  var vapidPublicKey = '{{ config("webpush.vapid.public_key") }}'
  if (vapidPublicKey) {
    options.applicationServerKey = urlBase64ToUint8Array(vapidPublicKey)
  }
  registration.pushManager.subscribe(options)
  .then(subscription => {
    updateSubscription(subscription)
  })
}
function urlBase64ToUint8Array (base64String) {
  var padding = '='.repeat((4 - base64String.length % 4) % 4);
  var base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/')
  var rawData = window.atob(base64)
  var outputArray = new Uint8Array(rawData.length)
  for (var i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i)
  }
  return outputArray
}
urlBase64ToUint8Arrayはbase64文字列を整数配列に置き換えるだけの関数。
vapidPublicKeyはサーバ側から渡されるVAPID用の公開鍵で、ここだけlaravelの変数展開({{ config("webpush.vapid.public_key") }})にて記述されています。
registration.pushManager.subscribe(options)にてプッシュ通知を受け取れるように許可を取ると、updateSubscriptionにてサーバ側にユーザーのエンドポイントを登録します。
/** 
 * 購読情報を更新する
 *
 */
function updateSubscription(subscription) {
  var key = subscription.getKey('p256dh')
  var token = subscription.getKey('auth')
  var data = new FormData()
  data.append('endpoint', subscription.endpoint)
  data.append('key', key ? btoa(String.fromCharCode.apply(null, new Uint8Array(key))) : null),
  data.append('token', token ? btoa(String.fromCharCode.apply(null, new Uint8Array(token))) : null)
  // サーバに通信し、endpointを渡す
  fetch('/news/subscription', {
    method: 'POST',
    body: data
  }).then(() => console.log('Subscription ended'))
}
サーバに生成したキーやエンドポイントをわたします。
サービスワーカー
サービスワーカーの実装をしていきます。
ユーザー側にて常駐プロセスとして動作します。
'use strict'
self.addEventListener('install', function (e) {
  console.log('ServiceWorker install')
})
self.addEventListener('activate', function (e) {
  console.log('Serviceworker activated')
})
ここでは単純にサービスワーカーの状態を確かめているだけです。
次に、プッシュ通知が来たときの挙動を書いていきます。
const WebPush = {
  init () {
    self.addEventListener('push', this.notificationPush.bind(this))
    self.addEventListener('notificationclick', this.notificationClick.bind(this))
    self.addEventListener('notificationclose', this.notificationClose.bind(this))
  },
3つのイベントについて、それぞれハンドラーを登録しています。
イベントはそれぞれ、pushが来る、通知をクリックする、通知を閉じる、となっています。
次に、ハンドラーの中身を書いていきます。
  /**
   * handle notification push event!
   * @param {NotificationEvent} event 
   */
  notificationPush(event) {
    if (!(self.Notification && self.Notification.permission === 'granted')) {
      return
    }
    if (event.data) {
      event.waitUntil(
        this.sendNotification(event.data.json())
      )
    }
  },
  /**
   * handle notification click event
   * @param {NotificationEvent} event 
   */
  notificationClick(event) {
    self.clients.openWindow('/')
  },
  /**
   * handle notification close event
   * @param {NotificationEvent} event 
   */
  notificationClose(event) {
    self.clients.openWindow('/')
    // self.registration.pushManager.getSubscription().then(subscription => {
    //   if (subscription) {
    //     this.dismissNotification(event, subscription)
    //   }
    // })
  },
通知が来たら、ディスプレイ上に通知を表示します。
通知クリック、もしくは閉じると、localhost:8080 のトップページに飛びます。
  /**
   * send request to server to dismiss a notification
   * @param {PushMessageData|Object} data 
   */
  sendNotification(data) {
    return self.registration.showNotification(data.title, data)
  },
}
WebPush.init()
実際にプッシュ通知をディスプレイに表示しているのがこの部分です。
スクリプトの最後でWebPushをinitすることで、処理を開始する
プッシュ通知機構を作成する
おさらい
ここまで少し長くなってきたので、おさらいします。
現在以下の部分が実装されています。
- プッシュ通知をするための情報を格納するためのテーブル作成
- プッシュ通知をするための情報を受け取る、サーバ側のAPIの実装
- プッシュ通知に必要なVAPIDを実行するフロントエンドの実装
- プッシュ通知を受け付けるサービスワーカーの実装
ここまで作ったときに、あと足りないのは何でしょうか
そうです、プッシュ通知を送付する機構が足りないのです。
バッチの作成
今回は任意でプッシュ通知を飛ばせるようにするため、artisanコマンドを作成します。
名前は適当です。
php artisan make:command PushTest
php artisan make:notification NewsPush
上段がコマンドライン上で実行するコマンドのハンドラで、下段が通知を定義するクラスです。
先に通知部分を作ります。
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use NotificationChannels\WebPush\WebPushMessage;
use NotificationChannels\WebPush\WebPushChannel;
class NewsPush extends Notification
{
    use Queueable;
    /**
     * Create a new notification instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }
    /**
     * Get the notification's delivery channels.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable)
    {
        return [WebPushChannel::class];
    }
    /**
     * プッシュ通知をする
     *
     * @param [type] $notifiable
     * @param [type] $notification
     * @return void
     */
    public function toWebPush($notifiable, $notification)
    {
        return (new WebPushMessage)
            ->title('新着情報')
            ->icon('/favicon.ico')
            ->body('test news incomming!!')
            ->action('View news', 'view_news');
    }
}
とりあえず、faviconをアイコンにして、固定のメッセージを送るようにしてあります。
最後に通知コマンドを作る
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Notifications\NewsPush;
use App\User;
class PushTest extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'webpush:test';
    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';
    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }
    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        $users = User::all();
        foreach ($users as $user) {
            $user->notify(new NewsPush);
        }
    }
}
これで完成です。
実行
workspaceでこいつを実行します
php artisan webpush:test
すると
 
こんな感じの通知が来ます
まとめ
というわけで、プッシュ通知する機構を、自分のローカル環境内部で完結させることができました。
プッシュ通知の動きを簡単に知りたい人や、サービスワーカーの動きを見てみたいときには思い出していただけるとありがたいかなぁって思います。
今回はそんなところです。
