JavaRush /Java блог /Java-проекты /Добавляем Spring Scheduler - "Java-проект от А до Я"
Roman Beekeeper
35 уровень

Добавляем Spring Scheduler - "Java-проект от А до Я"

Статья из группы Java-проекты
Привет всем, мои дорогие друзья. В предыдущей статье мы подготовили клиент для работы с JavaRush API для статей. Теперь можно писать логику для работы нашей джобы, которая будет выполняться каждые 15 минут. Вот ровно так, как показано на этой схеме: “Java-проект от А до Я”: Добавляем Spring Scheduler - 1Каждые 15 минут будет запускаться джоба (по нашему — просто метод в определенном классе), который выполняется на фоне работы основного приложения и делает следующее:
  1. Находит во всех группах, которые есть в нашей БД, новые статьи, вышедшие после предыдущего выполнения.

    В этой схеме указано меньшее количество групп — только с активными пользователями. На тот момент мне это показалось логичным, но сейчас я понимаю, что независимо от того, есть активные пользователи, подписанные на конкретную группу или нет, все равно нужно держать актуальным последнюю статью, что бот обработал. Может возникнуть ситуация, когда новому пользователю придет сразу все количество статей, вышедшее с момента деактивации этой группы. А это не ожидаемое поведение, и чтобы его избежать, нужно держать актуальным и те группы из нашей БД, что на данный момент не имеют активных пользователей.
  2. Если новые статьи есть, сформировать сообщения для всех пользователей, которые активно подписаны на эту группу. Если новых статей нет, просто завершаем работу.

К слову, я уже упоминал в своем ТГ-канале, бот уже работает и отправляет новые статьи по подпискам. Начнем писать FindNewArtcileService. В нем будет происходить вся работа по поиску и отправке сообщений, а джоба будет только запускать метод этого сервиса:

FindNewArticleService:


package com.github.javarushcommunity.jrtb.service;

/**
* Service for finding new articles.
*/
public interface FindNewArticleService {

   /**
    * Find new articles and notify subscribers about it.
    */
   void findNewArticles();
}
Очень простой, правда? В этом его и суть, а вся сложность будет в реализации:

package com.github.javarushcommunity.jrtb.service;

import com.github.javarushcommunity.jrtb.javarushclient.JavaRushPostClient;
import com.github.javarushcommunity.jrtb.javarushclient.dto.PostInfo;
import com.github.javarushcommunity.jrtb.repository.entity.GroupSub;
import com.github.javarushcommunity.jrtb.repository.entity.TelegramUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class FindNewArticleServiceImpl implements FindNewArticleService {

   public static final String JAVARUSH_WEB_POST_FORMAT = "https://javarush.com/groups/posts/%s";

   private final GroupSubService groupSubService;
   private final JavaRushPostClient javaRushPostClient;
   private final SendBotMessageService sendMessageService;

   @Autowired
   public FindNewArticleServiceImpl(GroupSubService groupSubService,
                                    JavaRushPostClient javaRushPostClient,
                                    SendBotMessageService sendMessageService) {
       this.groupSubService = groupSubService;
       this.javaRushPostClient = javaRushPostClient;
       this.sendMessageService = sendMessageService;
   }


   @Override
   public void findNewArticles() {
       groupSubService.findAll().forEach(gSub -> {
           List<PostInfo> newPosts = javaRushPostClient.findNewPosts(gSub.getId(), gSub.getLastArticleId());

           setNewLastArticleId(gSub, newPosts);

           notifySubscribersAboutNewArticles(gSub, newPosts);
       });
   }

   private void notifySubscribersAboutNewArticles(GroupSub gSub, List<PostInfo> newPosts) {
       Collections.reverse(newPosts);
       List<String> messagesWithNewArticles = newPosts.stream()
               .map(post -> String.format("✨Вышла новая статья <b>%s</b> в группе <b>%s</b>.✨\n\n" +
                               "<b>Описание:</b> %s\n\n" +
                               "<b>Ссылка:</b> %s\n",
                       post.getTitle(), gSub.getTitle(), post.getDescription(), getPostUrl(post.getKey())))
               .collect(Collectors.toList());

       gSub.getUsers().stream()
               .filter(TelegramUser::isActive)
               .forEach(it -> sendMessageService.sendMessage(it.getChatId(), messagesWithNewArticles));
   }

   private void setNewLastArticleId(GroupSub gSub, List<PostInfo> newPosts) {
       newPosts.stream().mapToInt(PostInfo::getId).max()
               .ifPresent(id -> {
                   gSub.setLastArticleId(id);
                   groupSubService.save(gSub);
               });
   }

   private String getPostUrl(String key) {
       return String.format(JAVARUSH_WEB_POST_FORMAT, key);
   }
}
Здесь разберемся со всем по порядку:
  1. При помощи groupService мы находим все группы, которые есть в БД.

  2. Потом разбегаемся по всем группам и для каждой вызываем созданный в прошлой статье клиент — javaRushPostClient.findNewPosts.

  3. Далее при помощи метода setNewArticleId мы обновляем ID шник нашей последней новой статьи, чтобы наша база данных знала, что мы уже обработали новые.

  4. И при помощи того, что у GroupSub есть коллекция пользователей, пробегаем по активным и отсылаем уведомления о новых статьях.

Какое там сообщение — сейчас обсуждать не будем, для нас это не сильно важно. Главное, что метод работает. Логика по поиску новых статей и отправке уведомлений готова, поэтому можно перейти к созданию джобы.

Создаем FindNewArticleJob

Мы уже говорили о том, что такое SpringScheduler, но повторим еще раз быстро: это механизм в Spring фреймворке для создания фонового процесса, который будет выполняться в определенное время, задаваемого нами. Что нужно для этого? Первый этап — добавить аннотацию @EnableScheduling к нашему входному классу для спринга:

package com.github.javarushcommunity.jrtb;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableScheduling
@SpringBootApplication
public class JavarushTelegramBotApplication {

   public static void main(String[] args) {
       SpringApplication.run(JavarushTelegramBotApplication.class, args);
   }

}
Второй этап — создать класс, добавить его в ApplicationContext и создать в нем метод, который будет запускаться периодически. Создаем пакет job на одном уровне с repository, service и так далее и там создаем класс FindNewArticleJob:

package com.github.javarushcommunity.jrtb.job;

import com.github.javarushcommunity.jrtb.service.FindNewArticleService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneOffset;

/**
* Job for finding new articles.
*/
@Slf4j
@Component
public class FindNewArticlesJob {

   private final FindNewArticleService findNewArticleService;

   @Autowired
   public FindNewArticlesJob(FindNewArticleService findNewArticleService) {
       this.findNewArticleService = findNewArticleService;
   }

   @Scheduled(fixedRateString = "${bot.recountNewArticleFixedRate}")
   public void findNewArticles() {
       LocalDateTime start = LocalDateTime.now();

       log.info("Find new article job started.");

       findNewArticleService.findNewArticles();

       LocalDateTime end = LocalDateTime.now();

       log.info("Find new articles job finished. Took seconds: {}",
               end.toEpochSecond(ZoneOffset.UTC) - start.toEpochSecond(ZoneOffset.UTC));
   }
}
Чтобы добавить этот класс в Application Context, я использовал аннотацию @Component. А чтобы метод внутри класса знал, что ему нужно запускаться периодически, я добавил к методу аннотацию: @Scheduled(fixedRateString = "${bot.recountNewArticleFixedRate}"). А вот какое значение будет — его мы задаем уже в application.properties файле:

bot.recountNewArticleFixedRate = 900000
Здесь значение указано в миллисекундах. Это будет 15 минут. В этом методе все просто: я для себя в логах добавил супер простую метрику для подсчета поиска новых статей, чтобы хоть примерно представлять насколько быстро работает.

Тестируем новый функционал

Теперь будем тестировать на нашем тестовом боте. Но как? Не буду же я удалять каждый раз статьи, чтобы показать, что уведомления пришли? Нет, конечно. Просто будем править данные в БД и запускать приложение. Тестировать я буду на своем тестовом енве. Для этого подпишемся на какую-то группу. Когда подписка будет оформлена, группе будет поставлен актуальный ID последней статьи. Пойдем в базу и поменяем значение на две статьи назад. В итоге ожидаем, что будет столько статей, на сколько раньше поставим lastArticleId."Java-проект от А до Я": Добавляем Spring Scheduler - 2Далее идем на сайт, сортируем статьи в группе Java-проекты — сначала новые — и заходим в третью статью из списка:"Java-проект от А до Я": Добавляем Spring Scheduler - 3Зайдем в нижнюю статью и из адресной строки получим article Id — 3313:"Java-проект от А до Я": Добавляем Spring Scheduler - 4Далее идем в MySQL Workbench и меняем значение lastArticleId на 3313. Посмотрим, что такая группа есть в базе:"Java-проект от А до Я": Добавляем Spring Scheduler - 5И для нее выполним команду:"Java-проект от А до Я": Добавляем Spring Scheduler - 6И все, теперь нужно подождать до следующего запуска джобы по поиску новых статей. Ожидаем, что придет два сообщения о новой статье из группы Java-проекты. Как говорится, результат не заставил себя ждать:"Java-проект от А до Я": Добавляем Spring Scheduler - 7Получается, что бот отработал так, как мы и ожидали.

Окончание

Как и всегда — обновляем версию в pom.xml и добавляем запись в RELEASE_NOTES, чтобы история работы сохранилась и всегда можно было вернуться и понять, что изменилось. Поэтому инкрементируем на одну единицу версию:

<version>0.7.0-SNAPSHOT</version>
И обновляем RELEASE_NOTES:
## 0.7.0-SNAPSHOT * JRTB-4: added ability to send notifications about new articles * JRTB-8: added ability to set inactive telegram user * JRTB-9: added ability to set active user and/or start using it.
Теперь уж можно создавать пулл-реквест и заливать новые изменения. Вот пулл-реквест со всеми изменениями за две части: STEP_8. Что дальше? Уже казалось бы все готово и, как говорят у нас, может выходить в продакшен, но есть еще некоторые вещи, которые хочется сделать. Например, настроить работу админов у бота, добавить их и добавить возможность задавать их. Также перед окончанием хорошо бы пройтись по коду и посмотреть, нет ли вещей, которые можно отрефакторить. Я вот уже вижу рассинхрон в именовании article/post. В самом конце сделаем ретроспективу того, что мы планировали и что получили. И что хочется сделать в будущем. Сейчас поделюсь с вами достаточно сырой идеей, которая может и увидит свет: сделать springboot starter, который имел бы всю функциональность по работе с телеграм-ботом и поиском статей. Это даст возможность унифицировать подход и использовать его для других телеграм-ботов. Таким образом этот проект станет более доступным для других и сможет принести пользу большему числу людей. Это одна из идей. Другая идея — идти в глубь разработки нотификаций. Но об этом мы поговорим несколько позже. Всем спасибо за внимание, с вас как обычно: лайк - подписка - колокольчик, звезду нашему проекту, комментарий и оценить статью! Всем спасибо за прочтение.

Список всех материалов серии в начале этой статьи.

Комментарии (10)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Алексей Уровень 22
18 июля 2021
Привет! В методе notifySubscribersAboutNewArticles() мы создаем List<String> messagesWithNewArticles, а затем передаем его в качестве аргумента в

sendMessageService.sendMessage(it.getChatId(), messagesWithNewArticles))
который ожидает String, а не List. Должны ли мы messagesWithNewArticles.toString или как-то иначе вывести этот лист в строку?
15 июня 2021
А почему мы прописываем

bot.recountNewArticleFixedRate = 900000
не в application-test.properties? это и имеется ввиду или уже можно переходить на основного бота?
Сергей Цыманов Уровень 35
7 июня 2021
Следую вашим статьям, но помойму last_article_id при первоначальной подписке на группу не проставляется, = NULL, кроме как через PostInfo последнюю статью в группе ну никак не узнать. Пересмотрел все ваши статьи еще раз не нашел, где-то я что-то пропустил?