Web PUSH Notifications быстро и просто
- вторник, 14 марта 2017 г. в 03:14:31
 
Добрый день. В этой небольшой заметке я хочу рассказать как быстро и просто настроить push-уведомления на вашем сайте. Эта статья ни в коем случае не претендует на звание исчерпывающего руководства, но, я надеюсь, что она даст точку старта для дальнейшего изучения.
Информации по этой теме в интернете полно, но она фрагментирована, разбросана по разным ресурсам и перемешена с уведомлениями для мобильных устройств с примерами на Java, C++ и Python. Нас же, как веб-разработчиков, интересует JavaScript. В этой статье я постараюсь саккумулировать всю необходимую и полезную информацию.

Я думаю, вы уже знаете что такое push-уведомления, но я всё же напишу коротко о главном.
Пользователь, заходя на сайт, вытягивает (pull) с него данные. Это удобно и безопасно, но с развитием интернет ресурсов, появилась необходимость оперативно доставлять информацию пользователям не дожидаясь пока те сами сделают запрос. Так и появилась технология принудительной доставки (push) данных с сервера клиенту.
Важно
Push-уведомления работают только если у вас на сайте есть HTTPS.
Без валидного SSL сертификата запустить не получится. Так что если у вас еще нет поддержки HTTPS, то пришло время её сделать. Рекомендую воспользоваться Let's Encrypt.
Для запуска на localhost нужно прибегать к хитростям. Я же тестировал скрипты на Github Pages.
Сразу хочу оговориться, что push-уведомления не для рекламных рассылок. Отправлять нужно только то, что действительно нужно конкретному пользователю и на что он действительно должен оперативно отреагировать.
Хороший пример:
Плохой пример:
Плохие примеры тоже требуют уведомления, но на них не нужно реагировать оперативно. Эти уведомления можно отправить на почту. Вообще, все важные уведомления рекомендуется дублировать на почту, так-как push-уведомления могут не дойти до пользователя по разным, не зависящих от вас, причинам. Также важным фактором является актуальность события. Об этом я еще поговорю чуть позже. Рекомендую к прочтению:
Вернемся к нашим баранам. Так как же всё это работает? Для начала немного теории.
Среди непосвященных бытует мнение что push-уведомления это простая технология, не требующая для реализации особых ресурсов. В действительности же это целый пул технологий.
Для начала небольшая схема того как все это работает (анимированная схема):

К сожалению, мне не удалось выяснить кто и как создает ID устройства и как сервер сообщений привязывается к конкретному устройству. Я использовал сервер сообщений Firebase Cloud Messaging от Google и его библиотеку. К сожалению, я не смог выяснить можно ли его заменить на свой сервер и как это сделать.
Забавный факт
Изначально для отправки сообщений использовали:
Cloud to Device Messaging
Потом его заменили на:
Google Cloud Messaging
А потом еще раз поменяли на:
Firebase Cloud Messaging
Интересно, что дальше.

Заметка
Google рекомендует использовать переключатель для подписки и отписки от уведомлений. Таким образом, инициация процедуры подписки на уведомления исходит от пользователя, а не от сайта. Принудительно подписывать на уведомления каждого приходящего пользователя, это плохая практика.
Это все выглядит очень сложно, но на сервере все не проще.
Наконец-то, мы перешли к самому главному. Как я уже говорил ранее, в качестве сервера сообщений мы будем использовать Firebase Cloud Messaging, поэтому мы начинаем с регистрации и создания проекта на Firebase.
Тут все просто:
Можно еще покопаться в настройках и поиграться с разделением прав доступа, но, в общем-то, работа с сайтом Firebase закончена.
Начнем с того что создадим Service Worker для получения push-уведомлений.
Создаем файл firebase-messaging-sw.js с следующим содержимым.
// firebase-messaging-sw.js
importScripts('https://www.gstatic.com/firebasejs/3.6.8/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/3.6.8/firebase-messaging.js');
firebase.initializeApp({
    messagingSenderId: '<SENDER_ID>'
});
const messaging = firebase.messaging();где,
<SENDER_ID> — это Sender ID который мы получили после регистрации в Firebase.Важное замечание
Файл Service Worker-а должен называться именно firebase-messaging-sw.js и обязательно должен находиться в корне проекта, то есть доступен по адресу https://example.com/firebase-messaging-sw.js. Путь к этому файлу жестко прописан в библиотеке Firebase.
Написанного кода достаточно для того чтобы показывать уведомления. О дополнительных возможностях поговорим чуть позже. Теперь добавим библиотеку Firebase и скрипт подписки в наш шаблон страницы.
<script type="text/javascript" src="//www.gstatic.com/firebasejs/3.6.8/firebase.js"></script>
<script type="text/javascript" src="/firebase_subscribe.js"></script>Добавляем на страницу кнопку для подписки на уведомления
<button type="button" id="subscribe">Следить за изменениями</button>Подписка на уведомления
// firebase_subscribe.js
firebase.initializeApp({
    messagingSenderId: '<SENDER_ID>'
});
// браузер поддерживает уведомления
// вообще, эту проверку должна делать библиотека Firebase, но она этого не делает
if ('Notification' in window) {
    var messaging = firebase.messaging();
    // пользователь уже разрешил получение уведомлений
    // подписываем на уведомления если ещё не подписали
    if (Notification.permission === 'granted') {
        subscribe();
    }
    // по клику, запрашиваем у пользователя разрешение на уведомления
    // и подписываем его
    $('#subscribe').on('click', function () {
        subscribe();
    });
}
function subscribe() {
    // запрашиваем разрешение на получение уведомлений
    messaging.requestPermission()
        .then(function () {
            // получаем ID устройства
            messaging.getToken()
                .then(function (currentToken) {
                    console.log(currentToken);
                    if (currentToken) {
                        sendTokenToServer(currentToken);
                    } else {
                        console.warn('Не удалось получить токен.');
                        setTokenSentToServer(false);
                    }
                })
                .catch(function (err) {
                    console.warn('При получении токена произошла ошибка.', err);
                    setTokenSentToServer(false);
                });
    })
    .catch(function (err) {
        console.warn('Не удалось получить разрешение на показ уведомлений.', err);
    });
}
// отправка ID на сервер
function sendTokenToServer(currentToken) {
    if (!isTokenSentToServer(currentToken)) {
        console.log('Отправка токена на сервер...');
        var url = ''; // адрес скрипта на сервере который сохраняет ID устройства
        $.post(url, {
            token: currentToken
        });
        setTokenSentToServer(currentToken);
    } else {
        console.log('Токен уже отправлен на сервер.');
    }
}
// используем localStorage для отметки того,
// что пользователь уже подписался на уведомления
function isTokenSentToServer(currentToken) {
    return window.localStorage.getItem('sentFirebaseMessagingToken') == currentToken;
}
function setTokenSentToServer(currentToken) {
    window.localStorage.setItem(
        'sentFirebaseMessagingToken',
        currentToken ? currentToken : ''
    );
}Вот и все. Это весь код который требуется для получения push-уведомлений.
В общем виде отправка уведомления выглядит так:
POST /fcm/send HTTP/1.1
Host: fcm.googleapis.com
Authorization: key=YOUR-SERVER-KEY
Content-Type: application/json
{
  "notification": {
    "title": "Ералаш",
    "body": "Начало в 21:00",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=40&height=40",
    "click_action": "http://eralash.ru/"
  },
  "to": "YOUR-TOKEN-ID"
}где,
YOUR-SERVER-KEY — это Server key который мы получили при регистрации в Firebase;YOUR-TOKEN-ID — это ID устройства конкретного пользователя.Все поля по порядку:
notification — параметры уведомления;title — заголовок уведомления. Лимит 30 символов;body — текст уведомление. Лимит 120 символов;icon — иконка уведомления. Есть некоторые стандарты размеров иконок, но я использую 192x192. Иконки меньшего размера плохо смотрятся на мобильных устройствах;click_action — URL адрес страницы на которую перейдет пользователь кликнув по уведомлению;to — ID устройства получателя уведомления;
Это пример отправки одного уведомления одному получателю. Можно отправить одно уведомление сразу нескольким получателям. Вплоть до 1000 получателей за раз.
{
  "notification": {
    "title": "Ералаш",
    "body": "Начало в 21:00",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "registration_ids": [
    "YOUR-TOKEN-ID-1",
    "YOUR-TOKEN-ID-2"
    "YOUR-TOKEN-ID-3"
  ]
}Пример ответов от сервера сообщений:
{
    "multicast_id": 6407277574671070000,
    "success": 1,
    "failure": 0,
    "canonical_ids": 0,
    "results": [
        {
            "message_id": "0:1489072146895227%e609af1cf9fd7ecd"
        }
    ]
}{
    "multicast_id": 7867877497742898000,
    "success": 1,
    "failure": 0,
    "canonical_ids": 0,
    "results": [
        {
            "message_id": "https://updates.push.services.mozilla.com/m/gAAAAABYwWmlTCKje5OLwedhNUQr9LbOCmZ0evAF9HJBnR-v7DF2KEkZY3zsT8AbrqB6JfJO6Z6vsotLJMmiIvJs9Pt1Q9oc980BRX2IU1-jlzRLIhSVVBLo2i80kBvTMYadVAMIlSIyFkWm-qg_DfLbenlO9z1S4TGMJl0XbN5gKMUlfaIjnX2FBG4XsQjDKasiw8-1L38v"
        }
    ]
}{
    "multicast_id": 8165639692561075000,
    "success": 0,
    "failure": 1,
    "canonical_ids": 0,
    "results": [
        {
            "error": "InvalidRegistration"
        }
    ]
}Полный список кодов ошибок.
Мы не привязаны к какому-то конкретному языку программирования и для простоты примера будем использовать PHP с расширением cURL. Скрипт отправки уведомления нужно запускать из консоли.
#!/usr/bin/env php
<?php
$url = 'https://fcm.googleapis.com/fcm/send';
$YOUR_API_KEY = ''; // Server key
$YOUR_TOKEN_ID = ''; // Client token id
$request_body = [
    'to' => $YOUR_TOKEN_ID,
    'notification' => [
        'title' => 'Ералаш',
        'body' => sprintf('Начало в %s.', date('H:i')),
        'icon' => 'https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192',
        'click_action' => 'http://eralash.ru/',
    ],
];
$fields = json_encode($request_body);
$request_headers = [
    'Content-Type: application/json',
    'Authorization: key=' . $YOUR_API_KEY,
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_HTTPHEADER, $request_headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, $fields);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;Важным свойством для уведомления может является время его актуальности. Это зависит от ваших бизнес процессов. По умолчанию время жизни уведомлений 4 недели. Это очень много для уведомлений такого характера. Например, уведомление "Ваша любимая передача начинается через 15 минут" актуально в течении 15 минут. После этого сообщение уже не актуально и показываться не должно. За контроль над временем жизни отвечает свойство time_to_live со значением от 0 до 2419200 секунд. Подробней читать в документации. Сообщение с указанным TTL будет иметь вид:
{
  "notification": {
    "title": "Ералаш",
    "body": "Начало через 15 минут",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "time_to_live": 900,
  "to": "YOUR-TOKEN-ID"
}Сообщение вида "Ваша любимая передача начинается через 15 минут" актуально в течении 15 минут, но уже через минуту после отправки оно станет не корректным. Потому что передача начнется не через 15 минут, а уже через 14. Контролировать такие ситуации нужно на стороне клиента.
Для этого мы поменяем отправляемое с сервера сообщение:
{
  "data": {
    "title": "Ералаш",
    "time": 1489006800,
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "time_to_live": 900,
  "to": "YOUR-TOKEN-ID"
}Обратите внимание что поле notification поменялось на data. Теперь не будет вызываться обработчик по умолчанию Firebase и нам нужно самостоятельно сделать это. Добавим в конце файла firebase-messaging-sw.js следующие строки:
// регистрируем свой обработчик уведомлений
messaging.setBackgroundMessageHandler(function(payload) {
    if (typeof payload.data.time != 'undefined') {
        var time = new Date(payload.data.time * 1000);
        var now = new Date();
        if (time < now) { // истек срок годности уведомления
            return null;
        }
        var diff = Math.round((time.getTime() - now.getTime()) / 1000);
        // показываем реальное время в уведомлении
        // будет сгенерировано сообщение вида: "Начало через 14 минут, в 21:00"
        payload.data.body = 'Начало через ' +
            Math.round(diff / 60) + ' минут, в ' + time.getHours() + ':' +
            (time.getMinutes() > 9 ? time.getMinutes() : '0' + time.getMinutes())
        ;
    }
    // Сохраяем data для получения пареметров в обработчике клика
    payload.data.data = payload.data;
    // Показываем уведомление
    return self.registration.showNotification(payload.data.title, payload.data);
});
// свой обработчик клика по уведомлению
self.addEventListener('notificationclick', function(event) {
    // извлекаем адрес перехода из параметров уведомления 
    var target = event.notification.data.click_action || '/';
    event.notification.close();
    // этот код должен проверять список открытых вкладок и переключатся на открытую
    // вкладку с ссылкой если такая есть, иначе открывает новую вкладку
    event.waitUntil(clients.matchAll({
        type: 'window'
    }).then(function(clientList) {
        // clientList почему-то всегда пуст!?
        for (var i = 0; i < clientList.length; i++) {
            var client = clientList[i];
            if (client.url == target && 'focus' in client) {
                return client.focus();
            }
        }
        // Открываем новое окно
        if (clients.openWindow) {
            return clients.openWindow(target);
        }
    }));
});Вот таким незамысловатым образом мы получили полный контроль над уведомлением. Что самое интересное, пользователю мы показываем время уведомления в его часовом поясе. Это актуально для сервисов который работают по всему миру или регионах с широким разбросом часовых поясов как у матушки-России.
А теперь поговорим о грустном. Не смотря на все прелести технологии, у неё есть ряд недостатков:
Библиотека Firebase скрывает в себе много тайн и её исследование могло бы дать ответы на некоторые вопросы, но это уже выходит за рамки этой статьи.
Так как для запуска Service Worker-а нужен HTTPS, то самым простым решением было разместить проект на GitHub Pages, что я и сделал.
Проект доступен по адресу: https://github.com/peter-gribanov/serviceworker
Исходники проекта: https://peter-gribanov.github.io/serviceworker/
Проект представляет из себя только клиентскау часть. Для того что бы получить уведомление надо:
Далее, в любом инструменте отправки HTTP запросов отправляем, уже описанный ранее, запрос. Можно использовать сURL, я предпочитаю приложение Postman для Chrome.
POST /fcm/send HTTP/1.1
Host: fcm.googleapis.com
Authorization: key=AAAAaGQ_q2M:APA91bGCEOduj8HM6gP24w2LEnesqM2zkL_qx2PJUSBjjeGSdJhCrDoJf_WbT7wpQZrynHlESAoZ1VHX9Nro6W_tqpJ3Aw-A292SVe_4Ho7tJQCQxSezDCoJsnqXjoaouMYIwr34vZTs
Content-Type: application/json
{
  "notification": {
    "title": "Ералаш",
    "body": "Начало в 21:00",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "to": "YOUR-TOKEN-ID"
}где,
YOUR-TOKEN-ID — это ID устройства который вы получили на странице приложения.Вот и все. Получаем уведомление и радуемся жизни.
Для статьи, я хотел сделать страницу на которой была бы кнопка, нажав на которую получаешь push-уведомление, но из этого ничего не вышло. Ни через AJAX, ни через веб-формы уведомления не доходят. Возможно оно и к лучшему.
Можете убедится в этом сами.
Проект доступен по адресу: https://push.peter-gribanov.ru/
Исходники проекта: https://github.com/peter-gribanov/article-push
Релизы проекта: https://github.com/peter-gribanov/article-push/releases
Пример сообщения для отправки через HTTP клиенты:
POST /fcm/send HTTP/1.1
Host: fcm.googleapis.com
Authorization: key=AAAAaGQ_q2M:APA91bGCEOduj8HM6gP24w2LEnesqM2zkL_qx2PJUSBjjeGSdJhCrDoJf_WbT7wpQZrynHlESAoZ1VHX9Nro6W_tqpJ3Aw-A292SVe_4Ho7tJQCQxSezDCoJsnqXjoaouMYIwr34vZTs
Content-Type: application/json
{
  "data": {
    "title": "Ералаш",
    "body": "Начало в 21:00",
    "icon": "https://eralash.ru.rsz.io/sites/all/themes/eralash_v5/logo.png?width=192&height=192",
    "click_action": "http://eralash.ru/"
  },
  "to": "YOUR-TOKEN-ID"
}где,
YOUR-TOKEN-ID — это ID устройства который вы получите после подтверждения. ID устройства будет отличатся от того что на GitHub Pages и показывается он только в консоли браузера.Обратите внимание, на GitHub Pages используется свойство notification, а на моей площадке data.