- Управление временем в Java приложениях
- А что там на уровне СУБД?
- Больше никаких вызов now() без параметров
- Часы должны быть одни и только одни
- Фиксируйте время в тестах
- Используйте в тестах MutableClock
- Эмулируйте поведение СУБД, если нужно
- Не забывайте о различиях в точности
- Как приучить разработчиков, правильно работать с датой/временем?
Управление временем в Java приложениях
Сегодня я хочу поговорить об управлении временем в Java приложениях: зачем это нужно, и как это можно делать.
В реальном коде часто требуется сохранять дату и время в базу данных. Это может быть фиксация времени создания\последней модификации какого-либо объекта или указание срока действия документа, билета и т.п. Думаю, многие из вас решали эту задачу в своих проектах: сама по себе она несложная. Трудности возникают, когда мы хотим подобную систему протестировать и оценить, как она будет вести себя, скажем, через полгода или год. В будущем.
Конечно, можно накручивать системные часы на вашей машине, build-агенте, тестовом сервере, но это неудобно, а иногда физически невозможно (банальное отсутствие доступа или автоматическая синхронизация времени). А ещё это абсолютно не инженерный подход. Ниже я покажу несколько простых и изящных приёмов, которые позволят вам почувствовать себя доктором Стрэнджем…
А что там на уровне СУБД?
Сначала давайте посмотрим, как устроена работа с датой и временем на уровне СУБД, например, PostgreSQL. Это пригодится для дальнейшего понимания концепции, которую я продемонстрирую.
В PostgreSQL метку времени можно получить с помощью функции now() — в пределах одной транзакции она всегда возвращает один и тот же результат. Таким образом, если вы добавляете или изменяете несколько записей в одной транзакции, то у всех из них будет одинаковое время создания/модификации. Это удобно и классно опять же до того момента, пока вам не нужно протестировать поведение системы в другой момент времени.
Современная разработка ПО должна быть управляемой и предсказуемой, а это невозможно без автоматизированного тестирования. Именно по этой причине мы вынуждены отказаться от работы с датой временем на уровне СУБД и вынести её на уровень приложения.
Больше никаких вызов now() без параметров
Начиная с 8-й версии Java, нам доступен современный и удобный API для работы со временем. Эта тема неоднократно рассматривалась на Хабре. Подробнее можно почитать тут и тут.
Типовой подход в Java для получения даты или времени заключается в использовании статических методов now() . Я видел такой код сотни раз в разных проектах.
И вот первая рекомендация: откажитесь от использования now() без параметров в вашем коде. Всегда и везде нужно использовать перегруженную версию, принимающую на вход объект Clock :
Clock clock = Clock.systemUTC(); LocalDate date = LocalDate.now(clock); LocalDateTime time = LocalDateTime.now(clock); OffsetDateTime offsetTime = OffsetDateTime.now(clock);
Теперь дата и время зависят от используемых часов: если изменим часы и их поведение, то изменим получение времени внутри всего приложения! Всё гениальное просто, а разработчики JDK о нас уже позаботились.
Часы должны быть одни и только одни
Следующая задача, которую предстоит решить, это получение и использование одного и того же экземпляра часов внутри всего нашего приложения.
На текущий момент в стандартной автоконфигурации Spring Boot’а нет bean’а с часами, и в ближайшее время он точно не появится, поэтому всё приходится делать самостоятельно.
Если вы используете Spring без JPA (или с JPA, но без EntityListeners), то можно использовать следующий вариант:
@Configuration public class ClockConfig < @Bean public Clock clock() < return Clock.systemDefaultZone(); >>
Где-нибудь в сервисе просто инжектим и используем этот bean:
@Service @Transactional(readOnly = true) @RequiredArgsConstructor public class EmployeeService
Если вы активно используете Bean Validation API, то, возможно, вы захотите использовать его интерфейс ClockProvider. Лично я считаю его применение избыточным: использование Clock проще и очевиднее (core team Spring’а считает так же).
Однако, их можно совмещать:
@Bean public ClockProvider clockProvider(@Nonnull final Clock clock) < return () ->clock; >
Ситуация несколько осложняется случае активного использования JPA и EntityListeners ( @PrePersist / @PreUpdate и т.п.), поскольку инжектить бины в entity как-то. не принято. Именно так было на проекте, куда я пришёл несколько месяцев назад. В этом случае мы выбрали использование отдельного класса ClockHolder :
@Slf4j @UtilityClass public final class ClockHolder < private static final AtomicReferenceCLOCK_REFERENCE = new AtomicReference<>(Clock.systemDefaultZone()); @Nonnull public static Clock getClock() < return CLOCK_REFERENCE.get(); >/** * Atomically sets the value to and returns the old value. * * @param newClock the new value * @return the previous value of clock */ @Nonnull public static Clock setClock(@Nonnull final Clock newClock) < Objects.requireNonNull(newClock, "newClock cannot be null"); final Clock oldClock = CLOCK_REFERENCE.getAndSet(newClock); log.info("Set new clock <>. Old clock is <>", newClock, oldClock); return oldClock; > >
И пример его использования:
@Getter @Setter @SuperBuilder @NoArgsConstructor @MappedSuperclass public abstract class BaseEntity < @Id @NotNull @Column(updatable = false, nullable = false) private UUID id; @Column(name = "created_at", updatable = false, nullable = false) private LocalDateTime createdAt; @PrePersist public void beforePersist() < createdAt = LocalDateTime.now(ClockHolder.getClock()); >>
В Spring-конфигурацию bean clock в этом случае лучше не добавлять: везде следует использовать ClockHolder .
Пока готовил статью к публикации, пришёл к другому (более spring-style) варианту через отдельный класс, реализующий обработчики EntityListener’а. Плюс в том, что ClockHolder не нужен, и используется тот же самый bean clock . Если знаете другие варианты для этого случая, напишите в комментариях.
@Component @NoArgsConstructor public class ClockAwareEntityListener < // Couldn't use constructor injection here @Autowired private Clock clock; @PrePersist public void initCreatedAt(@Nonnull final BaseEntity entity) < if (entity.getCreatedAt() == null) < entity.setCreatedAt(LocalDateTime.now(clock)); >> >
@Getter @Setter @SuperBuilder @NoArgsConstructor @MappedSuperclass @EntityListeners(ClockAwareEntityListener.class) public abstract class BaseEntity
Фиксируйте время в тестах
Итак, теперь мы имеем в коде единые часы, от которых зависит получение времени во всём приложении, но в тестах эти часы по-прежнему будут выдавать монотонно возрастающий недетерминированный результат при каждом запуске. Вероятно, это не совсем то, чего бы нам хотелось. К счастью, мы можем «остановить» время в тестах, используя Clock.fixed , например так:
@ActiveProfiles("test") @SpringBootTest(classes = ) class CustomConfigurationExampleTest < private static final LocalDateTime MILLENNIUM = LocalDateTime.of(2000, Month.JANUARY, 1, 0, 0, 0); @Autowired private Clock clock; @Test void clockAlsoShouldBeFixed() < final LocalDateTime realNow = LocalDateTime.now(Clock.systemDefaultZone()); assertThat(LocalDateTime.now(clock)) .isBefore(realNow) .isEqualTo(LocalDateTime.of(2000, Month.JANUARY, 1, 0, 0, 0)); >@TestConfiguration static class CustomClockConfiguration < @Bean @Primary public Clock fixedClock() < return Clock.fixed(MILLENNIUM.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); >> >
В случае использования ClockHolder время можно зафиксировать в базовом классе:
@ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public abstract class TestBase < protected static final LocalDateTime BEFORE_MILLENNIUM = LocalDateTime.of(1999, Month.DECEMBER, 31, 23, 59, 59); @BeforeAll static void setUpClock() < final Clock fixed = Clock.fixed(BEFORE_MILLENNIUM.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); ClockHolder.setClock(fixed); >>
А в тестах это будет выглядеть следующим образом:
class EmployeeRepositoryTest extends TestBase < @Autowired private EmployeeRepository employeeRepository; @Test void createdAtShouldBeSetAutomaticallyOnSave() < final Employee notSaved = prepareIvanIvanov(); assertThat(notSaved.getCreatedAt()) .isNull(); final Employee saved = employeeRepository.save(notSaved); assertThat(saved) .isNotNull() .satisfies(e ->assertThat(e.getCreatedAt()) .isEqualTo(LocalDateTime.now(ClockHolder.getClock())) .isEqualTo(LocalDateTime.of(1999, Month.DECEMBER, 31, 23, 59, 59)) .isBefore(LocalDateTime.now(Clock.systemDefaultZone()))); > >
В конкретном тесте время можно изменить следующим образом:
@Test void canBeSavedInFuture() < final LocalDateTime distantFuture = LocalDateTime.of(3000, Month.JANUARY, 1, 0, 0, 0); final Clock fixed = Clock.fixed(distantFuture.toInstant(ZoneOffset.UTC), ZoneOffset.UTC); final Clock oldClock = ClockHolder.setClock(fixed); try < final Employee notSaved = prepareIvanIvanov(); assertThat(notSaved.getCreatedAt()) .isNull(); final Employee saved = employeeRepository.save(notSaved); assertThat(saved) .isNotNull() .satisfies(e ->assertThat(e.getCreatedAt()) .isEqualTo(LocalDateTime.now(ClockHolder.getClock())) .isEqualTo(LocalDateTime.of(3000, Month.JANUARY, 1, 0, 0, 0)) .isAfter(LocalDateTime.now(Clock.systemDefaultZone()))); > finally < ClockHolder.setClock(oldClock); >>
Получается многословно, не правда ли?
Используйте в тестах MutableClock
Стандартные часы Clock из JDK являются неизменяемыми (иммутабельными). Это очень классно, но только не в тестах: там было бы удобнее иметь возможность манипулировать временем. К счастью, уже есть ряд готовых решений для этого. Я остановил свой выбор на имплементации MutableClock из ThreeTen-Extra.
В коде это будет выглядеть примерно так: объявляем bean с изменяемым часами, переопределяем через него bean clock и после каждого теста восстанавливаем исходное значение фиксированных часов (чтобы в других тестах не заботиться об этом).
@ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ContextConfiguration(classes = TestBase.CustomClockConfiguration.class) public abstract class TestBase < protected static final LocalDateTime BEFORE_MILLENNIUM = LocalDateTime.of(1999, Month.DECEMBER, 31, 23, 59, 59); @Autowired protected MutableClock mutableClock; @Autowired protected Clock clock; @AfterEach void resetClock() < mutableClock.setInstant(getTestInstant()); >static Instant getTestInstant() < return BEFORE_MILLENNIUM.toInstant(ZoneOffset.UTC); >@TestConfiguration static class CustomClockConfiguration < @Bean public MutableClock mutableClock() < return MutableClock.of(getTestInstant(), ZoneOffset.UTC); >@Bean @Primary public Clock fixedClock(@Nonnull final MutableClock mutableClock) < return mutableClock; >> >
Зато в тестах теперь очень легко изменять время как в прошлое, так и в будущее:
@Test void clockCanBeChangedLocally() < mutableClock.add(1_000L, ChronoUnit.YEARS); // Назад в будущее! assertThat(LocalDateTime.now(clock)) .isAfter(LocalDateTime.now(Clock.systemDefaultZone())) .isEqualTo(LocalDateTime.of(2999, Month.DECEMBER, 31, 23, 59, 59)); >
Эмулируйте поведение СУБД, если нужно
Помните, про поведение функции now() в PostgreSQL? Такого же поведения вы можете добиться внутри своих методов/транзакций. Это нужно далеко не всегда, но может быть полезно при выявлении аномалий/разборе инцидентов. Простейший вариант этого добиться — получить текущее время в начале транзакции, запомнить его и затем пробрасывать во все последующие методы как параметр. Если вариант с параметром кажется многословным, то посмотрите в сторону ThreadLocal / MDC .
Не забывайте о различиях в точности
Точность времени зависит от используемой платформы. Я уже упоминал об этом в одной из своих предыдущих статей: на macOS (M1, Monterey), например, секунды измеряются с точностью до 6 знаков после запятой, а на build-агенте под управлением Linux — 9 знаков после запятой. Иногда это мешает в тестах. Решение простое: транкейтить до 6 знаков.
@Nonnull public static LocalDateTime localDateTimeNow() < return LocalDateTime.now(clock()).truncatedTo(ChronoUnit.MICROS); >@Nonnull public static Instant instantNow()
Как приучить разработчиков, правильно работать с датой/временем?
Ключевой момент, который может помешать вам достичь успеха в управлении временем внутри вашего приложения, это использование метода now() без указания часов.
Разумеется, все ваши разработчики в команде должны об этом знать и использовать правильную перегруженную версию. Также вы можете контролировать этот момент на этапе code review, но я предпочитаю другой вариант — запрет на уровне Checkstyle, используя правило IllegalMethodCall :
В этом случае для получения времени должны использоваться статические методы, описанные в предыдущем пункте (с усечением времени). Такой подход решает сразу несколько проблем. И, да, он весьма кардинальный.
На этом у меня всё. Итоговые примеры кода можно найти на GitHub.