PWA の
[Service Worker の
vapid
で
前提
以下の
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 に して、 秘密鍵、 公開鍵を 発行します。 以下 コマンドで、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. の
自分は、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
登録の
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
をこれが
上記の
通知コマンドの 送信
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側の 情報を 紐付けしても いいかもしれません。 ↩
コメント