Управление временем в Java приложениях

2aee27138b74f8542febd024b1258062.png

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

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


    

В этом случае для получения времени должны использоваться статические методы, описанные в предыдущем пункте (с усечением времени). Такой подход решает сразу несколько проблем. И, да, он весьма кардинальный.

* * *

На этом у меня всё. Итоговые примеры кода можно найти на GitHub.

© Habrahabr.ru