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日 ハマっていたので、その解決の糸口になればと思います。
前提
以下の環境で作業は実施しています。
-
OS
% sw_vers ProductName: Mac OS X ProductVersion: 10.13.1 BuildVersion: 17B1003
-
Python の Version
% python3 -V Python 3.6.2
-
Django の Version
% python3 -m pip list --format=columns | grep Django Django 1.11.8
-
django-push-notifications の設定について
FCM
、VAPID
を使用した設定になります。
以下の記事を参考に設定は進めました。
Web Pushのサーバ認証VAPIDを試してみる (旧題: GCMの登録が不要になったChromeのWeb Pushを試してみる) - Qiita -
FCM の設定
動作確認には、Firebase を使いました。
事前に API の登録、設定を実施する必要があります。
手順
以下の手順で実施しています。
- インストール
- クライアント 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',
...
]
公開鍵、秘密鍵の発行
-
pywebpush
、py-vapid
をインストール
pywebpush
、py-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 にして、秘密鍵、公開鍵を発行します。
以下 コマンドで、P-256 楕円曲線の ECDSA 公開鍵? を生成します。vapid --sign claim.json
ここで、出力された、キーを コピーアンドペースト し、JavaScript に設定します。vapid --applicationServerKey -------------------------------- Application Server Key = BEFuGfKKEFp-kEBMxAIw7ng8HeH_QwnH5_h55ijKD4FRvgdJU1GVlDo8K5U5ak4cMZdQTUJlkA34llWF0xHya70 --------------------------------
公開鍵を返す、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
実装していてハマっていたところ2点記載します。// 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)); }
-
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")
実装してみて感じたこと
以下、実装した感想を記載します。
-
会員登録サイトで、会員登録していない人の緩い囲い込み
会員登録を求めず、「通知を実施するか確認する」、「通知に必要な最低限の情報のみ求める」で通知を送信できるかと思いました。
末登録>末登録だけど通知設定済>会員登録した で、会員登録前のレベルとして状態を設けることができます。
少しだけ情報を求めて、データを集めるのは良いかと思いました。1
サイトの再方率にも寄与しそうに思います。 -
ダイアログの出し方を工夫する
ServiceWorker の登録直後に、通知設定まで行うと、画面表示直後にダイアログが出てきます。
個人的な経験上は余りこのタイミングで登録は行わないので、通知の求め方を工夫する必要はあるかなと思いました。
以上です。
-
GAのcookie を送付しておいて、後で GA側の情報を紐付けしてもいいかもしれません。 ↩
コメント