Представим, что нужно написать функцию, которая должна срабатывать каждый час, день, месяц и выполнять бизнесовую задачу: housekeeping базы данных, рассылку почты, расчёт метрик, составление отчетов по накопившемся данным.

При этом известно, что разрабатываемое приложение должно работать корректно при работе нескольких копий (инстансов) одновременно. Другими словами, должна сохраняться возможность горизонтального масштабирования приложения.

На мой взгляд, любой грамотный разработчик перед тем, как приступить к созданию собственного велосипеда, должен провести некоторый анализ, изучив уже существующие решения. Этим мы и займёмся.

Меня зовут Денис Шевцов, я бэкенд-разработчик в Surf. В статье расскажу, с решением каких проблем при разработке горизонтально-масштабируемых Spring Boot-приложений поможет планировщик задач Quartz, приведу примеры использования библиотеки.

Общая концепция работы Quartz

Quartz — это библиотека с открытым исходным кодом на языке Java. Помогает реализовать выполнение функций с определенным интервалом или по расписанию.

Не считая довольно низкоуровневых механизмов, таких как классы java.util.Timer и java.util.TimerTask, ближайший аналог для Quartz — аннотация @Scheduled, доступная в Spring. Однако, несмотря на удобство и простоту использования, при запуске более одного инстанса приложения, содержащего эту аннотацию, могут начаться проблемы: каждая копия приложения будет вызывать функцию независимо от другой копии.

Quartz предоставляет готовое решение для этой проблемы: он использует общую базу данных. В ней сохраняется информация о джобах (JobDetail) и триггерах (Trigger), а также синхронизируется запуск задачи между планировщиками.

Чтобы понять устройство библиотеки, рассмотрим основные понятия:

  • SchedulerFactory — интерфейс фабрики для создания Scheduler.

  • Scheduler — основной класс библиотеки, через который происходит управление планировщиком задач.

  • Job — интерфейс для создания задач с запланированным выполнением.

  • JobDetail — интерфейс для создание инстансов Job.

  • Trigger — интерфейс для определения расписания выполнения задач

  • JobBuilder и TriggerBuilder — вспомогательные классы для создания инстансов JobDetail и Trigger.

В общем случае связь между частями библиотеки можно представить в виде схемы: 

Чем Spring Boot помогает в связке с Quartz

При работе с Quartz, Spring Boot даёт разработчику преимущества для более простого и эффективного использования библиотеки:

  • Значительно упрощает настройку Scheduler — благодаря возможности указать требуемые параметры в конфигурационном файле приложения.

  • Даёт возможность внедрять зависимости прямо в джобы, предоставляя для этого абстрактный класс QuartzJobBean, который реализует интерфейс Job.

Подготовка к работе

Важные аспекты работы с библиотекой разберём на примере без лишних усложнений. Представим, что раз в час нужно выводить в консоль текущее время, а также время предыдущего срабатывания функции. Код приложения на Github

Не буду углубляться в настройку и запуск типичного Spring Boot приложения, подробнее об этом можно узнать в гайде Building an Application with Spring Boot. Только отмечу, что все примеры кода буду приводить на языке Kotlin, а в качестве системы сборки воспользуюсь Gradle.

Для работы создаваемого приложения понадобятся зависимости:

implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("com.h2database:h2")
implementation("org.flywaydb:flyway-core") //опционально

implementation("org.springframework.boot:spring-boot-starter-quartz")

Перед тем, как перейдём к написанию кода, нам понадобится схема БД со всеми таблицами, которые нужны Quartz для работы.

Конечно, у Quartz есть возможность работы в in-memory режиме. Но чтобы можно было синхронизировать работы планировщиков, запущенных в разных инстансах приложения, необходимо использовать внешнюю по отношению к приложению сущность. В Quartz для этого используется база данных.

Чтобы создать нужные таблицы, я предлагаю воспользоваться официальными миграциями БД, которые можно применять с любым удобным вам инструментом. Я буду использовать библиотеку Flyway. Для этого мне необходимо создать директорию resources/bd/migration и поместить туда подходящий для выбранной БД скрипт.

Настраиваем выполнение регулярных задач при запуске приложения

Первый шаг в создании джобы — создание классов с функциями, которые должны выполняться в соответствии с расписанием. Для этого создадим сервисный класс ShowTimeService, содержащий единственный метод showTime.

import org.springframework.stereotype.Service
import java.time.LocalDateTime

@Service
class ShowTimeService {
   fun showTime(lastExecutionDateTime: LocalDateTime?): LocalDateTime {
       println()
       lastExecutionDateTime?.let {
           println("Previous execution time: $it")
       } ?: println("No previous executions found")
       val now = LocalDateTime.now()
       println("Current execution time: $now")
       return now;
   }
}

В методе выводится информация о предыдущем времени запуска, если она есть, а также выводится и возвращается текущее время.

Далее потребуется класс, наследующий от QuartzJobBean: в нём будем получать из специальной сущности JobDataMap информацию о предыдущем запуске джобы и вызывать метод из сервисного слоя. Класс в последующем будет использован в JobDetail, чтобы Scheduler мог работать с инстансами этой джобы.

import com.example.demo.service.ShowTimeService
import org.quartz.DisallowConcurrentExecution
import org.quartz.JobExecutionContext
import org.quartz.PersistJobDataAfterExecution
import org.springframework.scheduling.quartz.QuartzJobBean
import java.time.LocalDateTime

@DisallowConcurrentExecution
@PersistJobDataAfterExecution
class ShowTimeJob(
   private val showTimeService: ShowTimeService
) : QuartzJobBean() {
   override fun executeInternal(context: JobExecutionContext) {
       val dataMap = context.jobDetail.jobDataMap
       val lastExecutionDateTime = dataMap["lastExecutionDateTime"] as LocalDateTime?
       dataMap["lastExecutionDateTime"] = showTimeService.showTime(lastExecutionDateTime)
   }
}

Стоит обратить внимание на несколько важных моментов:

  1. В метод, который необходимо реализовать при наследовании от QuartzJobBean, передается параметр JobExecutionContext. Через него можем получить доступ к DataMap конкретного экземпляра джобы. В данном случае воспользуемся jobDetail.jobDataMap, чтобы получать и сохранять время запуска джобы в базе.

  2. Аннотация @PersistJobDataAfterExecution нужна, чтобы все изменения в dataMap после выполнения джобы сохранялись в БД.

  3. Аннотация @DisallowConcurrentExecution позволяет гарантировать, что в любой момент времени будет запущено не более одного экземпляра джобы.
    Важно понимать, что аннотация относится только к джобам, запускающимся на одном инстансе приложения. При работе в кластерном режиме Quartz сам контролирует срабатывание триггеров и, с помощью блокировок в БД, осуществляет запуск джобы только на одном из инстансов приложения.

Теперь, когда у нас есть джоба, необходимо настроить JobDetail и Trigger: чтобы Scheduler мог запускать выполнение джобы в соответствии с расписанием.

Чтобы иметь возможность настраивать приложение с помощью конфигурационного файла, воспользуемся возможностям Spring Boot и создадим класс SchedulerProperties, который будет хранить в себе некоторые параметры, необходимые для создания джобы.

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding

@ConfigurationProperties(prefix = "scheduler")
@ConstructorBinding
data class SchedulerProperties(
       val permanentJobsGroupName: String = "PERMANENT",
       val showTimeJobCron: String = "0 0 * * * ?"
)

При этом нужно добавить аннотацию @ConfigurationPropertiesScan (basePackages = ["<Пакет с конфигурационными классами>"])на главный класс, иначе Spring не создаст экземпляр SchedulerProperties.

Теперь можем добавить в нашу конфигурацию (application.yml) следующие строки:

scheduler:
 permanent-jobs-group-name: PERMANENT
 show-time-job-cron: ${SHOW_TIME_JOB_CRON:0 0 * * * ?}
  • Параметр permanent-jobs-group-name понадобится, чтобы иметь возможность выделить среди всех созданных джоб только те, которые относятся к создаваемым при запуске приложения.

  • Параметр show-time-job-cron позволит задавать расписание запуска джобы в формате крона. Подробнее об этом формате можно прочитать в Cron Trigger Tutorial. В данном случае значением по умолчанию стоит запуск джобы в начале каждого часа. Чтобы изменить расписание, достаточно указать новое значение в переменной окружения SHOW_TIME_JOB_CRON.

Конфигурационный класс для настройки джобы есть. Создадим бины для JobDetail и Trigger, который в последующем используем для того, чтобы запланировать выполнение джобы с помощью Scheduler.

import org.quartz.*
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration


@Configuration
class ShowTimeJobConfig(
   val schedulerProperties: SchedulerProperties
) {

   @Bean
   fun showTimeJobDetail(): JobDetail = JobBuilder
       .newJob(ShowTimeJob::class.java)
       .withIdentity("showTimeJob", schedulerProperties.permanentJobsGroupName)
       .storeDurably()
       .requestRecovery(true)
       .build()

   @Bean
   fun showTimeTrigger(): Trigger = TriggerBuilder.newTrigger()
       .forJob(showTimeJobDetail())
       .withIdentity("showTimeJobTrigger", schedulerProperties.permanentJobsGroupName)
       .withSchedule(CronScheduleBuilder.cronSchedule(schedulerProperties.showTimeJobCron))
       .build()
}

В двух методах, представленных выше, происходит настройка и создание JobDetail и Trigger. Для этого используются специальные классы-билдеры: JobDetailBuilder и TriggerBuilder.

Подробнее обо всех возможностях —  в документации: 

Последним шагом в настройке приложение будет конфигурация класса Scheduler, а точнее, SchedulerFactory, отвечающего за создание Scheduler.

Чтобы настроить SchedulerFactory, используя конфигурационный файл, необходимо в application.yml (application.properties) добавить следующую конфигурацию:

spring:
 dataSource: #Настраиваем подключение к БД
   url: jdbc:h2:file:./quartz
   username: sa
   password: password
   driver-class-name: org.h2.Driver

 quartz:
   job-store-type: jdbc #Указываем, что будем хранить информацию о джобах в БД, а не в памяти
   jdbc:
     initialize-schema: never #Мы будем инициализировать схему БД вручную, поэтому ставим never
   properties:
     org:
       quartz:
         scheduler:
           instanceId: AUTO #Используев AUTO, для того, чтобы каждый новый инстанс Scheduler`a имел уникальное название.
         jobStore:
           driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate #Указываем диалект для запросов к БД
           useProperties: false #Указываем, что все данные в БД будут храниться в качестве строк, а не в двоичном формате
           tablePrefix: QRTZ_  #Префикс таблиц в БД
           clusterCheckinInterval: 5000 #Указываем частоту сверки инстанса Scheduler с остальными инстансами в кластере
           isClustered: true #Включаем режим работы в кластере
         threadPool: #Указываем настройки для создания пула поток, на котором будут выполняться джобы
           class: org.quartz.simpl.SimpleThreadPool
           threadCount: 10
           threadsInheritContextClassLoaderOfInitializingThread: true
   auto-startup: false #Выключаем автоматический старт для scheduler, т.к. запуск будет выполнен вручную

Со всеми возможностями, по настройке можно ознакомиться в Quartz Configuration Reference.

Наконец, реализуем класс, отвечающий за создание и настройку бина Scheduler.

import org.quartz.*
import org.quartz.impl.matchers.GroupMatcher
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.quartz.SchedulerFactoryBean

@Configuration
class SchedulerConfig(
   val schedulerProperties: SchedulerProperties
) {

   @Bean
   fun scheduler(triggers: List<Trigger>, jobDetails: List<JobDetail>, factory: SchedulerFactoryBean): Scheduler {
       factory.setWaitForJobsToCompleteOnShutdown(true)
       val scheduler = factory.scheduler
       revalidateJobs(jobDetails, scheduler)
       rescheduleTriggers(triggers, scheduler)
       scheduler.start()
       return scheduler
   }

   fun rescheduleTriggers(triggers: List<Trigger>, scheduler: Scheduler) {
       triggers.forEach {
           if (!scheduler.checkExists(it.key)) {
               scheduler.scheduleJob(it)
           } else {
               scheduler.rescheduleJob(it.key, it)
           }
       }
   }

   fun revalidateJobs(jobDetails: List<JobDetail>, scheduler: Scheduler) {
       val jobKeys = jobDetails.map { it.key }
       scheduler.getJobKeys(GroupMatcher.jobGroupEquals(schedulerProperties.permanentJobsGroupName)).forEach {
           if (it !in jobKeys) {
               scheduler.deleteJob(it)
           }
       }
   }
}

Разберём подробнее, что происходит при создании бина:

  1. Перед созданием scheduler устанавливаем в true флаг, отвечающий за ожидание завершения всех джоб при выключении самого scheduler.

  2. Все джобы и триггеры хранятся в базе, поэтому необходимо проверить, что в БД нет джоб, которые были удалены из кода.

  3. Похожую процедуру нужно выполнить и для триггеров: триггер мог измениться с последнего запуска приложения.

  4. После этого выполняем запуск scheduler и возвращаем его в качестве бина, чтобы его можно было проинжектить в любое нужное место.

Теперь можем полноценно проверить работу приложения. Для этого советую изменить частоту вызова джобы, задав новый крон для переменной окружения SHOW_TIME_JOB_CRON = */3 * * * * ?

Запускаем приложение и видим, что каждые 3 секунды в консоль выводится текущее и предыдущее время запуска джобы.

Преимущества и недостатки Quartz

Из основных преимуществ:

  1. Встроенная поддержка многоинстансного режима значительно облегчает горизонтальное масштабирование приложений.

  2. Есть возможность сохранять нужные для работы данные между запусками джобы.

  3. Возможность динамического создания джоб.

  4. Quartz позволяет привязывать одну и ту же джобу к разным триггерам: можно создавать комплексное расписание работы джобы. Но этого момента в статье мы не коснулись.

Из недостатков:

  1. Синхронизация между инстансами происходит только при помощи реляционной БД. Другие виды внешних систем не поддерживаются.

  2. По сравнению с аналогами Quartz требует больше усилий и строк кода для начальной настройки приложения.

  3. Если требуется запускать какую-либо джобу на каждом из инстансов одновременно, лучше использовать один из аналогов. В Quartz нет встроенной возможности для выполнения одной и той же джобы на разных инстансах одновременно.

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


  1. yerbabuena
    02.09.2022 12:32

    Хотелось бы увидеть еще подходы к тестированию таких джобов, особенно когда расписание их запуска редкое.


  1. Serzh90
    02.09.2022 14:31

    Спасибо, пригодится. Ещё из полезного: Spring Boot Admin и соответственно actuator суппортят Quartz из коробки.