Привет, Хабр!
Сегодня я хочу поговорить об управлении временем в 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 {
private final Clock clock;
private final EmployeeRepository employeeRepository;
...
}
Если вы активно используете 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 AtomicReference<Clock> CLOCK_REFERENCE = new AtomicReference<>(Clock.systemDefaultZone());
@Nonnull
public static Clock getClock() {
return CLOCK_REFERENCE.get();
}
/**
* Atomically sets the value to {@code newClock} 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
.
Update
Пока готовил статью к публикации, пришёл к другому (более 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));
}
}
}
И сама entity:
@Getter
@Setter
@SuperBuilder
@NoArgsConstructor
@MappedSuperclass
@EntityListeners(ClockAwareEntityListener.class)
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;
}
Фиксируйте время в тестах
Итак, теперь мы имеем в коде единые часы, от которых зависит получение времени во всём приложении, но в тестах эти часы по-прежнему будут выдавать монотонно возрастающий недетерминированный результат при каждом запуске. Вероятно, это не совсем то, чего бы нам хотелось. К счастью, мы можем «остановить» время в тестах, используя Clock.fixed
, например так:
@ActiveProfiles("test")
@SpringBootTest(classes = {ClockConfig.class, CustomConfigurationExampleTest.CustomClockConfiguration.class})
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() {
return Instant.now(clock()).truncatedTo(ChronoUnit.MICROS);
}
Как приучить разработчиков, правильно работать с датой/временем?
Ключевой момент, который может помешать вам достичь успеха в управлении временем внутри вашего приложения, это использование метода now()
без указания часов.
Разумеется, все ваши разработчики в команде должны об этом знать и использовать правильную перегруженную версию. Также вы можете контролировать этот момент на этапе code review, но я предпочитаю другой вариант — запрет на уровне Checkstyle, используя правило IllegalMethodCall
:
<module name="IllegalMethodCall">
<property name="illegalMethodNames" value="now" />
</module>
В этом случае для получения времени должны использоваться статические методы, описанные в предыдущем пункте (с усечением времени). Такой подход решает сразу несколько проблем. И, да, он весьма кардинальный.
* * *
На этом у меня всё. Итоговые примеры кода можно найти на GitHub.
Комментарии (12)
venum
10.08.2022 07:27Не совсем понятно, чем Clock лучше проставления ZoneOffset/ZoneId ?
Xobotun
10.08.2022 09:38+3Мне кажется, использование Clock не спасёт от проставления ZoneId, но могу ошибаться.
А сам Clock полезен для тестирования вещей, завязанных на время. Можно в тестах замокать Clock и заставить его возвращать фиксированное значение, либо некую последовательность, например, текущее время, а потом внезапный прыжок на год вперёд, например.
bugy
10.08.2022 10:09+2Я бы рекомендовал не использовать LocalDateTime если для этого нет специальных причин. Очень велик шанс потерять правильную таймзону и получить неправильное время
OffsetDateTime или лучше ZonedDateTime. Так вы сможете перекидывать даты повсюду (другие сервисы, фронт, бд и т.п.) без рисков и без знания, на какой же таймзоне работает ваш сервер.
IvanVakhrushev Автор
10.08.2022 14:28Пулл реквесты в демо-проект приветствуются. Можно перейти на OffsetDateTime
nehaev
10.08.2022 11:19+1private LocalDateTime createdAt;
Это грубейшая ошибка, которая говорит о том, что вы не понимаете, как правильно использовать Java Time API, и даже не читали официальную документацию. А в ней вполне конкретно написано: "LocalDateTime is a description of the date, as used for birthdays, combined with the local time as seen on a wall clock. It cannot represent an instant on the time-line without additional information such as an offset or time-zone."
В вашем случае должно быть:
private ZonedDateTime createdAt;
Но тогда большая часть страданий и "полезных советов" из статьи становится неактуальной.
IvanVakhrushev Автор
10.08.2022 14:18Покажите код, который запишет ваш пример с
ZonedDateTime
в БД PostgreSQL, а там посмотрим, в чём конкретно я не прав. Попутно можете почитать https://jdbc.postgresql.org/documentation/head/java8-date-time.html и https://docs.oracle.com/javase/8/docs/api/java/time/ZonedDateTime.html
Lewigh
Можно просто не использовать напрямую работу с датой и временем а вынести в отдельный сервис . Потом можно менять реализацию сколько захочется. В тестах мокать и выставлять любое время. И не нужно никаких радикальных ограничений на code-style. Все просто.
IvanVakhrushev Автор
А что помешает разработчику вызвать LocalDateTime.now()? И здесь речь даже не про злой умысел/раздолбайство, а просто про нового человека в команде, который ещё слабо ориентируется в кодовой базе?
Простой вариант не жизнеспособен на большом отрезке времени, когда команда может полностью обновиться или продукт уйдёт на поддержку совершенно другой команде.
cudu
так у вас то тоже ничего не запрещает разрабу вызвать LocalDateTime.now()? кроме стайлчека..добавить к утилитному классу рестрикт и все.
IvanVakhrushev Автор
В предлагаемом мною варианте разработчик получит ошибку компиляции при попытке использовать now(). Checkstyle позволяет настраивать сообщения об ошибках, и туда можно прокинуть, например, ссылку на эту статью или на внутреннюю wiki, где будет объясняться концепция.
Попытки обойти это ограничение должны отлавливаться уже на этапе код ревью.
moonster
Например, кодстайлчекер?
В моем текущем проекте уже лет 5 никто руками даты не создает. Несколько десятков программистов колбасят - это пять команд, люди периодически приходят и уходят. И все хорошо.
Lewigh
Если у вас нет нормального code review на проекте, вас ничего не спасет от "творчества" таких разработчиков. Ну забанили Вы слово now, что дальше? Есть еще миллион и один способ как угробить проект, если нет нормального контроля качества кода. Знаете что будет в реальной жизни? Отдадут проект на поддержку другой команде и через непродолжительное время найдется разработчик который посчитает написанное Вами правило глупым и просто отключит его. Вот и вся жизнеспособность на большом отрезке времени.