Предисловие
Я начинающий Android разработчик, за плечами у меня около 1,5 года опыта в данной сфере. Взялся я за довольно-таки большой проект, в команде кроме меня никого нет, а бекенд писать я не умею. Решено было в качестве платформы выбрать Firebase. Так как специфика моего приложения требовала постоянной работы и получения данных из базы в фоне, я просто вставил все EventListener-ы в сервис и был доволен. До того самого момента, когда я решил написать iOS версию. Выучив Swift я ринулся в бой. Firebase SDK благо оказались очень хороши и похожи для обеих систем, так что я быстро написал основную часть и… Почему не работает?
Суть проблемы и постановка задачи
iOS мягко говоря не уважает приложения работающие в фоне. Единственный способ пробудить приложение которое убила система (а убивает она их из-за любого чиха) — уведомления через APNS. К тому же, на Android 6+ постоянное соединение не держится и уведомления в итоге приходят с задержкой от 5 минут до 2 часов (на 7.1), если они реализованы не через GCM. Хорошо, что Firebase Cloud Messaging поддерживает и APNS, и GCM. Плохо, что для этого нужен дополнительный сервер. Было бы круто, если б уведомления автоматически отправлялись по определённым изменениям в базе данных. Инженеры обещают сделать нечто подобное в следующем году… А работать то должно уже сейчас.
Собственно, на то чтобы реализовать полноценный сервер с авторизацией и XMPP не у всех есть желание / знания / ресурсы. Итак, у нас есть две проблемы — авторизация пользователя, который хочет отправить push и собственно его отправка. Это в моём случае. Если вам нужно просто отслеживать появление новых данных в базе (например статей) и отправлять уведомление всем, кто подписан на эту тему — то всё ещё проще.
Подготовка
Изначально всё было написано на Python, но ситуация приключилась аналогичной из одной из недавних статей.
На Python возникли проблемы с повторным открытием устройства на чтение — во второй раз данные уже не читались. Мы не стали разбираться и просто переписали то же самое на Golang — после этого все заработало.
Итак, как это работает? Мы используем Firebase REST API чтобы следить за изменениями интересующих нас веток, и в случае добавления новых элементов отправляем пуш через FCM. Где оно работает? Да где угодно. И это одно из главных преимуществ. Вам не обязательно иметь статический IP и приличный
Но перед тем как перейти к делу, нужно понимать две вещи.
Во-первых, слежение за всей базой потребует предварительной её загрузки. А если «сервер-помошник» лежал (или перемещался на другой компьютер) — то он загрузит всё заново и заново отправит пуши. Для решения этой проблемы я создал в корне БД ветку notif — в неё пользователи (либо загрузчик контента) добавляют уведомления, которые нужно разослать пользователям, а сервер их удаляет после отправки. Использую я вот такую структуру:
"notif" {
"$key" { // Автоматически сгенерированный методом push() ключ
"from": "2vgajTP5Vd...", // UID пользователя
"to": "all_users", // Либо название темы, либо UID
"value": "Hello, Habr!", // Опциональное значение, например сообщение из чата
"type": "message"// Тип сообщения, нужен устройству для корректного отображения
}
}
Во-вторых, нам нужно знать куда отправлять. Поэтому я создал ещё одну ветку «tokens» в которую устройства записывают токены регистрации в FCM. Тонкости реализации на клиентских устройствах это уже тема для отдельной статьи. Храню я их в этой ветке в формате:
"tokens" {
"userId": "fcmToken"
}
Также, чтобы сообщение нельзя было отправить от чужого имени или получать чужие, я дополнил Firebase Database Rules:
{
"rules": {
/// Тут куча других правил
"notif": {
".read": "false",
"$key": {
".write": "auth != null && newData.child('from').val() === auth.uid"
}
},
"tokens": {
".read": "false",
"$key": {
".write": "auth != null && $key == auth.uid"
}
}
}
}
Также нам понадобятся ключи и библиотеки:
- Firebase Database Secret — для чтения данных с запретом на чтение, охохо (тут мог бы быть смайлик). Получить его можно в настройках Firebase Console.
- FCM API key — для отправки пушей. Получить можно там же, на следующей вкладке.
- FireGo — для слежения за базой данных
- FCM — не писать же самому?
Реализация (ну наконец-то!)
Для упрощения примера я убрал из него кэширование токенов, удаление устаревших и проверку покупок приложения через Android publisher API, но если что-то из этого вам интересно — пишите в комментарии, поделюсь полным кодом.
Итак, основная часть программы:
package main
import (
"github.com/zabawaba99/firego"
"github.com/edganiukov/fcm"
"fmt"
"log"
)
const (
//TODO вставьте сюда свои ключи
FDBSecret = "P3cUiIQytto**************NzQM5TrzERjEDO"
FCMAPIKey = "AIzaSyDXjRG**************8oOCMrPj18JVD8"
DAY_IN_SEC = 86400
// Названия веток в базе
TOKENS = "tokens"
NOTIFICATIONS = "notif"
)
var (
FBDB = firego.New("https://kidgl.firebaseio.com", nil) // Объект для доступа к базе данных
FCM, _ = fcm.NewClient(FCMAPIKey) // Объект для отправки пушей
)
func main() {
FBDB.Auth(FDBSecret)
FBDB.Child(NOTIFICATIONS).ChildAdded(gotPush)
// Процесс, не умирай, подумай
for {
var res string
fmt.Scanln(&res)
if res == "exit" {
return
} else {
println(`Type "exit" to stop service`)
}
}
}
Функция ChildAdded принимает на вход функцию, которую она будет вызывать в случае изменений в базе. Исполняется это всё в отдельном потоке (а может и не в одном, откуда мне знать), так называемом Goroutine. Поэтому, чтобы программа не завершилась, я использую вечный цикл (а она всё равно завершиться от какого-нибудь исключения, перезапуск осуществляется bash-скриптом который на вход принимает stderr).
С этим всё ясно, теперь функция gotPush:
func gotPush(snapshot firego.DataSnapshot, previousChildKey string) {
// Мы получили этот пуш, в базе он больше не нужен
FBDB.Child(NOTIFICATIONS).Child(snapshot.Key).Remove()
// Разбираем его на запчасти
data := snapshot.Value.(map[string]string{})
from := data["from"]
to := data["to"]
typ := data["type"]
// Получаем сам токен, потому что мы знаем кому отправлять, но не знаем куда
var token string
FBDB.Child(TOKENS).Child(to).Value(&token)
msg := &fcm.Message{
Token: token,
// Data - это всё, что будет доставлено на устройство пользователя
Data: &fcm.Data{
"from": from,
"type": typ,
"value": data["value"],
},
CollapseKey: typ + from + to, // Используется для замещения старых уведомлений новыми
Priority: "high",
ContentAvailable: true,
TimeToLive: DAY_IN_SEC, // Наличие этого параметра повышает вероятность доставки пуша
}
response, err := FCM.Send(msg)
if (err!=nil) {
log.Println(err)
}
println("Отправлено: ", response.Success)
println("Ошибок: ", response.Failure)
if response.Results[0].Unregistered() {
// TODO: Приложение удалено с устройства, его можно удалить из базы или оповестить других пользователей об удалении
}
}
Ну в общем-то и всё, можно запускать и радоваться жизни пушам. В моём случае ещё понадобилось скомпилировать для linux на макбуке, я думаю многим тоже пригодиться `env GOOS=linux GOARCH=amd64 go build backend_helper.go`
Спасибо за прочтение!
Автор: rostopira