10 июля

Внедряем кросс-платформенные пуш-уведомления: начало

Блог компании ДомКликGoogle APIGoБраузеры

Добрый день! Меня зовут Владимир Столяров, я бэкенд-разработчик в команде Клиентские коммуникации в ДомКлике. В этой статье я расскажу о том, как внедрить кросс-платформенные пуш-уведомления. Хотя про это уже написано немало, я бы хотел рассказать о некоторых нюансах, с которыми нам пришлось столкнуться в процессе внедрения. Для лучшего понимания происходящего также напишем с вами небольшое веб-приложение, способное принимать пуши.



Для начала нужно понять, куда мы вообще хотим отправлять пуши. В нашем случае это веб-сайт, iOS-приложение и Android-приложение.


Начнем с веб-пушей. Для их получения браузер подключается к своему пуш-серверу, идентифицируется и принимает уведомления в сервис-воркер (в нем срабатывает событие push). Нюанс тут в том, что у каждого браузера пуш-сервис свой:


  • У Firefox он называется Mozilla Push Service. Его исходный код и спецификация протокола открыты, чем мы позже воспользовались.
  • У Chrome это Google Cloud Messaging (не Firebase Cloud Messaging, что можно увидеть по именам доменов в исходном коде), и так далее.

Хорошая новость для нас в том, что веб-пуши стандартизированы IETF (https://datatracker.ietf.org/wg/webpush/documents/), и поддерживать разные форматы API для каждого браузера как на клиенте, так и на сервере нам не придется.


Теперь рассмотрим устройства на базе Android. Здесь есть несколько вариантов:


  • Если в системе установлены Google Apps, то можно воспользоваться Firebase Cloud Messaging.
  • Если у вас устройство от Huawei без Google Apps, то можно использовать Huawei Push Kit.
  • Можно написать собственный пуш-сервер или воспользоваться готовыми проектами, например, https://bubu1.eu/openpush/, благо открытость платформы позволяет.

Далее идет iOS. В отличие от Android, способ отправить уведомления на устройства Apple всего один — использовать Apple Push Notification service (APNs).


Может возникнуть логичный вопрос: неужели придется поддерживать всё это многообразие стандартов, API и прочего на серверной стороне? На самом деле, всё не так уж и плохо, так как Firebase Cloud Messaging, помимо отправки на Android, покрывает еще и веб-пуши и работает с APNs. Так мы пришли к следующей схеме: на устройства Huawei без Google Apps отправляем через Huawei Push Kit, в остальных случаях пользуемся Firebase Cloud Messaging.



Несмотря на многообразие стандартов и сервисов, схема работы будет примерно одинаковой:


  1. Клиент подключается к пуш-серверу и получает уникальный идентификатор — токен.
  2. Клиент отправляет токен серверу конкретного приложения, чтобы стать связаным с учетной записью пользователя.
  3. Сервер приложений начинает по полученному токену отправлять пуши для конкретного пользователя.

Попробуем написать тестовое приложение


Для начала просто получим пуш-токен от Firebase и попробуем отправить пуш. Нужно зарегистрировать проект в консоли Firebase и получить конфигурацию для веб-приложения. Для корректного функционирования будет нужен локальный HTTP-сервер с передачей статики.


Сделаем страницу с одной кнопкой и необходимыми скриптами:


simple_example.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Тестовый пример приема пушей</title>
    </head>
    <body>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-messaging.js"></script>
    <script>
        function toClipboard(text) {
            const tmp = document.createElement('textarea');
            tmp.hidden = true;
            tmp.value = text;
            window.document.body.appendChild(tmp);
            tmp.select();
            window.document.execCommand("copy");
            alert("Copied the text: " + text);
            window.document.body.removeChild(tmp);
        }
    </script>
    <button onclick="enableNotifications()">Получать уведомления</button>
    <div id="pushTokenLayer" hidden>
        Firebase token <code id="pushTokenValue" style="cursor:pointer" onclick="toClipboard(this.innerText)"></code><br/>
    </div>
    <script>
        async function enableNotifications() {
            // Insert your firebase project config here
            const firebaseConfig = {};

            const app = firebase.initializeApp(firebaseConfig);
            const messaging = app.messaging();

            const permission = await Notification.requestPermission();
            if (permission !== 'granted') {
                console.log("user denied notifications")
            }

            const token = await messaging.getToken();

            window.document.getElementById("pushTokenLayer").removeAttribute("hidden");

            const pushTokenValue = window.document.getElementById("pushTokenValue");
            pushTokenValue.innerText = token
        }
    </script>
    </body>
</html>

Также потребуется скрипт сервис-воркера. По умолчанию он подгружается автоматически по пути /firebase-messaging-sw.js. Для начала будем использовать готовый скрипт отсюда.


Открываем страницу, нажимаем на кнопку, разрешаем уведомления в браузере и копируем отображенный токен. Для удобства работы с API вручную можно создать долговременный ключ сервера (не сервисный аккаунт). Делаем простой запрос:


curl -X POST 'https://fcm.googleapis.com/fcm/send' \
-H 'Authorization: key=<fcm server key>' \
-H 'Content-Type: application/json' \
-d '{
 "to" : "<токен из браузера>",
 "notification" : {
     "body" : "Body of Your Notification",
     "title": "Title of Your Notification"
 }
}'

Получаем уведомление:



Тут есть важный момент, о котором можно забыть: пуш-токен никак не связан с пользователем приложения, он связан с конкретным получателем (т.е. с клиентской конфигурацией). На мобильных устройствах он связан с конкретной инсталляцией приложения.


Посмотрим, что нам приходит в браузер. В документации описан колбэк setBackgroundMessageHandler. Модифицируем сервис-воркер, добавив в конец файла код:


messaging.setBackgroundMessageHandler((payload) => {
  console.log('Message received. ', payload);
  // ...
});

Открываем консоль сервис-воркера, снова отправляем пуш… и ничего не видим в консоли, хотя уведомление отобразилось. Почему же? Ответ в есть в документации:


Note: If you set notification fields in your message payload, your setBackgroundMessageHandler callback is not called, and instead the SDK displays a notification based on your payload.

В нашем случае в запросе есть поле notification и данный колбэк не вызывается. Это довольно важное замечание, к нему мы вернемся дальше.


Тем не менее, можно это обойти, обрабатывая входящие пуши вручную. Поменяем содержимое firebase-messaging-sw.js:


firebase-messaging-sw.js
self.addEventListener("push", event => { event.waitUntil(onPush(event)) });

async function onPush(event) {
    const push = event.data.json();
    console.log("push received", push)

    const { notification = {} } = {...push};

    await self.registration.showNotification(notification.title, {
        body: notification.body,
    })
}

Здесь мы считываем полезную нагрузку в json и парсим ее в js-объект, который будет выведен в консоль, заодно показывая уведомление. Обратите внимание на waitUntil внутри обработчика: он нужен для того, чтобы сервис-воркер не завершил работу до окончания асинхронного вызова onPush.


Теперь добавим пользователей в наше приложение


Для удобства заведем новую страницу:


user_example.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Тестовый пример приема пушей</title>
    </head>
    <body>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-messaging.js"></script>
    <script>
        function toClipboard(text) {
            const tmp = document.createElement('textarea');
            tmp.hidden = true;
            tmp.value = text;
            window.document.body.appendChild(tmp);
            tmp.select();
            window.document.execCommand("copy");
            alert("Copied the text: " + text);
            window.document.body.removeChild(tmp);
        }
    </script>
    <form onsubmit="enableNotifications(this); return false" action="#">
        User ID <input type="number" name="userID" required/>
        <input type="submit" value="Получать уведомления"/>
    </form>
    <div id="pushTokenLayer" hidden>
        Firebase token <code id="pushTokenValue" style="cursor:pointer" onclick="toClipboard(this.innerText)"></code><br/>
        <button onclick="logout()">Выйти</button>
    </div>
    <script>
        // Insert your firebase project config here
        const firebaseConfig = {};

        const app = firebase.initializeApp(firebaseConfig);
        const messaging = app.messaging(); // this fails if browser not supported

        async function getMe() {
            const resp = await fetch(`${window.location.origin}/api/v1/users/me`, {
                credentials: "include",
            });

            if (resp.status === 401) {
                return null;
            }
            if (!resp.ok) {
                throw `unexpected status code ${resp.status}`
            }

            return await resp.json();
        }

        async function sendToken(token) {
            const me = await getMe();
            if (me === null) {
                console.error("unauthorized on send token");
                return;
            }

            window.localStorage.getItem("push-token-user");

            const resp = await fetch(`${window.location.origin}/api/v1/tokens`, {
                method: "POST",
                body: JSON.stringify({
                    token: {token: token, platform: "web"}
                }),
                credentials: "include",
            })
            if (!resp.ok) {
                console.error("send token failed");
                return;
            }

            // put current user to local storage for comparison
            window.localStorage.setItem("push-token-user", JSON.stringify(me));
        }

        getMe().
            then(me => {
                if (!me) {
                    // if user not authorized we must invalidate firebase registration
                    // to prevent receiving pushes for unauthorized user
                    // this may happen i.e. if 'deleteToken' failed on logout
                    console.log(`user unauthorized, invalidate fcm registration`);
                    window.localStorage.removeItem("push-token-user");
                    messaging.deleteToken();
                    return null;
                }

                // if user authorized and it's not user that received push token earlier
                // we also must invalidate token to prevent receiving pushes for wrong user
                // this may happen if i.e. user not logged out explicitly
                let pushTokenUser = window.localStorage.getItem("push-token-user");
                if (pushTokenUser && JSON.parse(pushTokenUser).id !== me.id) {
                    console.log("token for wrong user, invalidate fcm registration");
                    window.localStorage.removeItem("push-token-user");
                    messaging.deleteToken();
                    pushTokenUser = null;
                }

                // if user authorized and permission granted but token wasn't send we should re-send it
                if (!pushTokenUser && Notification.permission === "granted") {
                    console.log("token not sent to server while notification permission granted");
                    messaging.getToken().then(sendToken);
                }
            }).
            catch(e => console.log("get me error", e))

        // according to sources of firebase-js-sdk source code registration token refreshed once a week
        messaging.onTokenRefresh(async () => {
            const newToken = await messaging.getToken();
            pushTokenValue.innerText = newToken;
            console.log(`updated token to ${newToken}`)
            await sendToken(newToken)
        })

        async function enableNotifications(form) {
            const loginResponse = await fetch(`${window.location.origin}/api/v1/users/login`, {
                method: "POST",
                body: JSON.stringify({
                    id: Number(form.elements.userID.value),
                })
            })
            if (!loginResponse.ok) {
                alert("login failed");
                return;
            }

            const permission = await Notification.requestPermission();
            if (permission !== 'granted') {
                console.log("user denied notifications")
                return;
            }

            const token = await messaging.getToken();

            window.document.getElementById("pushTokenLayer").removeAttribute("hidden");

            const pushTokenValue = window.document.getElementById("pushTokenValue");
            pushTokenValue.innerText = token

            await sendToken(token)
        }

        async function logout() {
            const messaging = firebase.messaging();
            await messaging.deleteToken();
            console.log(`deleted token from firebase`)
            window.document.getElementById("pushTokenLayer").setAttribute("hidden", "");
            await fetch(`${window.location.origin}/api/v1/users/logout`, {
                method: "POST",
                credentials: "include",
            })
        }
    </script>
    </body>
</html>

Нам понадобится простенький бэкенд, писать будем на Go. Тут приведу только пример кода, отвечающего за хранилище токенов:


inmemory.go
type MemoryStorage struct {
    mu          sync.RWMutex
    userTokens  map[uint64][]Token
    tokenOwners map[string]uint64
}

func NewMemoryStorage() *MemoryStorage {
    return &MemoryStorage{
        userTokens:  map[uint64][]Token{},
        tokenOwners: map[string]uint64{},
    }
}

type Token struct {
    Token    string `json:"token"`
    Platform string `json:"platform"`
}

func (ms *MemoryStorage) SaveToken(ctx context.Context, userID uint64, token Token) error {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    owner, ok := ms.tokenOwners[token.Token]
    // if old user comes with some token it's ok
    if owner == userID {
        return nil
    }
    // if new user come with existing token we
    // should change it's owner to prevent push target mismatch
    if ok {
        ms.deleteTokenFromUser(token.Token, owner)
    }

    ut := ms.userTokens[userID]
    ut = append(ut, token)
    ms.userTokens[userID] = ut

    ms.tokenOwners[token.Token] = userID

    return nil
}

func (ms *MemoryStorage) deleteTokenFromUser(token string, userID uint64) {
    ut := ms.userTokens[userID]
    for i, t := range ut {
        if t.Token == token {
            ut[i], ut[len(ut)-1] = ut[len(ut)-1], Token{}
            ut = ut[:len(ut)-1]
            break
        }
    }
    ms.userTokens[userID] = ut
}

func (ms *MemoryStorage) UserTokens(ctx context.Context, userID uint64) ([]Token, error) {
    ms.mu.RLock()
    defer ms.mu.RUnlock()

    tokens := ms.userTokens[userID]
    ret := make([]Token, len(tokens))
    copy(ret, tokens)

    return ret, nil
}

func (ms *MemoryStorage) DeleteTokens(ctx context.Context, tokens []string) error {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    for _, token := range tokens {
        user, ok := ms.tokenOwners[token]
        if !ok {
            return nil
        }

        ms.deleteTokenFromUser(token, user)
    }
    return nil
}

В бэкенд также добавлен код отправки пушей конкретному пользователю.


Базово мы обеспечиваем следующую функциональность:


  • При входе пользователя запрашиваем разрешение на показ уведомлений, и при согласии получаем пуш-токен и отправляем его на сервер. Пуш-токен привязывается к пользователю с помощью сессионных/авторизационных кук.
  • При выходе мы инвалидируем пуш-токен. При следующей попытке отправить его Firebase нам ответит ошибкой, что такой токен не существует, и мы удалим его из хранилища.
  • При обновлении пуш-токена мы отправляем новый в хранилище. Старый токен мы удалим уже при следующей отправке пуша.

Опишу несколько важных моментов, на которые мы наткнулись уже на практике (они отражены в примере):


  • Пуш-токены имеют ограниченный срок жизни, однако узнать его из самого токена, на первый взгляд, невозможно. Судя по коду firebase-js-sdk, этот срок чуть больше недели, так как колбэк на обновление токена onTokenRefresh вызывается раз в неделю.
  • Разные пользователи могут прислать одинаковые пуш-токены. Такое возможно в случае, если запрос на инвалидацию в Firebase не прошел успешно. Для решения этой проблемы мы в данном случае меняем владельца токена.
  • У пользователя может завершиться сессия без явного логаута. Т.е. пользователь больше не авторизован, однако уведомления продолжают поступать. Способ решения этой проблемы зависит от архитектуры приложения. Мы при отправке пуш-токена на сервер сохраняем идентификатор пользователя еще и локально, при каждой загрузке страницы сверяя его с ответом на запрос о текущем пользователе. Если значения различаются или пользователь не авторизован, то пуш-токен инвалидируется. Однако у такого подхода всё же есть один недостаток: инвалидация происходит только в случае загрузки страницы сайта.
  • Сохраняйте платформу, с которой получен токен. Это поможет при дальнейшей кастомизации: например, добавить возможность ответа в чат прямо из пуша (в Android/iOS можно, в браузере — нет), кнопки и прочее.

И вот, грабли собраны, доработки выложены в прод. Пуши ходят… или не ходят? Самое время поговорить про


Надежность


Никаких методов подтверждения доставки от клиента серверу приложений изначально не предусмотрено, хотя в Huawei над этим задумались и сделали. Поэтому нам придется реализовывать эту функциональность самим. Первое, что приходит в голову — отправлять на сервер HTTP-запрос при получении пуша. Для этого нам потребуется идентифицировать каждый пуш, благо и Firebase, и Huawei это позволяют: можно пробросить произвольные данные при отправке уведомления.


Идея следующая: мы генерируем одноразовый токен подтверждения (в нашем случае это просто UUID) и отправляем его в пуше. Клиент при получении и показе пуша делает HTTP-запрос, в который включается присланный токен подтверждения. Немного дорабатываем бекенд и firebase-messaging-sw.js:


firebase-messaging-sw.js
self.addEventListener("push", event => { event.waitUntil(onPush(event)) });

async function onPush(event) {
    const push = event.data.json();
    console.log("push received", push)

    const { notification = {}, data = {} } = {...push};

    await self.registration.showNotification(notification.title, {
        body: notification.body,
    })

    if (data.id) {
        await fetch(`${self.location.origin}/api/v1/notifications/${data.id}/confirm`, { method: "POST" })
    }
}

И если с вебом нам хватило такой простой доработки, то с мобильными устройствами всё несколько сложнее. Помните про замечание в документации о setBackgroundMessageHandler? Так вот, дело в том, что в Firebase (да и в Huawei) есть не совсем очевидное (по API) разделение на, условно, информационные пуши (если есть поле notification) и data-пуши. По задумке, информационные пуши никак не обрабатываются приложением и на их основе сразу формируется уведомление, а data-пуши попадают в специальный обработчик и дальше приложение решает, что делать.


Если при получении веб-пушей с ними можно работать до показа, отказавшись от firebase-js-sdk в сервис-воркере, то в Android так не получится. Поэтому для Android мы перенесли всю нужную информацию исключительно в data и перестали отправлять notification, что позволило нам реализовать подтверждение доставки.


Для APNs же достаточно просто выставить mutable-content в 1, и тогда при обработке пуша можно будет выполнять некоторый код, но с довольно серьезными ограничениями, хотя этого вполне достаточно для простого HTTP-запроса. Собственно, именно из-за ограничений iOS при подтверждении пуша не задействуется пользовательская сессия, а используются одноразовые токены.


Отсюда вытекает еще одна интересная особенность: data-пуши можно использовать не только для уведомлений пользователя, но и для доставки какой-либо информации, настроек и прочего в приложение. Примерно таким способом, например, Telegram обходил блокировки, отправляя адреса серверов через пуши.


Мы же используем механизм подтверждения следующим образом: для некоторых типов уведомлений, если не дождались подтверждения через, скажем, 15 минут после отправки, отправляется смс. А чтобы исключить случай, когда после смс приходит пуш, можно выставить TTL при его отправке.


Также этот механизм позволяет нам собирать статистику по доставляемости. Исходя из собранных данных, доставляемость пушей по платформам получается примерно следующей:


  • Android (исключая Huawei) — 40 %
  • Web — 50 %
  • iOS — 70 %

Для Huawei достаточное количество данных еще не накоплено. Следует сказать о том, что мы не предпринимали никаких действий по увеличению доли доставленных пушей, как это сделали, например, в Яндекс.Почте.


Итоговая архитектура получается примерно такой:



Полную версию проекта можно взять на GitHub.


В следующих частях я планирую рассказать о дополнительных возможностях, предоставляемых пуш-сервисами, более детально о подводных камнях на клиентских платформах, а также о том, как можно принимать веб-пуши, не используя браузер.

Теги:пуш-уведомленияfirebase cloud messaginghuawei push kitapns
Хабы: Блог компании ДомКлик Google API Go Браузеры
+27
5,2k 94
Комментарии 15
Лучшие публикации за сутки