django-push-notifications を使って、Web Push 通知を実装する


PWA の 要素の 1つに Web Push があります。
[Service Worker の仕様が何で定められているかしっかり調べていない] という状態で、django アプリケーションに、django-push-notifications/django-push-notifications: Send push notifications to mobile devices through GCM or APNS in Django. を インストールして、Web Push を実装した結果を記載します。
vapid で1-2日 ハマっていたので、その解決の糸口になればと思います。


前提

以下の環境で作業は実施しています。


手順

以下の手順で実施しています。

  • インストール
  • クライアント JavaScript の作成
  • ID 登録用の Web API の設定
  • 通知コマンドの送信

インストール

pip で、インストールします。

python3 -m pip install django-push-notifications
------------------------------------------------------
Installing collected packages: pycparser, cffi, asn1crypto, cryptography, PyJWT, hpack, hyperframe, h2, hyper, apns2, django-push-notifications
Successfully installed PyJWT-1.5.3 apns2-0.3.0 asn1crypto-0.24.0 cffi-1.11.4 cryptography-2.1.4 django-push-notifications-1.5.0 h2-2.6.2 hpack-3.0.0 hyper-0.7.0 hyperframe-3.2.0 pycparser-2.18
------------------------------------------------------

settings.py に アプリケーションを追加する

    INSTALLED_APPS = [
    ....
    'push_notifications',
    ...
    ]

公開鍵、秘密鍵の発行

  • pywebpushpy-vapid をインストール
    pywebpushpy-vapid をインストールします。

    pip install pywebpush
    pip install py-vapid  (Only for generating key)
    

  • claim.json の作成
    以下の内容の claim.json を作成しました。

    {
        "sub": "mailto: your.mail@gmail.com",      
        "aud": "https://fcm.googleapis.com"
    }
    

  • 公開鍵、秘密鍵を発行する
    claim.json を input にして、秘密鍵、公開鍵を発行します。

    vapid --sign claim.json
    
    以下 コマンドで、P-256 楕円曲線の ECDSA 公開鍵? を生成します。
    vapid --applicationServerKey
    --------------------------------
    Application Server Key = BEFuGfKKEFp-kEBMxAIw7ng8HeH_QwnH5_h55ijKD4FRvgdJU1GVlDo8K5U5ak4cMZdQTUJlkA34llWF0xHya70
    --------------------------------
    
    ここで、出力された、キーを コピーアンドペースト し、JavaScript に設定します。
    公開鍵を返す、API を作る方式もありますが、個人的には、JavaScript に含めるで一旦は問題ないかと思いました。
    Cache される場合もあるので、実際使用する際は、サーバ側から返す実装がいいかもしれません。

Web Push の設定を追加する

settings.py に、PUSH_NOTIFICATIONS_SETTINGS の設定を追加します。

PUSH_NOTIFICATIONS_SETTINGS = {
        "FCM_API_KEY": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "FCM_ERROR_TIMEOUT": 1000,
        "WP_PRIVATE_KEY" : os.path.join(BASE_DIR, "private_key.pem"),
        "WP_CLAIMS" :  {'sub': "mailto: your.mail@gmail.com"}
}


クライアント JavaScript の作成

django-push-notifications/django-push-notifications: Send push notifications to mobile devices through GCM or APNS in Django. の Sample を参考に、JavaScript クライアントを作成します。
自分は、ReactのregisterServiceWorkerとは、なんぞや - Qiita に記載のあるregisterServiceWorker.js を使おうとしており、その内部に組み込みました。

  • registerServiceWorker.js

    // In production, we register a service worker to serve assets from local cache.
    
    // This lets the app load faster on subsequent visits in production, and gives
    // it offline capabilities. However, it also means that developers (and users)
    // will only see deployed updates on the "N+1" visit to a page, since previously
    // cached resources are updated in the background.
    
    // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
    // This link also includes instructions on opting out of this behavior.
    
    // Utils functions:
    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;
    }
    
    function loadVersionBrowser(userAgent) {
        var ua = userAgent, tem, M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || [];
        if (/trident/i.test(M[1])) {
            tem = /\brv[ :]+(\d+)/g.exec(ua) || [];
            return {name: 'IE', version: (tem[1] || '')};
        }
        if (M[1] === 'Chrome') {
            tem = ua.match(/\bOPR\/(\d+)/);
            if (tem != null) {
                return {name: 'Opera', version: tem[1]};
            }
        }
        M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, '-?'];
        if ((tem = ua.match(/version\/(\d+)/i)) != null) {
            M.splice(1, 1, tem[1]);
        }
        return {
            name: M[0],
            version: M[1]
        };
    };
    const isLocalhost = Boolean(
        window.location.hostname === 'localhost' ||
        // [::1] is the IPv6 localhost address.
        window.location.hostname === '[::1]' ||
        // 127.0.0.1/8 is considered localhost for IPv4.
        window.location.hostname.match(
            /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
        )
    )
    
    export default function register() {
        if ('serviceWorker' in navigator) {
            // The URL constructor is available in all browsers that support SW.
            const publicUrl = new URL("/static/js", window.location)
            if (publicUrl.origin !== window.location.origin) {
                // Our service worker won't work if PUBLIC_URL is on a different origin
                // from what our page is served on. This might happen if a CDN is used to
                // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
                return
            }
    
            window.addEventListener('load', () => {
                const swUrl = '/static/js/service-worker.js'
                if (!isLocalhost) {
                    // Is not local host. Just register service worker
                    registerValidSW(swUrl)
                } else {
                    // This is running on localhost. Lets check if a service worker still exists or not.
                    checkValidServiceWorker(swUrl)
                }
            })
        }
    }
    
    function registerValidSW(swUrl) {
        // パーミッション
        Notification.requestPermission(state => {
            if (state === 'granted') {
    
            }
        });
        // serviceWorkerの登録
        navigator.serviceWorker
            .register(swUrl)
            .then(registration => {
                    var browser = loadVersionBrowser(window.navigator.userAgent);
                    const serverPublicKey = "xxxxxxxxxxxxxxxxxx";
                    registration.pushManager.subscribe({
                        userVisibleOnly: true,
                        applicationServerKey: urlBase64ToUint8Array(serverPublicKey)
                    }).then(sub => {
                        var endpointParts = sub.endpoint.split('/');
                        var registration_id = endpointParts[endpointParts.length - 1];
                        let contentEncoding; // プッシュ通知の送信時に指定するContent-Encoding
                        // Chrome 50以降、Firefox 48以降のみを想定
                        if ('supportedContentEncodings' in PushManager) {
                            contentEncoding =
                                PushManager.supportedContentEncodings.includes('aes128gcm') ? 'aes128gcm' : 'aesgcm';
                        } else {
                            contentEncoding = 'aesgcm';
                        }
                        var data = {
                            'browser': browser.name.toUpperCase(),
                            'p256dh': btoa(String.fromCharCode.apply(null, new Uint8Array(sub.getKey('p256dh')))),
                            'auth': btoa(String.fromCharCode.apply(null, new Uint8Array(sub.getKey('auth')))),
                            'name': browser.name.toUpperCase(),
                            "active": true,
                            'registration_id': registration_id,
                            'contentEncoding': contentEncoding,
                            'cloud_message_type': 'FCM'
                        };
                        console.log(data);
                        requestPOSTToServer(data);
                    }).catch(error => {
                        console.error('Error during service worker ready:', error)
                    });
    
                    registration.onupdatefound = () => {
                        const installingWorker = registration.installing
                        installingWorker.onstatechange = () => {
                            if (installingWorker.state === 'installed') {
                                if (navigator.serviceWorker.controller) {
                                    // At this point, the old content will have been purged and
                                    // the fresh content will have been added to the cache.
                                    // It's the perfect time to display a "New content is
                                    // available; please refresh." message in your web app.
                                    console.log('New content is available; please refresh.')
                                } else {
                                    // At this point, everything has been precached.
                                    // It's the perfect time to display a
                                    // "Content is cached for offline use." message.
                                    console.log('Content is cached for offline use.')
                                }
                            }
                        }
                    };
                }
            ).catch(error => {
            console.error('Error during service worker registration:', error)
        });
    }
    
    function checkValidServiceWorker(swUrl) {
        // Check if the service worker can be found. If it can't reload the page.
        fetch(swUrl)
            .then(response => {
                // Ensure service worker exists, and that we really are getting a JS file.
                if (
                    response.status === 404 ||
                    response.headers.get('content-type').indexOf('javascript') === -1
                ) {
                    // No service worker found. Probably a different app. Reload the page.
                    navigator.serviceWorker.ready.then(registration => {
                        registration.unregister().then(() => {
                            window.location.reload()
                        })
                    })
                } else {
                    // Service worker found. Proceed as normal.
                    registerValidSW(swUrl)
                }
            })
            .catch((e) => {
                console.log(
                    'No internet connection found. App is running in offline mode.', e
                )
            })
    }
    
    export function unregister() {
        if ('serviceWorker' in navigator) {
            navigator.serviceWorker.ready.then(registration => {
                registration.unregister()
            })
        }
    }
    
    function requestPOSTToServer(data) {
        var xmlhttp = new XMLHttpRequest();   // new HttpRequest instance
        xmlhttp.open("POST", "/api/v2/web_push/");
        xmlhttp.setRequestHeader("Content-Type", "application/json");
        xmlhttp.send(JSON.stringify(data));
    }
    
    実装していてハマっていたところ2点記載します。

  • requestPOSTToServerは、自前実装する必要がある
    Sample プログラムに記載がなく、最初理解できなかったのですが、通知処理と registration_id の払い出しを非同期で行う場合、払い出した 情報を、サーバー側に送信する必要があります。 登録した情報が、サーバー側で通知処理を行う場合の宛先になります。

  • const serverPublicKey = “xxxxxxxxxxxxxxxxxx”;
    ここに、vapid --applicationServerKey で出力された鍵を記載します。

  • registration のタイミングでのrequestPOSTToServer実行について
    ServiceWorker の ready のタイミングで送信を実施したのですが、うまくいかず、registration に移動しました。
    しかしながらこの実装だと2度目の表示時にキー重複エラーが発生し、エラーとなります。
    UPDATE_ON_DUPLICATE_REG_ID を設定すると更新が発生し、エラーは抑制できますが、タイミングを変更するのが理想かと思いました。
    どう実装すべきかは考え中です。


ID 登録用の Web API の設定

registration_id 登録のための API を urls.py に紐付けます。

from push_notifications.api.rest_framework import WebPushDeviceViewSet
from rest_framework.routers import DefaultRouter

router = DefaultRouter()
router.register(r'web_push', WebPushDeviceViewSet)
urlpatterns = [
    ...
    url(r'^api/v2/', include(router.urls)),
    ...
    ]
VAPID を使う場合は、WebPushDeviceViewSet を使用します。
これがわからず、別のViewSetを使って送信がうまくいかないで、ハマっていました。
上記の例だと、/api/v2/web_push/ が API の URLになります。


通知コマンドの送信

1件のみ登録したので、id=1 で データを取り出して、対象に通知を送信します。

from push_notifications.models import WebPushDevice
fcm_device = WebPushDevice.objects.get(id=1)
fcm_device.send_message("This is a message")
上記で localhost でブラウザで通知が送信されるのを確認できました。


実装してみて感じたこと

以下、実装した感想を記載します。

  • 会員登録サイトで、会員登録していない人の緩い囲い込み
    会員登録を求めず、「通知を実施するか確認する」、「通知に必要な最低限の情報のみ求める」で通知を送信できるかと思いました。
    末登録>末登録だけど通知設定済>会員登録した で、会員登録前のレベルとして状態を設けることができます。
    少しだけ情報を求めて、データを集めるのは良いかと思いました。1
    サイトの再方率にも寄与しそうに思います。

  • ダイアログの出し方を工夫する
    ServiceWorker の登録直後に、通知設定まで行うと、画面表示直後にダイアログが出てきます。
    個人的な経験上は余りこのタイミングで登録は行わないので、通知の求め方を工夫する必要はあるかなと思いました。

以上です。


  1. GAのcookie を送付しておいて、後で GA側の情報を紐付けしてもいいかもしれません。 

コメント