Добавление Quartz в Spring Boot

Автор оригинала: John Vester
  • Перевод
И снова здравствуйте. Специально для студентов курса «Разработчик на Spring Framework» подготовили перевод интересной статьи.




В моей статье «Specifications to the Rescue» я показал как можно использовать JPA Specification в Spring Boot для реализации фильтрации в RESTful API. Затем в статье «Testing those Specifications» было показано как протестировать эти самые спецификации.

Следующим шагом я решил продемонстрировать, как добавить планировщик заданий в это же приложение Spring Boot.

Планировщик заданий Quartz


Команда Spring продолжает облегчать разработку на Java, предоставляя различные Spring Boot Starter, подключаемые через простую maven-зависимость.

В этой статье я сконцентрируюсь на стартере Quartz Scheduler, который можно добавить в проект Spring Boot с помощью следующей зависимости:

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>

Реализация довольно проста и описана здесь. Полный список текущих Spring Boot Starter вы можете посмотреть здесь.

Настройка


Используя работу, опубликованную Дэвидом Киссом, первым этапом будет добавление автосвязывания для заданий Quartz:

public final class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware {
   private transient AutowireCapableBeanFactory beanFactory;
 
   @Override
   public void setApplicationContext(final ApplicationContext context) {
       beanFactory = context.getAutowireCapableBeanFactory();
   }
 
   @Override
   protected Object createJobInstance(final TriggerFiredBundle bundle) throws Exception {
       final Object job = super.createJobInstance(bundle);
       beanFactory.autowireBean(job);
       return job;
   }
}

Далее добавляем базовую конфигурацию Quartz:

@Configuration
public class QuartzConfig {
   private ApplicationContext applicationContext;
   private DataSource dataSource;
   public QuartzConfig(ApplicationContext applicationContext, DataSource dataSource) {
       this.applicationContext = applicationContext;
       this.dataSource = dataSource;
   }
 
   @Bean
   public SpringBeanJobFactory springBeanJobFactory() {
       AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory();
       jobFactory.setApplicationContext(applicationContext);
       return jobFactory;
   }
 
   @Bean
   public SchedulerFactoryBean scheduler(Trigger... triggers) {
       SchedulerFactoryBean schedulerFactory = new SchedulerFactoryBean();
       Properties properties = new Properties();
       properties.setProperty("org.quartz.scheduler.instanceName", "MyInstanceName");
       properties.setProperty("org.quartz.scheduler.instanceId", "Instance1");
       schedulerFactory.setOverwriteExistingJobs(true);
       schedulerFactory.setAutoStartup(true);
       schedulerFactory.setQuartzProperties(properties);
       schedulerFactory.setDataSource(dataSource);
       schedulerFactory.setJobFactory(springBeanJobFactory());
       schedulerFactory.setWaitForJobsToCompleteOnShutdown(true);
       if (ArrayUtils.isNotEmpty(triggers)) {
           schedulerFactory.setTriggers(triggers);
       }
       return schedulerFactory;
   }
}

Можно вынести свойства, используемые в методе scheduler(), наружу, но я специально решил упростить этот пример.

Затем добавляются статические методы, обеспечивающие программный способ создания заданий и триггеров:

@Slf4j
@Configuration
public class QuartzConfig {
 ...
static SimpleTriggerFactoryBean createTrigger(JobDetail jobDetail, long pollFrequencyMs, String triggerName) {
       log.debug("createTrigger(jobDetail={}, pollFrequencyMs={}, triggerName={})", jobDetail.toString(), pollFrequencyMs, triggerName);
       SimpleTriggerFactoryBean factoryBean = new SimpleTriggerFactoryBean();
       factoryBean.setJobDetail(jobDetail);
       factoryBean.setStartDelay(0L);
       factoryBean.setRepeatInterval(pollFrequencyMs);
       factoryBean.setName(triggerName);
       factoryBean.setRepeatCount(SimpleTrigger.REPEAT_INDEFINITELY);
       factoryBean.setMisfireInstruction(SimpleTrigger.MISFIRE_INSTRUCTION_RESCHEDULE_NEXT_WITH_REMAINING_COUNT);
       return factoryBean;
   }
 
   static CronTriggerFactoryBean createCronTrigger(JobDetail jobDetail, String cronExpression, String triggerName) {
       log.debug("createCronTrigger(jobDetail={}, cronExpression={}, triggerName={})", jobDetail.toString(), cronExpression, triggerName);
       // To fix an issue with time-based cron jobs
       Calendar calendar = Calendar.getInstance();
       calendar.set(Calendar.SECOND, 0);
       calendar.set(Calendar.MILLISECOND, 0);
       CronTriggerFactoryBean factoryBean = new CronTriggerFactoryBean();
       factoryBean.setJobDetail(jobDetail);
       factoryBean.setCronExpression(cronExpression);
       factoryBean.setStartTime(calendar.getTime());
       factoryBean.setStartDelay(0L);
       factoryBean.setName(triggerName);
       factoryBean.setMisfireInstruction(CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING);
       return factoryBean;
   }
 
   static JobDetailFactoryBean createJobDetail(Class jobClass, String jobName) {
       log.debug("createJobDetail(jobClass={}, jobName={})", jobClass.getName(), jobName);
       JobDetailFactoryBean factoryBean = new JobDetailFactoryBean();
       factoryBean.setName(jobName);
       factoryBean.setJobClass(jobClass);
       factoryBean.setDurability(true);
       return factoryBean;
   }
}

Метод createJobDetail() — это простой и полезный метод для создания заданий.
Для триггеров существуют два варианта: на основе CRON и простые триггеры.

Сервисы


Теперь базовый планировщик Quartz готов к запуску заданий в нашем Spring Boot — приложении. Далее создадим несколько примеров сервисов, которые будут запускаться планировщиком.

Первый сервис отображает простую статистику членства. Если вы помните, пример в первоначальном проекте был связан с фитнес-клубом. В классе MemberService создаем метод memberStats():

public void memberStats() {
 List<Member> members = memberRepository.findAll();
 int activeCount = 0;
 int inactiveCount = 0;
 int registeredForClassesCount = 0;
 int notRegisteredForClassesCount = 0;
 for (Member member : members) {
   if (member.isActive()) {
     activeCount++;
     if (CollectionUtils.isNotEmpty(member.getMemberClasses())) {
       registeredForClassesCount++;
     } else {
       notRegisteredForClassesCount++;
     }
   } else {
     inactiveCount++;
   }
 }
 log.info("Member Statics:");
 log.info("==============");
 log.info("Active member count: {}", activeCount);
 log.info(" - Registered for Classes count: {}", registeredForClassesCount);
 log.info(" - Not registered for Classes count: {}", notRegisteredForClassesCount);
 log.info("Inactive member count: {}", inactiveCount);
 log.info("==========================");
}

Для отслеживания интересов в классах фитнес-клуба создаем в MemberClassService метод classStats():

public void classStats() {
 List<MemberClass> memberClasses = classRepository.findAll();
 Map<String, Integer> memberClassesMap = memberClasses
   .stream()
   .collect(Collectors.toMap(MemberClass::getName, c -> 0));
 List<Member> members = memberRepository.findAll();
 for (Member member : members) {
   if (CollectionUtils.isNotEmpty(member.getMemberClasses())) {
     for (MemberClass memberClass : member.getMemberClasses()) {
       memberClassesMap.merge(memberClass.getName(), 1, Integer::sum);
     }
   }
 }
 log.info("Class Statics:");
 log.info("=============");
 memberClassesMap.forEach((k,v) -> log.info("{}: {}", k, v));
 log.info("==========================");
}

Задания


Для запуска кода сервисов необходимо создать соответствующие задания (Job). Для MemberService я создал класс задания MemberStatsJob:

@Slf4j
@Component
@DisallowConcurrentExecution
public class MemberStatsJob implements Job {
   @Autowired
   private MemberService memberService;
 
   @Override
   public void execute(JobExecutionContext context) {
       log.info("Job ** {} ** starting @ {}", context.getJobDetail().getKey().getName(), context.getFireTime());
       memberService.memberStats();
       log.info("Job ** {} ** completed.  Next job scheduled @ {}", context.getJobDetail().getKey().getName(), context.getNextFireTime());
   }
}

Для сервиса MemberClassService был создан класс MemberClassStatsJob:

@Slf4j
@Component
@DisallowConcurrentExecution
public class MemberClassStatsJob implements Job {
   @Autowired
   MemberClassService memberClassService;
 
   @Override
   public void execute(JobExecutionContext context) {
       log.info("Job ** {} ** starting @ {}", context.getJobDetail().getKey().getName(), context.getFireTime());
       memberClassService.classStats();
       log.info("Job ** {} ** completed.  Next job scheduled @ {}", context.getJobDetail().getKey().getName(), context.getNextFireTime());
   }
}

Расписание заданий


В этом проекте мы хотим, чтобы все задания были запланированы при запуске Spring Boot сервера. Для этого я создал класс QuartzSubmitJobs, который включает в себя четыре простых метода. Два метода создают новые задания, а два метода — соответствующие триггеры.

@Configuration
public class QuartzSubmitJobs {
   private static final String CRON_EVERY_FIVE_MINUTES = "0 0/5 * ? * * *";
 
   @Bean(name = "memberStats")
   public JobDetailFactoryBean jobMemberStats() {
       return QuartzConfig.createJobDetail(MemberStatsJob.class, "Member Statistics Job");
   }
 
   @Bean(name = "memberStatsTrigger")
   public SimpleTriggerFactoryBean triggerMemberStats(@Qualifier("memberStats") JobDetail jobDetail) {
       return QuartzConfig.createTrigger(jobDetail, 60000, "Member Statistics Trigger");
   }
 
   @Bean(name = "memberClassStats")
   public JobDetailFactoryBean jobMemberClassStats() {
       return QuartzConfig.createJobDetail(MemberClassStatsJob.class, "Class Statistics Job");
   }
 
   @Bean(name = "memberClassStatsTrigger")
   public CronTriggerFactoryBean triggerMemberClassStats(@Qualifier("memberClassStats") JobDetail jobDetail) {
       return QuartzConfig.createCronTrigger(jobDetail, CRON_EVERY_FIVE_MINUTES, "Class Statistics Trigger");
   }
}

Запуск Spring Boot


Когда все готово, можно запустить Spring Boot сервер и увидеть инициализацию Quartz:

2019-07-14 14:36:51.651  org.quartz.impl.StdSchedulerFactory      : Quartz scheduler 'MyInstanceName' initialized from an externally provided properties instance.
2019-07-14 14:36:51.651  org.quartz.impl.StdSchedulerFactory      : Quartz scheduler version: 2.3.0
2019-07-14 14:36:51.651  org.quartz.core.QuartzScheduler          : JobFactory set to: com.gitlab.johnjvester.jpaspec.config.AutowiringSpringBeanJobFactory@79ecc507
2019-07-14 14:36:51.851  o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-07-14 14:36:51.901  aWebConfiguration$JpaWebMvcConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2019-07-14 14:36:52.051  o.s.s.quartz.SchedulerFactoryBean        : Starting Quartz Scheduler now
2019-07-14 14:36:52.054  o.s.s.quartz.LocalDataSourceJobStore     : Freed 0 triggers from 'acquired' / 'blocked' state.
2019-07-14 14:36:52.056  o.s.s.quartz.LocalDataSourceJobStore     : Recovering 0 jobs that were in-progress at the time of the last shut-down.
2019-07-14 14:36:52.056  o.s.s.quartz.LocalDataSourceJobStore     : Recovery complete.
2019-07-14 14:36:52.056  o.s.s.quartz.LocalDataSourceJobStore     : Removed 0 'complete' triggers.
2019-07-14 14:36:52.058  o.s.s.quartz.LocalDataSourceJobStore     : Removed 0 stale fired job entries.
2019-07-14 14:36:52.058  org.quartz.core.QuartzScheduler          : Scheduler MyInstanceName_$_Instance1 started.


И запуск задания memberStats():

2019-07-14 14:36:52.096  c.g.j.jpaspec.jobs.MemberStatsJob        : Job ** Member Statistics Job ** starting @ Sun Jul 14 14:36:52 EDT 2019
2019-07-14 14:36:52.217  c.g.j.jpaspec.service.MemberService      : Member Statics:
2019-07-14 14:36:52.217  c.g.j.jpaspec.service.MemberService      : ==============
2019-07-14 14:36:52.217  c.g.j.jpaspec.service.MemberService      : Active member count: 7
2019-07-14 14:36:52.217  c.g.j.jpaspec.service.MemberService      :  - Registered for Classes count: 6
2019-07-14 14:36:52.217  c.g.j.jpaspec.service.MemberService      :  - Not registered for Classes count: 1
2019-07-14 14:36:52.217  c.g.j.jpaspec.service.MemberService      : Inactive member count: 3
2019-07-14 14:36:52.217  c.g.j.jpaspec.service.MemberService      : ==========================
2019-07-14 14:36:52.219  c.g.j.jpaspec.jobs.MemberStatsJob        : Job ** Member Statistics Job ** completed.  Next job scheduled @ Sun Jul 14 14:37:51 EDT 2019

А затем выполнение задания classStats():

2019-07-14 14:40:00.006  c.g.j.jpaspec.jobs.MemberClassStatsJob   : Job ** Class Statistics Job ** starting @ Sun Jul 14 14:40:00 EDT 2019
2019-07-14 14:40:00.021  c.g.j.j.service.MemberClassService       : Class Statics:
2019-07-14 14:40:00.022  c.g.j.j.service.MemberClassService       : =============
2019-07-14 14:40:00.022  c.g.j.j.service.MemberClassService       : Tennis: 4
2019-07-14 14:40:00.022  c.g.j.j.service.MemberClassService       : FitCore 2000: 3
2019-07-14 14:40:00.022  c.g.j.j.service.MemberClassService       : Spin: 2
2019-07-14 14:40:00.022  c.g.j.j.service.MemberClassService       : Swimming: 4
2019-07-14 14:40:00.022  c.g.j.j.service.MemberClassService       : New Class: 0
2019-07-14 14:40:00.022  c.g.j.j.service.MemberClassService       : Basketball: 2
2019-07-14 14:40:00.022  c.g.j.j.service.MemberClassService       : ==========================
2019-07-14 14:40:00.022  c.g.j.jpaspec.jobs.MemberClassStatsJob   : Job ** Class Statistics Job ** completed.  Next job scheduled @ Sun Jul 14 14:45:00 EDT 2019

Заключение


В приведенном выше примере я использовал существующий проект на Spring Boot и без особых усилий добавил в него планировщик Quartz. Я создал сервисные методы, которые выполняли простой анализ данных. Эти сервисные методы были запущены классами заданий. Наконец, задания и триггеры были запланированы для запуска.

Полный исходный код можно найти здесь.

В следующей статье я покажу как добавить RESTful API для просмотра информации о настройках Quartz.
OTUS. Онлайн-образование
684,34
Цифровые навыки от ведущих экспертов
Поделиться публикацией

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

    +1
    Добрый вечер. А почему нельзя обойтись @EnableScheduling вместе с @Scheduled(cron = "* * * * * *")?
      0

      Есть предположение, что для удобства масштабирования сервиса в кластере: Quartz вполре себе умеет держать информацию о задачах и их состоянии в БД. Если же баловаться с аннотациями, то придеться самому думать над тем, как, например, ограничить одновременный запуск задачи на разных нодах. Если где не прав — критику и пояснения приветствую.

      0
      По своему опыту могу сказать что кварц — штука крайне неудобная, с запутанным API и создаёт кучу непонятных таблиц, которых сложно контролировать.
      Как уже выше писали @EnableScheduling работает превосходно. Лок на уровне базы, если надо, делается элементарно. Если требуется динамическое обновление, то это делается в один класс.
      В конце концов, можно периодически запускать проверку и через CronSequenceGenerator узнавать нужно ли что либо выполнять в данный момент.
        0

        А если без базы, то прекрасно локи ставятся через zookeeper
        В 2013 писали на кварце приложение большое, что ни месяц — бага на проде. Зависла Джоба.

          0
          @EnableScheduling работает превосходно. Лок на уровне базы, если надо, делается элементарно.

          sved подскажите бест практис, плиз. Заранее благодарю.

            0
            cepro про лок или scheduling?

            В спринге есть TaskExecutor и TaskScheduler, которые позволяют programaticaly запускать асинхронные задачи или shсhedul-ить (в том числе и по cron-у)

            Им соответствуют аннотации на методы Async и @Scheduled, которые упрощают эту задач, до безобразно простого уровня
              0

              sved это я знаю )
              Я "лок" имел в виду. Я, в принципе, представил реализацию, но есть вопросы. Поэтому интересно было узнать как это делают другие..

                0
                1. можно использовать ShedLock, настраивается несложно, работает на аннотациях. Мне не нравится, в т.ч. из-за кривого названия:)
                2. запрос в БД вида
                INSERT ... ON CONFLICT UPDATE ... RETURN ...
                . вызывать можно внутри метода, помеченного, как @Scheduled, установив его в самом начале. если insert/update проходит и что-то вернулось, выполняем дальнейшие действия
                  0

                  Огромное спасибо за наводку на ShedLock! На первый взгляд — замечательное решение, не смотря на название )
                  Но, кроме названия, как я понял есть какие-то др. недостатки?

                    0
                    показалось слишком громоздко ради небольшого функционала тянуть в проект либу с аспектами
                      0

                      А, понятно.
                      Благодарю! )

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

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