Внедряем кросс-платформенные пуш-уведомления: начало
Добрый день! Меня зовут Владимир Столяров, я бэкенд-разработчик в команде Клиентские коммуникации в ДомКлике. В этой статье я расскажу о том, как внедрить кросс-платформенные пуш-уведомления. Хотя про это уже написано немало, я бы хотел рассказать о некоторых нюансах, с которыми нам пришлось столкнуться в процессе внедрения. Для лучшего понимания происходящего также напишем с вами небольшое веб-приложение, способное принимать пуши.
Для начала нужно понять, куда мы вообще хотим отправлять пуши. В нашем случае это веб-сайт, 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.
Несмотря на многообразие стандартов и сервисов, схема работы будет примерно одинаковой:
- Клиент подключается к пуш-серверу и получает уникальный идентификатор — токен.
- Клиент отправляет токен серверу конкретного приложения, чтобы стать связаным с учетной записью пользователя.
- Сервер приложений начинает по полученному токену отправлять пуши для конкретного пользователя.
Для начала просто получим пуш-токен от Firebase и попробуем отправить пуш. Нужно зарегистрировать проект в консоли Firebase и получить конфигурацию для веб-приложения. Для корректного функционирования будет нужен локальный HTTP-сервер с передачей статики.
Сделаем страницу с одной кнопкой и необходимыми скриптами:
Тестовый пример приема пушей
Firebase token
Также потребуется скрипт сервис-воркера. По умолчанию он подгружается автоматически по пути /firebase-messaging-sw.js
. Для начала будем использовать готовый скрипт отсюда.
Открываем страницу, нажимаем на кнопку, разрешаем уведомления в браузере и копируем отображенный токен. Для удобства работы с API вручную можно создать долговременный ключ сервера (не сервисный аккаунт). Делаем простой запрос:
curl -X POST 'https://fcm.googleapis.com/fcm/send' \
-H 'Authorization: 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
:
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
.
Теперь добавим пользователей в наше приложение
Для удобства заведем новую страницу:
Тестовый пример приема пушей
Firebase token
Нам понадобится простенький бэкенд, писать будем на 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
:
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.
В следующих частях я планирую рассказать о дополнительных возможностях, предоставляемых пуш-сервисами, более детально о подводных камнях на клиентских платформах, а также о том, как можно принимать веб-пуши, не используя браузер.