Привет, Хабр!

Сегодня я хочу поговорить об управлении временем в 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)


  1. Lewigh
    09.08.2022 16:07
    +2

    Можно просто не использовать напрямую работу с датой и временем а вынести в отдельный сервис . Потом можно менять реализацию сколько захочется. В тестах мокать и выставлять любое время. И не нужно никаких радикальных ограничений на code-style. Все просто.


    1. IvanVakhrushev Автор
      09.08.2022 18:27

      А что помешает разработчику вызвать LocalDateTime.now()? И здесь речь даже не про злой умысел/раздолбайство, а просто про нового человека в команде, который ещё слабо ориентируется в кодовой базе?
      Простой вариант не жизнеспособен на большом отрезке времени, когда команда может полностью обновиться или продукт уйдёт на поддержку совершенно другой команде.


      1. cudu
        09.08.2022 20:41

        так у вас то тоже ничего не запрещает разрабу вызвать  LocalDateTime.now()? кроме стайлчека..добавить к утилитному классу рестрикт и все.


        1. IvanVakhrushev Автор
          09.08.2022 22:28

          В предлагаемом мною варианте разработчик получит ошибку компиляции при попытке использовать now(). Checkstyle позволяет настраивать сообщения об ошибках, и туда можно прокинуть, например, ссылку на эту статью или на внутреннюю wiki, где будет объясняться концепция.

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


      1. moonster
        09.08.2022 23:57

        Например, кодстайлчекер?

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


      1. Lewigh
        10.08.2022 09:31
        +1

        Если у вас нет нормального code review на проекте, вас ничего не спасет от "творчества" таких разработчиков. Ну забанили Вы слово now, что дальше? Есть еще миллион и один способ как угробить проект, если нет нормального контроля качества кода. Знаете что будет в реальной жизни? Отдадут проект на поддержку другой команде и через непродолжительное время найдется разработчик который посчитает написанное Вами правило глупым и просто отключит его. Вот и вся жизнеспособность на большом отрезке времени.


  1. venum
    10.08.2022 07:27

    Не совсем понятно, чем Clock лучше проставления ZoneOffset/ZoneId ?


    1. Xobotun
      10.08.2022 09:38
      +3

      Мне кажется, использование Clock не спасёт от проставления ZoneId, но могу ошибаться.

      А сам Clock полезен для тестирования вещей, завязанных на время. Можно в тестах замокать Clock и заставить его возвращать фиксированное значение, либо некую последовательность, например, текущее время, а потом внезапный прыжок на год вперёд, например.


  1. bugy
    10.08.2022 10:09
    +2

    Я бы рекомендовал не использовать LocalDateTime если для этого нет специальных причин. Очень велик шанс потерять правильную таймзону и получить неправильное время

    OffsetDateTime или лучше ZonedDateTime. Так вы сможете перекидывать даты повсюду (другие сервисы, фронт, бд и т.п.) без рисков и без знания, на какой же таймзоне работает ваш сервер.


    1. IvanVakhrushev Автор
      10.08.2022 14:28

      Пулл реквесты в демо-проект приветствуются. Можно перейти на OffsetDateTime


  1. nehaev
    10.08.2022 11:19
    +1

    private 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;
    

    Но тогда большая часть страданий и "полезных советов" из статьи становится неактуальной.


    1. 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