Рассылка Push-уведомлений с SpringBoot сервера

Предисловие


Приветствую Вас. Недавно передо мной стала задача — настроить Push-уведомления на сайте. С этим я столкнулся впервые и во много разобраться мне помогла эта статья. В ней же уже есть описание серверной стороны, но, в процессе изучения данной темы я обнаружил более удобный способ реализации средствами самой библиотеки Firebase. Собственно, о нем я и хотел бы вам рассказать, т.к. внятного объяснения в интернете мне не удалось найти.

Также данная статья может быть полезна программирующим на Node.js, Python и Go, поскольку библиотека есть и на этих языках.

Непосредственно к сути


В данной статье я расскажу только о серверной стороне.
(клиентскую часть Вы можете настроить используя ту самую статью)
Итак:

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

Для удобства объявим в application.properties путь к скаченному файлу

fcm.service-account-file = /path/to/file.json

Добавим необходимые зависимости в pom.xml

<dependency>
    <groupId>com.google.firebase</groupId>
    <artifactId>firebase-admin</artifactId>
    <version>6.7.0</version>
</dependency>

Создадим бин возвращающий наш JSON:

@ConfigurationProperties(prefix = "fcm")
@Component
public class FcmSettings {
    private String serviceAccountFile;

    public String getServiceAccountFile() {
        return this.serviceAccountFile;
    }

    public void setServiceAccountFile(String serviceAccountFile) {
        this.serviceAccountFile = serviceAccountFile;
    }
}


Конфиг-объект

@Getter 
@Setter
public class PushNotifyConf {

    private String title;
    private String body;
    private String icon;
    private String click_action;
    private String ttlInSeconds;

    public PushNotifyConf() {
    }

    public PushNotifyConf(String title, String body, String icon, 
                                    String click_action, String ttlInSeconds) {
        this.title = title;
        this.body = body;
        this.icon = icon;
        this.click_action = click_action;
        this.ttlInSeconds = ttlInSeconds;
    }
}

Поля:
  • title — Оглавление уведомления
  • body — текст уведомления
  • icon — ссылка на картинку
  • click_action — ссылка, куда отправится пользователь при клике на уведомление (с названием, пример в сервисе)
    Их можно добавлять несколько, но все отобразит не каждый браузер (ниже пример из Chroma)
  • ttlInSeconds — время актуальности уведомления


И сервис, в котором и будет вся логика отправки уведомлений:

@Service
public class FcmClient {


    public FcmClient(FcmSettings settings) {
        Path p = Paths.get(settings.getServiceAccountFile());
        try (InputStream serviceAccount = Files.newInputStream(p)) {
            FirebaseOptions options = new FirebaseOptions.Builder()
                  .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                  .build();

            FirebaseApp.initializeApp(options);
        } catch (IOException e) {
            Logger.getLogger(FcmClient.class.getName())
                    .log(Level.SEVERE, null, e);
        }
    }

    public String sendByTopic(PushNotifyConf conf, String topic)
            throws InterruptedException, ExecutionException {
       
        Message message = Message.builder().setTopic(topic)
                .setWebpushConfig(WebpushConfig.builder()
                        .putHeader("ttl", conf.getTtlInSeconds())
                        .setNotification(createBuilder(conf).build())
                        .build())
                .build();

        String response = FirebaseMessaging.getInstance()
                .sendAsync(message)
                .get();
        return response;
    }

    public String sendPersonal(PushNotifyConf conf, String clientToken)
            throws ExecutionException, InterruptedException {
        Message message = Message.builder().setToken(clientToken)
                .setWebpushConfig(WebpushConfig.builder()
                        .putHeader("ttl", conf.getTtlInSeconds())
                        .setNotification(createBuilder(conf).build())
                        .build())
                .build();

        String response = FirebaseMessaging.getInstance()
                .sendAsync(message)
                .get();
        return response;
    }

    public void subscribeUsers(String topic, List<String> clientTokens)
            throws  FirebaseMessagingException {
        for (String token : clientTokens) {
             TopicManagementResponse response = FirebaseMessaging.getInstance()
                    .subscribeToTopic(Collections.singletonList(token), topic);
        }
    }

    private WebpushNotification.Builder createBuilder(PushNotifyConf conf){
        WebpushNotification.Builder builder = WebpushNotification.builder();
        builder.addAction(new WebpushNotification
                .Action(conf.getClick_action(), "Открыть"))
                .setImage(conf.getIcon())
                .setTitle(conf.getTitle())
                .setBody(conf.getBody());
        return builder;
    }
}

Я: — Firebase, почему так много билдим?
Firebase: — Потому что
  1. Конструктор служит для инициализации FirebaseApp с использованием нашего JSON-файла
  2. Метод sendByTopic() производит отправку уведомлений пользователям подписанным на заданную тему.
  3. Метод subscribeUsers() подписывет на тему (topic) пользователей (clientTokens).
    может выполняться асинхронно, для этого используется .subscribeToTopicAsync()

  4. Метод sendPersonal() реализует отправку персонального уведомления пользователю (clientToken)
  5. Метод createBuilder() создает сообщение

Результат

image

Другой браузер

image
Иконок нет потому, что Ubuntu:)

Подводим итоги



По сути, библиотека Firebase собирает нам JSON примерно такого вида:

{from: "Server key"
​
     notification: {
​​        title: "Привет Habr"
        actions: (1) [​​​
               0: Object { 
                       action: "https://habr.com/ru/top/",
                       title: "Открыть" }
                           ]​​
        length: 1​​
        body: "как-то так"​​
        image: "https://habrastorage.org/webt/7i/k5/77/7ik577fzskgywduy_2mfauq1gxs.png"​​
          }
​priority: "normal"}

А на стороне клиента Вы уже парсите его, как нравится.

Спасибо за внимание!

Полезные ссылки:


firebase.google.com/docs/reference/admin/java/reference/com/google/firebase/messaging/WebpushNotification

habr.com/ru/post/321924/#otpravka-uvedomleniy-s-servera

firebase.google.com/docs/web/setup
Поделиться публикацией

Комментарии 10

    +1
    Коду в этой статье бы код-ревью пройти, а потом уже в народ)
      0
      У меня есть возможность редактирования статьи. Поэтому, с радостью выслушаю Ваши предложения по ее улучшению.
        0
        1) Где-то используете билдер в формате лесенки, где-то вызов каждый метода на каждой строке у экземпляра билдера
        2) ttlInSeconds в виде строки? замените на примитив
        3) Не проверяете существование файла настроек при создании бина
        4) Используете sout
        5) Метод с 6ю параметрами заменил бы конфиг-объектом
        6) принципы DRY
          +1
          Благодарю Вас. Постараюсь исправить эти недочеты.
      0
      Используете ли очередь (MQ) для отправки уведомлений или они отправляются синхронно по событиям? Недавно задумался над этим вопросом: операция отправки push-уведомления может быть дорогой, нужно отправить сетевой запрос в Firebase, а то и сходить в БД за данными для уведомления. Стоит ли сразу озаботиться настройкой очереди для пушей, чтобы сервер не падал под нагрузкой при большом количестве уведомлений?
        0
        Сложности на серверной стороне

        • Понятно, что идентификатор устройства, присылаемый пользователем, мы сохраняем в базу данных;
        • Идентификатор устройства хорошо бы привязывать к пользователю, чтобы отправлять персонализированные сообщения;
        • Стоит помнить, что пользователь у нас один, а устройств у него может быть несколько, также одним устройством могут пользоваться несколько пользователей;
        • Отправка уведомлений пользователям не самая дешевая операция и поэтому событие, инициирующее отправку уведомления, нужно ставить в очередь на отправку;
        • Только маленькие проекты с малым числом получателей могут позволить себе отправлять уведомления по событию, в течении того-же HTTP запроса;
        • Так у нас появляется система очередей на RabbitMQ, Redis и т.д.;
        • Появляются демоны/воркеры которые разбирают очередь и другие инструменты поддержки очередей;
        • Для увеличения скорости отправки можно распараллелить процесс и разнести его на несколько нод.
        0
        А как узнать дошло push-уведомление до пользователя?
          0
          Никак. Нет никаких гарантий, что ваше уведомление дойдет.
          0
          Спасибо за статью, но почему бы в 2к19 вместо геттеров и сеттеров в классе PushNotifyConf не воспользоваться аннотациями Getter и Setter от Lombok (а также @AllArgsConstructor и @NoArgsConstructor)? Тем более при написании статьи, когда так важно не захламлять код
            0
            Спасибо за совет. Не подумал, когда писал. Сам просто не пользуюсь Lombok

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое