HTML Diff
0 added 0 removed
Original 2026-01-01
Modified 2026-03-10
1 <p>Представим достаточно популярную задачу - утилизацию вычислительных ресурсов в дни с минимальной нагрузкой. Допустим, у нас есть какой-то сайт, с которым обычно работают по будням. И есть тяжёлые фоновые задачи, которые хотим запускать в выходные, чтобы не пересекаться с клиентами.</p>
1 <p>Представим достаточно популярную задачу - утилизацию вычислительных ресурсов в дни с минимальной нагрузкой. Допустим, у нас есть какой-то сайт, с которым обычно работают по будням. И есть тяжёлые фоновые задачи, которые хотим запускать в выходные, чтобы не пересекаться с клиентами.</p>
2 <p>Но тут возникает небольшая проблема - выходные в РФ совсем не ограничиваются субботой и воскресеньем. Да и суббота и воскресенье не всегда могут быть выходными.</p>
2 <p>Но тут возникает небольшая проблема - выходные в РФ совсем не ограничиваются субботой и воскресеньем. Да и суббота и воскресенье не всегда могут быть выходными.</p>
3 <p>Попробуем решить эту задачу с помощью<a>Quartz</a>- одной из самых навороченных библиотек для запуска задач по расписанию и, конечно, Spring.</p>
3 <p>Попробуем решить эту задачу с помощью<a>Quartz</a>- одной из самых навороченных библиотек для запуска задач по расписанию и, конечно, Spring.</p>
4 <p>Естественно, мы напишем приложение на<strong>Spring Boot</strong>. Создадим его с помощью Spring Initializr.</p>
4 <p>Естественно, мы напишем приложение на<strong>Spring Boot</strong>. Создадим его с помощью Spring Initializr.</p>
5 <p><strong>pom.xml</strong>:</p>
5 <p><strong>pom.xml</strong>:</p>
6 &lt;?xml version="1.0" encoding="UTF-8"?&gt; &lt;project&gt; &lt;parent&gt; &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt; &lt;artifactId&gt;spring-boot-starter-parent&lt;/artifactId&gt; &lt;version&gt;2.2.1.RELEASE&lt;/version&gt; &lt;relativePath/&gt; &lt;/parent&gt; &lt;properties&gt; &lt;java.version&gt;11&lt;/java.version&gt; &lt;/properties&gt; &lt;dependencies&gt; &lt;dependency&gt; &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt; &lt;artifactId&gt;spring-boot-starter&lt;/artifactId&gt; &lt;/dependency&gt; &lt;/dependencies&gt; &lt;!-- и ещё немного --&gt; &lt;/project&gt;<p>Надо найти одновременно простое, полное и поддерживаемое API для получения списка выходных дней. Воспользуемся не самым простым для интеграции (это XML)<a>http://xmlcalendar.ru/</a>, но и не самым сложным с точки зрения полноты данных.</p>
6 &lt;?xml version="1.0" encoding="UTF-8"?&gt; &lt;project&gt; &lt;parent&gt; &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt; &lt;artifactId&gt;spring-boot-starter-parent&lt;/artifactId&gt; &lt;version&gt;2.2.1.RELEASE&lt;/version&gt; &lt;relativePath/&gt; &lt;/parent&gt; &lt;properties&gt; &lt;java.version&gt;11&lt;/java.version&gt; &lt;/properties&gt; &lt;dependencies&gt; &lt;dependency&gt; &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt; &lt;artifactId&gt;spring-boot-starter&lt;/artifactId&gt; &lt;/dependency&gt; &lt;/dependencies&gt; &lt;!-- и ещё немного --&gt; &lt;/project&gt;<p>Надо найти одновременно простое, полное и поддерживаемое API для получения списка выходных дней. Воспользуемся не самым простым для интеграции (это XML)<a>http://xmlcalendar.ru/</a>, но и не самым сложным с точки зрения полноты данных.</p>
7 <p>Данное API возвращает XML следующего вида:</p>
7 <p>Данное API возвращает XML следующего вида:</p>
8 <p><strong>src/test/resources/calendar.xml</strong>:</p>
8 <p><strong>src/test/resources/calendar.xml</strong>:</p>
9 &lt;?xml version="1.0" encoding="UTF-8"?&gt; &lt;calendar year="2018" lang="ru" date="2017.10.22"&gt; &lt;holidays&gt; &lt;!-- ещё немножко XML --&gt; &lt;holiday id="6" title="День Победы" /&gt; &lt;/holidays&gt; &lt;days&gt; &lt;!-- ещё немножко XML --&gt; &lt;day d="05.09" t="1" h="6" /&gt; &lt;!-- t="1" - выходной, t="2" - сокращённый, t="3" - рабочий, суббота и воскрсенье - выходные по умолчанию. да, формат даты ММ.ДД --&gt; &lt;day d="06.09" t="2" /&gt; &lt;/days&gt; &lt;/calendar&gt;<p>Немножко разобравшись с форматом ответа, получается следующий алгоритм определения выходного дня:</p>
9 &lt;?xml version="1.0" encoding="UTF-8"?&gt; &lt;calendar year="2018" lang="ru" date="2017.10.22"&gt; &lt;holidays&gt; &lt;!-- ещё немножко XML --&gt; &lt;holiday id="6" title="День Победы" /&gt; &lt;/holidays&gt; &lt;days&gt; &lt;!-- ещё немножко XML --&gt; &lt;day d="05.09" t="1" h="6" /&gt; &lt;!-- t="1" - выходной, t="2" - сокращённый, t="3" - рабочий, суббота и воскрсенье - выходные по умолчанию. да, формат даты ММ.ДД --&gt; &lt;day d="06.09" t="2" /&gt; &lt;/days&gt; &lt;/calendar&gt;<p>Немножко разобравшись с форматом ответа, получается следующий алгоритм определения выходного дня:</p>
10 День выходной == если это понедельник… пятница и он присутствует в праздничном календаре с типом t="1", а если это суббота или воскресенье, то день должен отсутствовать в праздничном календаре с типами t="2" или t="3".<p>Напишем с помощью Jackson маппер данного XML. Итак, для начала настроим Jackson:</p>
10 День выходной == если это понедельник… пятница и он присутствует в праздничном календаре с типом t="1", а если это суббота или воскресенье, то день должен отсутствовать в праздничном календаре с типами t="2" или t="3".<p>Напишем с помощью Jackson маппер данного XML. Итак, для начала настроим Jackson:</p>
11 <p><strong>pom.xml</strong>:</p>
11 <p><strong>pom.xml</strong>:</p>
12 &lt;dependency&gt; &lt;groupId&gt;com.fasterxml.jackson.dataformat&lt;/groupId&gt; &lt;artifactId&gt;jackson-dataformat-xml&lt;/artifactId&gt; &lt;/dependency&gt;<p><strong>src/main/java/ru/otus/springquartzexample/config/ApplicationConfig.java</strong>:</p>
12 &lt;dependency&gt; &lt;groupId&gt;com.fasterxml.jackson.dataformat&lt;/groupId&gt; &lt;artifactId&gt;jackson-dataformat-xml&lt;/artifactId&gt; &lt;/dependency&gt;<p><strong>src/main/java/ru/otus/springquartzexample/config/ApplicationConfig.java</strong>:</p>
13 @Configuration public class ApplicationConfig { @Bean XmlMapper xmlMapper() { return new XmlMapper(); } }<p>Напишем классы для элементов:</p>
13 @Configuration public class ApplicationConfig { @Bean XmlMapper xmlMapper() { return new XmlMapper(); } }<p>Напишем классы для элементов:</p>
14 <p><strong>src/main/java/ru/otus/springquartzexample/xmlcalendar/CalendarElement.java</strong>:</p>
14 <p><strong>src/main/java/ru/otus/springquartzexample/xmlcalendar/CalendarElement.java</strong>:</p>
15 @Data @JacksonXmlRootElement(localName = "calendar") @JsonIgnoreProperties(ignoreUnknown = true) public class CalendarElement { @JacksonXmlElementWrapper(localName = "days") private List&lt;DayElement&gt; days; }<p><strong>src/main/ru/otus/springquartzexample/xmlcalendar/DayElement.java</strong>:</p>
15 @Data @JacksonXmlRootElement(localName = "calendar") @JsonIgnoreProperties(ignoreUnknown = true) public class CalendarElement { @JacksonXmlElementWrapper(localName = "days") private List&lt;DayElement&gt; days; }<p><strong>src/main/ru/otus/springquartzexample/xmlcalendar/DayElement.java</strong>:</p>
16 @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; }<p>Да, не удивляйтесь, что здесь присутствуют аннотации для Json. Всё-таки, это Jackson.</p>
16 @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; }<p>Да, не удивляйтесь, что здесь присутствуют аннотации для Json. Всё-таки, это Jackson.</p>
17 <p>Чтобы убедиться, что мы всё сделали правильно, напишем небольшой тест:</p>
17 <p>Чтобы убедиться, что мы всё сделали правильно, напишем небольшой тест:</p>
18 <p><strong>src/main/java/ru/otus/springquartzexample/xmlcalendar/CalendarElementTest.java</strong>:</p>
18 <p><strong>src/main/java/ru/otus/springquartzexample/xmlcalendar/CalendarElementTest.java</strong>:</p>
19 @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"); } }<p>Ну и напишем клиента, который получает соответствующий календарь:</p>
19 @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"); } }<p>Ну и напишем клиента, который получает соответствующий календарь:</p>
20 @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); } }<p><strong>И куда без теста</strong>:</p>
20 @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); } }<p><strong>И куда без теста</strong>:</p>
21 @DisplayName("Класс XmlCalendarClient") @SpringBootTest class XmlCalendarClientTest { @Autowired private XmlCalendarClient client; @DisplayName("должен возвращать данные за 2019 год") @Test void shouldReturn2019() throws Exception { val calendar = client.read(2019); assertThat(calendar).isNotNull(); } }<p>Теперь пришло время написать нашу<strong>бизнес-логику</strong>. Для приличия создадим интерфейс сервиса:</p>
21 @DisplayName("Класс XmlCalendarClient") @SpringBootTest class XmlCalendarClientTest { @Autowired private XmlCalendarClient client; @DisplayName("должен возвращать данные за 2019 год") @Test void shouldReturn2019() throws Exception { val calendar = client.read(2019); assertThat(calendar).isNotNull(); } }<p>Теперь пришло время написать нашу<strong>бизнес-логику</strong>. Для приличия создадим интерфейс сервиса:</p>
22 <p><strong>src/main/java/ru/otus/springquartzexample/service/RussianHolidaysService.java</strong>:</p>
22 <p><strong>src/main/java/ru/otus/springquartzexample/service/RussianHolidaysService.java</strong>:</p>
23 public interface RussianHolidaysService { boolean isHoliday(LocalDate date); }<p>Но неприлично реализуем его прямо в клиенте:</p>
23 public interface RussianHolidaysService { boolean isHoliday(LocalDate date); }<p>Но неприлично реализуем его прямо в клиенте:</p>
24 @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 -&gt; dayToFind.equals(day.getDate())) .findAny(); return isSaturdayOrSunday ? fromCalendar.isEmpty() : fromCalendar.isPresent() &amp;&amp; fromCalendar.map(DayElement::isHoliday).get(); }<p><strong>Ну и куда без тестов</strong>:</p>
24 @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 -&gt; dayToFind.equals(day.getDate())) .findAny(); return isSaturdayOrSunday ? fromCalendar.isEmpty() : fromCalendar.isPresent() &amp;&amp; fromCalendar.map(DayElement::isHoliday).get(); }<p><strong>Ну и куда без тестов</strong>:</p>
25 @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))); }<p>Здесь мы не будем рассматривать особенности кэширования вызовов методов, хотя со Spring можно сделать кэширование очень просто.</p>
25 @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))); }<p>Здесь мы не будем рассматривать особенности кэширования вызовов методов, хотя со Spring можно сделать кэширование очень просто.</p>
26 <h2>Пришло время разобраться c Quartz</h2>
26 <h2>Пришло время разобраться c Quartz</h2>
27 <p>Начиная со Spring Boot 2.0, для него существует отдельный стартер:</p>
27 <p>Начиная со Spring Boot 2.0, для него существует отдельный стартер:</p>
28 &lt;dependency&gt; &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt; &lt;artifactId&gt;spring-boot-starter-quartz&lt;/artifactId&gt; &lt;/dependency&gt;<p>Главная сущность, которую мы хотели:</p>
28 &lt;dependency&gt; &lt;groupId&gt;org.springframework.boot&lt;/groupId&gt; &lt;artifactId&gt;spring-boot-starter-quartz&lt;/artifactId&gt; &lt;/dependency&gt;<p>Главная сущность, которую мы хотели:</p>
29 <p><strong>src/main/java/ru/otus/springquartzexample/quartz/VeryHardJob.java</strong>:</p>
29 <p><strong>src/main/java/ru/otus/springquartzexample/quartz/VeryHardJob.java</strong>:</p>
30 @Slf4j public class VeryHardJob implements Job { @Override public void execute(JobExecutionContext context) { log.info("Very very very hard job..."); } }<p><strong>Quartz</strong>- очень сложный фреймворк. Он хранит выполненные работы в БД и для того, чтобы его настроить, необходимо создать множество бинов, один из которых главный -<strong>Quartz Sheduler</strong>.</p>
30 @Slf4j public class VeryHardJob implements Job { @Override public void execute(JobExecutionContext context) { log.info("Very very very hard job..."); } }<p><strong>Quartz</strong>- очень сложный фреймворк. Он хранит выполненные работы в БД и для того, чтобы его настроить, необходимо создать множество бинов, один из которых главный -<strong>Quartz Sheduler</strong>.</p>
31 <p>Spring Boot спасает нас от подобной необходимости, имеет настроенные бины, включая хранение в памяти данных работ.</p>
31 <p>Spring Boot спасает нас от подобной необходимости, имеет настроенные бины, включая хранение в памяти данных работ.</p>
32 <p>Поэтому мы просто настроим только несколько бинов, а благодаря автоконфигурации они будут использованы.</p>
32 <p>Поэтому мы просто настроим только несколько бинов, а благодаря автоконфигурации они будут использованы.</p>
33 <p><strong>src/main/java/ru/otus/springquartzexample/quartz/RussianHolidaysQuartzCalendar.java</strong>:</p>
33 <p><strong>src/main/java/ru/otus/springquartzexample/quartz/RussianHolidaysQuartzCalendar.java</strong>:</p>
34 @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); } } }<p>Ну и сами бины:</p>
34 @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); } } }<p>Ну и сами бины:</p>
35 @Bean JobDetail veryHardJob() { return JobBuilder.newJob(VeryHardJob.class) .withIdentity("veryHardJob") .storeDurably() .build(); } @Bean Trigger jobTrigger(){ return TriggerBuilder.newTrigger().forJob(veryHardJob()) .withIdentity("veryHardJobTrigger") .startNow() .build(); }<p>Запустив это в будний день, мы увидим:</p>
35 @Bean JobDetail veryHardJob() { return JobBuilder.newJob(VeryHardJob.class) .withIdentity("veryHardJob") .storeDurably() .build(); } @Bean Trigger jobTrigger(){ return TriggerBuilder.newTrigger().forJob(veryHardJob()) .withIdentity("veryHardJobTrigger") .startNow() .build(); }<p>Запустив это в будний день, мы увидим:</p>
36 2019-11-15 09:57:01.315 INFO 18580 --- [eduler_Worker-1] r.o.s.quartz.VeryHardJob : Very very very hard job...<p>Подробности реализации этой задачи вы можете посмотреть<a>здесь</a>.</p>
36 2019-11-15 09:57:01.315 INFO 18580 --- [eduler_Worker-1] r.o.s.quartz.VeryHardJob : Very very very hard job...<p>Подробности реализации этой задачи вы можете посмотреть<a>здесь</a>.</p>
37  
37