Предисловие


Я начинающий 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.

    image

  • 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`

Спасибо за прочтение!
Поделиться с друзьями
-->

Комментарии (2)


  1. juggleru
    03.11.2016 23:08

    Было бы круто, если б уведомления автоматически отправлялись по определённым изменениям в базе данных.


    Попробуйте scorocode.ru, там реализован механизм запуска серверного кода из триггеров (например, после вставки документа в коллекцию), а уже в серверном коде можно отправлять PUSH и вообще делать все что угодно.


    1. rostopira
      04.11.2016 10:49

      Спасибо, обязательно попробую