Фреймворк Quartz: решаем вопрос запуска задач
Представим достаточно популярную задачу – утилизацию вычислительных ресурсов в дни с минимальной нагрузкой. Допустим, у нас есть какой-то сайт, с которым обычно работают по будням. И есть тяжёлые фоновые задачи, которые хотим запускать в выходные, чтобы не пересекаться с клиентами.
Но тут возникает небольшая проблема – выходные в РФ совсем не ограничиваются субботой и воскресеньем. Да и суббота и воскресенье не всегда могут быть выходными.
Попробуем решить эту задачу с помощью Quartz – одной из самых навороченных библиотек для запуска задач по расписанию и, конечно, Spring.
Естественно, мы напишем приложение на Spring Boot. Создадим его с помощью Spring Initializr.
pom.xml:
<?xml version="1.0" encoding="UTF-8"?> <project> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.2.1.RELEASE</version> <relativePath/> </parent> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> </dependencies> <!-- и ещё немного --> </project>
Надо найти одновременно простое, полное и поддерживаемое API для получения списка выходных дней. Воспользуемся не самым простым для интеграции (это XML) http://xmlcalendar.ru/, но и не самым сложным с точки зрения полноты данных.
Данное API возвращает XML следующего вида:
src/test/resources/calendar.xml:
<?xml version="1.0" encoding="UTF-8"?> <calendar year="2018" lang="ru" date="2017.10.22"> <holidays> <!-- ещё немножко XML --> <holiday id="6" title="День Победы" /> </holidays> <days> <!-- ещё немножко XML --> <day d="05.09" t="1" h="6" /> <!-- t="1" - выходной, t="2" - сокращённый, t="3" - рабочий, суббота и воскрсенье - выходные по умолчанию. да, формат даты ММ.ДД --> <day d="06.09" t="2" /> </days> </calendar>
Немножко разобравшись с форматом ответа, получается следующий алгоритм определения выходного дня:
День выходной == если это понедельник… пятница и он присутствует в праздничном календаре с типом t="1", а если это суббота или воскресенье, то день должен отсутствовать в праздничном календаре с типами t="2" или t="3".
Напишем с помощью Jackson маппер данного XML. Итак, для начала настроим Jackson:
pom.xml:
<dependency> <groupId>com.fasterxml.jackson.dataformat</groupId> <artifactId>jackson-dataformat-xml</artifactId> </dependency>
src/main/java/ru/otus/springquartzexample/config/ApplicationConfig.java:
@Configuration public class ApplicationConfig { @Bean XmlMapper xmlMapper() { return new XmlMapper(); } }
Напишем классы для элементов:
src/main/java/ru/otus/springquartzexample/xmlcalendar/CalendarElement.java:
@Data @JacksonXmlRootElement(localName = "calendar") @JsonIgnoreProperties(ignoreUnknown = true) public class CalendarElement { @JacksonXmlElementWrapper(localName = "days") private List<DayElement> days; }
src/main/ru/otus/springquartzexample/xmlcalendar/DayElement.java:
@Data @JacksonXmlRootElement(localName = "day") @JsonIgnoreProperties(ignoreUnknown = true) public class DayElement { @JacksonXmlProperty(isAttribute = true, localName = "d") private String date; @JacksonXmlProperty(isAttribute = true, localName = "t") private int type; }
Да, не удивляйтесь, что здесь присутствуют аннотации для Json. Всё-таки, это Jackson.
Чтобы убедиться, что мы всё сделали правильно, напишем небольшой тест:
src/main/java/ru/otus/springquartzexample/xmlcalendar/CalendarElementTest.java:
@DisplayName("Класс CalendarElement") @SpringBootTest class CalendarElementTest { @Autowired private XmlMapper xmlMapper; @DisplayName("должен десериализовываться из XML") @Test void shouldDeserializeFromXml() throws Exception { @Cleanup val resource = getClass().getResourceAsStream("/calendar.xml"); val calendar = xmlMapper.readValue(resource, CalendarElement.class); assertThat(calendar.getDays().get(0).getDate()).isEqualTo("01.01"); } }
Ну и напишем клиента, который получает соответствующий календарь:
@RequiredArgsConstructor @Service public class XmlCalendarClient { private final XmlMapper xmlMapper; public CalendarElement read(int year) throws Exception { val url = new URL("http://xmlcalendar.ru/data/ru/" + year + "/calendar.xml"); return xmlMapper.readValue(url, CalendarElement.class); } }
И куда без теста:
@DisplayName("Класс XmlCalendarClient") @SpringBootTest class XmlCalendarClientTest { @Autowired private XmlCalendarClient client; @DisplayName("должен возвращать данные за 2019 год") @Test void shouldReturn2019() throws Exception { val calendar = client.read(2019); assertThat(calendar).isNotNull(); } }
Теперь пришло время написать нашу бизнес-логику. Для приличия создадим интерфейс сервиса:
src/main/java/ru/otus/springquartzexample/service/RussianHolidaysService.java:
public interface RussianHolidaysService { boolean isHoliday(LocalDate date); }
Но неприлично реализуем его прямо в клиенте:
@Override public boolean isHoliday(LocalDate date) throws Exception { val year = date.getYear(); val calendar = read(year); val dayToFind = date.format(DateTimeFormatter.ofPattern("MM.dd")); val isSaturdayOrSunday = date.getDayOfWeek() == DayOfWeek.SATURDAY || date.getDayOfWeek() == DayOfWeek.SUNDAY; val fromCalendar = calendar.getDays().stream() .filter(day -> dayToFind.equals(day.getDate())) .findAny(); return isSaturdayOrSunday ? fromCalendar.isEmpty() : fromCalendar.isPresent() && fromCalendar.map(DayElement::isHoliday).get(); }
Ну и куда без тестов:
@DisplayName("должен сказать, что 8-ое марта 2019 - выходной") @Test void shouldSayThat20190308IsHoliday() throws Exception { assertTrue(client.isHoliday(LocalDate.of(2019, 3, 8))); } @DisplayName("должен сказать, что 9-ое июня 2018 был рабочий") @Test void shouldSayThat20180609IsHoliday() throws Exception { assertFalse(client.isHoliday(LocalDate.of(2018, 6, 9))); }
Здесь мы не будем рассматривать особенности кэширования вызовов методов, хотя со Spring можно сделать кэширование очень просто.
Пришло время разобраться c Quartz
Начиная со Spring Boot 2.0, для него существует отдельный стартер:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency>
Главная сущность, которую мы хотели:
src/main/java/ru/otus/springquartzexample/quartz/VeryHardJob.java:
@Slf4j public class VeryHardJob implements Job { @Override public void execute(JobExecutionContext context) { log.info("Very very very hard job..."); } }
Quartz — очень сложный фреймворк. Он хранит выполненные работы в БД и для того, чтобы его настроить, необходимо создать множество бинов, один из которых главный – Quartz Sheduler.
Spring Boot спасает нас от подобной необходимости, имеет настроенные бины, включая хранение в памяти данных работ.
Поэтому мы просто настроим только несколько бинов, а благодаря автоконфигурации они будут использованы.
src/main/java/ru/otus/springquartzexample/quartz/RussianHolidaysQuartzCalendar.java:
@RequiredArgsConstructor @Component public class RussianHolidaysQuartzCalendar implements Calendar { private final RussianHolidaysService russianHolidaysService; @Override public boolean isTimeIncluded(long timestamp) { try { val date = LocalDateTime.ofEpochSecond(timestamp, 0, ZoneOffset.UTC).toLocalDate(); return russianHolidaysService.isHoliday(date); } catch (Exception ex) { return false; } } @Override public long getNextIncludedTime(long timestamp) { LocalDate date = LocalDateTime.ofEpochSecond(timestamp, 0, ZoneOffset.UTC).toLocalDate(); try { while (!russianHolidaysService.isHoliday(date)) { date = date.plusDays(1); } return 0; } catch (Exception ex) { return date.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC); } } }
Ну и сами бины:
@Bean JobDetail veryHardJob() { return JobBuilder.newJob(VeryHardJob.class) .withIdentity("veryHardJob") .storeDurably() .build(); } @Bean Trigger jobTrigger(){ return TriggerBuilder.newTrigger().forJob(veryHardJob()) .withIdentity("veryHardJobTrigger") .startNow() .build(); }
Запустив это в будний день, мы увидим:
2019-11-15 09:57:01.315 INFO 18580 --- [eduler_Worker-1] r.o.s.quartz.VeryHardJob : Very very very hard job...
Подробности реализации этой задачи вы можете посмотреть здесь.