Зачем еще одно руководство?
Когда передо мной поставили задачу сделать черновой вариант push notifications, быстрый поиск показал, что на хабре уже есть много статей по настройке push notifications. Вот наиболее, на мой взгляд, годные:
Как работает JS: веб push-уведомления
Web PUSH Notifications быстро и просто924/
Service Workers. Web Push и где они обитают
Это все прекрасно, но лично мне очень не хватало простого и понятного руководства, которое позволило бы сразу, практически методом копипаста сделать так, чтобы все сразу заработало. Ну и кроме того среди руководств нет адаптированного под бек на питоне.
Настройка уведомлений в итоге заняла три дня и мне кажется, что это несколько многовато. Надеюсь, моя статья поможет кому-то настроить push notifications за три часа вместо трех дней.
Проект, на котором я работаю, реализован на Django и описывать ход работы я буду применительно к этому фреймворку, но желающие легко адаптируют его к Flask или чему-либо еще.
Итак, погнали.
Получаем ключи
Без ключей нас, естественно, никуда не пустят, поэтому начнем с них. Для генерации ключей я использовала py_vapid. Он устанавливается автоматически вместе с pywebpush, который нам все равно понадобится позже, поэтому мы, чтобы два раза не вставать, начнем с pywebpush:
> bin/pip install pywebpush
<Здесь вывод результата установки>
> bin/vapid --applicationServerKey
No private_key.pem file found.
Do you want me to create one for you? (Y/n)Y
Generating private_key.pem
Generating public_key.pem
Application Server Key = <Мой Server Key>
Полученное значение Application Server Key добавляем в файл settings.py.
NOTIFICATION_KEY = <Мой Server Key>
Кроме того, нам нужно будет передать NOTIFICATION_KEY в контекст. Проще всего для этого написать свой context_processor.
Делаем Service worker
Service worker, кто не знает — это специальный файл, работающий в фоновом режиме. Нам он нужен для отображения наших уведомлений.
Самый простой код service worker будет выглядеть так:
self.addEventListener('push', function(event) {
var message = JSON.parse(event.data.text()); //
event.waitUntil(
self.registration.showNotification(message.title, {
body: message.body,
})
);
});
А теперь нам нужно зарегистрировать наш service worker. Процесс, в принципе, описан здесь. Поэтому кратко:
function checkWorkerAndPushManager () {
if (!('serviceWorker' in navigator)) {
console.log('Workers are not supported.');
return;
}
if (!('PushManager' in window)) {
console.log('Push notifications are not supported.');
return;
}
}
function registerWorker () {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/static/js/sw.js').then(function (registration) {
console.log('ServiceWorker registration successful');
}, function (err) {
console.log('ServiceWorker registration failed: ', err);
return;
});
});
return true;
}
var supported = checkWorkerAndPushManager();
if (supported){
var worker = registerWorker ();
}
Отлично, можно проверить работу нашего service worker. Для этого зайдите в Developer Tools в браузере, убедитесь, что в консоли появилось сообщение об успешной регистрации вокера и перейдите на вкладку Application и слева выберите Service Worker.
Если уведомление не появляется — проверьте, что у вас в браузере разрешены уведомления.
Ну вот, мы уже умеем оправлять уведомления сами себе. Круто, едем дальше.
Получаем разрешение пользователя на показ уведомлений
После того, как вокер зарегистрирован, попросим у пользователя разрешение показывать уведомления.
function requestPermission() {
return new Promise(function(resolve, reject) {
const permissionResult = Notification.requestPermission(function(result) {
// Поддержка устаревшей версии с функцией обратного вызова.
resolve(result);
});
if (permissionResult) {
permissionResult.then(resolve, reject);
}
})
.then(function(permissionResult) {
if (permissionResult !== 'granted') {
console.log(permissionResult);
throw new Error('Permission not granted.');
}
});
return true;
}
К этому коду и комментариев никаких не нужно, он просто работает.
Подписываемся и сохраняем подписку
Подписка выполняется тоже на фронте. Здесь можно найти код подписки, но нет используемой функции urlBase64ToUint8Array, поэтому я код вместе с ней.
NOTIFICATION_KEY = '{{ NOTIFICATION_KEY }};
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/')
;
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
function subscribeUserToPush(key) {
return navigator.serviceWorker.register('/static/coolwriter/js/sw.js')
.then(function(registration) {
var subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
};
return registration.pushManager.subscribe(subscribeOptions)
})
.then(function(pushSubscription) {
sendSubscriptionToBackEnd(pushSubscription);
});
}
(Используемая здесь urlBase64ToUint8Array, видимо, из разряда костылей и велосипедов, но попытка перекодировать данные с помощью btoa не привела к успеху, уж не знаю, почему. Может кто подскажет).
Далее полученные данные отправляем на сервер. У меня это реализовано так:
function sendSubscriptionToBackEnd(subscription) {
$.post(
SAVE_REGISTRATION_URL,
{
'csrfmiddlewaretoken': $('input[name=csrfmiddlewaretoken]').val(),
//Естественно, в темплейте есть тег {% csrf_token %}.
'registration_data': JSON.stringify(subscription)
}
);
}
Ну и дальше сохраняем это на сервере. Можно прямо строкой. Да, не вздумайте делать связь user-subscription типа «один к одному». Вроде бы очевидно, но вдруг кому вздумается.
У меня для сохранения используется вот такая простая модель, она дальше будет использоваться, поэтому я ее приведу:
class UserSubscription(models.Model):
subscription = models.CharField(max_length=500)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='subscriptions')
Последний шаг. Отправляем сообщение с помощью pywebpush
Здесь все по туториалу, никаких особенных тонкостей. Ну, разве что сообщение делаем такой структуры, чтобы наш service worker мог его разобрать.
import json
from pywebpush import webpush, WebPushException
from django.conf import settings
from .models import UserSubscription
def push_notification(user_id):
user_subscriptions = UserSubscription.objects.filter(user_id=notification.user_id)
for subscription in user_subscriptions:
data = json.dumps({
'title': 'Hello',
'body': 'there',
})
try:
webpush(
subscription_info=json.loads(subscription.subscription),
data=data,
vapid_private_key='./private_key.pem',
vapid_claims={
'sub': 'mailto:{}'.format(settings.ADMIN_EMAIL),
}
)
notification.sent = True
notification.save()
except WebPushException as ex:
print('I\'m sorry, Dave, but I can\'t do that: {}'.format(repr(ex)))
print(ex)
# Mozilla returns additional information in the body of the response.
if ex.response and ex.response.json():
extra = ex.response.json()
print('Remote service replied with a {}:{}, {}',
extra.code,
extra.errno,
extra.message
)
Собственно, уже можно вызывать push_notification из django shell и пытаться запустить.
В этом коде хорошо бы еще сделать перехват ответа со статусом 410. Такой ответ говорит, что подписка аннулирована, и такие подписки хорошо бы удалять из БД.
В заключение
На самом деле есть еще одна замечательная библиотека django-webpush. Возможно, тем, кто работает с Django, стоит начинать именно с нее.
P.S. Всех с Днем программиста!
R4_Serega
Не понимаю в чем сложность. Примеры Front-end кода лежит в документации, а отправка пуша происходит через курл) вот для Настройки пушей на сафари надо поплясать, а вот firebase наверное самый простой способ :)