• Krzysztof Borkowski

LocalDateTime.now() - nie używać! - część II

To jest część druga z serii artykułów o tym, w jaki sposób informacja o aktualnym czasie powinna być dostarczana do logiki biznesowej i innych elementów oprogramowania, które wytwarzamy. W tym artykule kontynuuję temat zakładając, że zapoznałeś się z poprzednią częścią tej serii. Jeżeli tego nie uczyniłeś, to proszę zrób to, zanim przejdziesz do kolejnego akapitu na tej stronie. Pierwszą część tej serii znajdziesz tutaj.


Dwie decyzje architektoniczne


Spójrzmy jeszcze raz na metodę isExpired(). Aktualnie wygląda ona następująco:

public boolean isExpired() {
  final boolean result;

  final LocalDateTime now = LocalDateTime.now();
  final LocalDateTime expirationDateTime =
    publicationDateTime.plus(timeToLive);

  result = expirationDateTime.compareTo(now) <= 0;

  return result;
}

W pierwszej części tej serii napisaliśmy test tej metody i przekonaliśmy się (brutalnie), jak wielkie problemy sprawia brak możliwości podmockowania źródła czasu wykorzystywanego w tej metodzie... jak wielkim problemem jest ta oto linijka kodu:

final LocalDateTime now = LocalDateTime.now();

Dla przypomnienie dodam, że druga, podobna, znajduje się w konstruktorze klasy Announcement.


Tę linijkę kodu musimy zastąpić. Naszym celem jest napisanie kodu tak, aby zmienna now była inicjowana ze źródła czasu, które możemy podmockować. Jak to zrobić? Istnieje kilka możliwych rozwiązań. Różnice między nimi sprowadzają się jednak do tego, jakich odpowiedzi udzielimy na takie oto dwa, fundamentalne pytania.

  1. Jaką postać będzie miało źródło czasu, które będziemy dostarczać do metody isExpired()?

  2. W jaki sposób będziemy dostarczać to źródło do tej metody?

Zajmijmy się pytaniem pierwszym.


Postacie źródła czasu


Mamy co najmniej dwie opcji do wyboru. Pierwsza, to stworzenie własnego interfejsu, który będzie zawierał metodę dostarczającą obiekty klasy LocalDateTime, reprezentujące aktualny czas. Interfejs ten mógłby wyglądać następująco:

public interface TimeProvider
{
  LocalDateTime provideCurrentDateTime();
}

Interfejs ten miałby co najmniej dwie implementacje. Pierwsza, produkcyjna, która dostarczałaby aktualny czas pobrany z systemu, mogłaby wyglądać następująco:

public enum SystemTimeProvider implements TimeProvider
{
  // Klasyczny singleton
  INSTANCE;

  @Override
  public LocalDateTime provideCurrentDateTime() {
    return LocalDateTime.now();
  }
}

Druga implementacja byłaby wykorzystywana w testach i mogłaby wyglądać tak:

public final class FixedTimeProvider implements TimeProvider
{
  private LocalDateTime currentDateTime;
  
  public FixedTimeProvider(final LocalDateTime currentDateTime) {
    if ( currentDateTime == null )
      throw new IllegalArgumentException();
    
    this.currentDateTime = currentDateTime;
  }

  @Override
  public LocalDateTime provideCurrentDateTime() {
    return currentDateTime;
  }

  public void updateCurrentDateTime(
      final LocalDateTime newCurrentDateTime) {
    if ( newCurrentDateTime == null )
      throw new IllegalArgumentException();
    
    this.currentDateTime = newCurrentDateTime;
  }
}

Feralna linijka kodu wyglądałaby wówczas następująco:

final TimeProvider timeProvider = ...; // hmm... skąd wziąć ten obiekt?
final LocalDateTime now = timeProvider.provideCurrentDateTime();

Trywialne, prawda? Jedyny problem to... skąd wziąć obiekt interfejsu TimeProvider. To jest właśnie drugie z w/w fundamentalnych pytań. Odpowiem na nie za chwilę.


Czas na drugą możliwą postać źródła czasu. Pojawiła się wraz z Javą 8. To klasa java.time.Clock. Jeżeli uważnie prześledzisz dokumentację klas LocalDate, LocalDateTime, Instant, itd., to zauważysz, że taka np. LocalDateTime ma nie tylko metodę now(), ale również metodę now(Clock). Dokumentacja Javadoc tej metody mówi: "using this method allows the use of an alternate clock for testing" - wygląda więc na to, że możemy z tej metody skorzystać.


Klasa Clock jest odpowiednikiem naszego interfejsu TimeProvider. Jest, co prawda, odrobinę bardziej skomplikowana, ale za to przychodzi od razu z kilkoma ciekawymi metodami statycznymi - wśród nich są:

  1. systemDefaultZone() - zwraca obiekt klasy Clock, który podaje aktualny czas pobrany z systemu w domyślnej strefie czasowej; to dokładnie tej metody używa wewnętrznie metoda LocalDateTime.now(); tak więc systemDefaultZone() tworzy obiekty klasy Clock, które są odpowiednikami obiektów naszej klasy SystemTimeProvider;

  2. fixed(Instant, ZoneId) - zwraca obiekt klasy Clock, który podaje zawsze ten sam czas... określony w argumentach tej metody; metoda ta, więc, tworzy obiekty klasy Clock, które są odpowiednikiem obiektów naszej klasy FixedTimeProvider.

W tym podejściu feralna linijka kodu wyglądałaby następująco:

final Clock clock = ...; // hmm... a ten obiekt skąd wziąć?
final LocalDateTime now = LocalDateTime.now(clock);

I tak jak w poprzednim podejściu kwestią otwartą było, skąd wziąć zmienną timeProvider, tak tutaj do ustalenia pozostaje, skąd wziąć obiekt klasy Clock. Dodatkowo zwróć uwagę, że w tym rozwiązaniu programista sam musi sobie skonstruować obiekt klasy LocalDateTime, zaś w przypadku pierwszego rozwiązania, opartego o interfejs TimeProvider, jest z tego zadania zwolniony, gdyż realizuje je metoda provideCurrentDateTime().


OK. Które z tych dwóch rozwiązań wybrać? Długo rozważałem argumenty za jednym i drugim podejściem i doszedłem do wniosku, że rozważania te mają charakter czysto akademicki i nie dają decydującego rozstrzygnięcia. Wybierz więc te podejście, które Ci bardziej pasuje.


Jeżeli chodzi o mnie, to oczywiście mam pewną preferencję - bardziej odpowiada mi rozwiązanie pierwsze, czyli własny interfejs TimeProvider. Dlaczego?


Po pierwsze... przyzwyczaiłem się. Takie podejście stosuję od 2002 roku, czyli na dwanaście lat przed pojawieniem się Javy 8, a wraz z nią klasy java.time.Clock.


Po drugie, obok interfejsu TimeProvider, z reguły tworzę również interfejs ElapsedNanoTimeProvider, który stanowi abstrakcję dla metody System.nanoTime(). Takiej abstrakcji nie da się uzyskać (w sposób sensowny) w oparciu o klasę java.time.Clock. Mógłbym oczywiście używać klasy Clock jako abstrakcji dla pobierania aktualnego czasu, a interfejsu ElapsedNanoTimeProvider jako abstrakcji dla metody System.nanoTime(), ale wówczas miałbym w kodzie dwa różne architektonicznie rozwiązania dla dwóch bardzo podobnych problemów. Wolę tego uniknąć - wybieram podejście jednorodne.


Po trzecie, nie podoba mi się w rozwiązaniu z klasą Clock to, że programiści są tutaj zmuszeni do samodzielnego konstruowania obiektów klas LocalDateTime, LocalDate etc. Nie widzę w tym żadnej wartości. Minimalnym, ale jednak, udogodnieniem jest to, że metoda provideCurrentDateTime() od razu zwraca skonstruowany obiekt klasy LocalDateTime.


W tym temacie... to wszystko.


Jak dostarczać źródło czasu?


Odpowiedź na to pytanie niesie ze sobą znacznie poważniejsze konsekwencje niż odpowiedź na pytanie pierwsze. Zasadniczo mamy tutaj trzy możliwości. Zajmijmy się pierwszą z nich - jest nią... przekazywanie źródła czasu w argumentach metod i konstruktorów.


Nie będę tutaj, w tym miejscu, omawiał wszystkich trzech możliwości. Dość już było teoretycznych rozważań. Czas, na nieco praktyki. Pozostałe dwie możliwości omówię w kolejnej części tej serii.


Obiekt klasy Clock przekazywany w argumentach


Tytułem wstępu muszę dodać, że nie jest to moje preferowane podejście. Zaznaczam to już teraz, bo nie chcę, żebyś pomyślał, że skoro zaczynam od przekazywania źródła czasu w argumentach to znaczy, że jest to podejście przeze mnie rekomendowane. Tak nie jest. Zaczynam od tego podejścia ponieważ jest trywialne. Dzięki temu bardzo szybko przejdziemy do powtórnego napisania testu metody isExpired() i zobaczymy, jak fantastycznym udogodnieniem jest możliwość podmockowania źródła czasu. Przejdźmy więc teraz do testu.


Spójrzmy na naszą klasę Announcement - teraz nieco zmodyfikowaną, pobierającą aktualny czas z obiektu klasy java.time.Clock. Kolorem pomarańczowym wyróżniłem zmiany naniesione na wersję, którą stworzyliśmy w pierwszej części niniejszej serii.

public class Announcement
{
  private static final Duration MIN_TIME_TO_LIVE = Duration.ofHours(1);

  private final LocalDateTime publicationDateTime;
  private final Duration timeToLive;
  private final String message;  

  public Announcement(final Duration timeToLive, final String message,
                      final Clock clock) {
                      
    if ( timeToLive == null )
      throw new IllegalArgumentException();
    if ( timeToLive.isNegative() || timeToLive.isZero() )
      throw new IllegalArgumentException();
    if ( timeToLive.compareTo(MIN_TIME_TO_LIVE) < 0 )
      throw new IllegalArgumentException();
    if ( (message == null) || message.isEmpty() )
      throw new IllegalArgumentException();
    if ( clock == null )
      throw new IllegalArgumentException();
    
    this.publicationDateTime = LocalDateTime.now(clock);
    this.timeToLive = timeToLive;
    this.message = message;
  }
  
  public boolean isExpired(final Clock clock)
  {
    if ( clock == null )
      throw new IllegalArgumentException();
    
    final boolean result;
    
    final LocalDateTime now = LocalDateTime.now(clock);
    final LocalDateTime expirationDateTime =
      publicationDateTime.plus(timeToLive);
    
    result = expirationDateTime.compareTo(now) <= 0; 
    
    return result;
  }
}

Przyjrzyjmy się teraz, jak wyglądałyby testy tej wersji metody isExpired.


Początek jest dokładnie taki sam, jak w przypadku poprzedniej wersji i jestem pewien, że nie wymaga tłumaczenia.

public class AnnouncementTest {
  @Test
  void testIsExpired() {
    final Duration announcementTimeToLive = Duration.ofHours(1);
    
    // to be continued...
  }
}

Następnie deklarujemy dwie zmienne typu LocalDateTime. Będą one reprezentowały dwa szczególne punkty w czasie. Pierwszy to moment stworzenia i jednocześnie publikacji ogłoszenia - ustalamy go sobie dowolnie. Drugi, to najwcześniejszy możliwy punkt w czasie, od którego ogłoszenie powinno być już wygasłe.

public class AnnouncementTest {
  @Test
  void testIsExpired() {
    final Duration announcementTimeToLive = Duration.ofHours(1);
    
    final LocalDateTime announcementCreationDateTime =
      LocalDateTime.of(2021, 2, 7, 12, 19, 52, 1893);
    final LocalDateTime exactlyOnAnnouncementExpiryDateTime =
      announcementCreationDateTime.plusHours(announcementTimeToLive);    
    
    // to be continued...
  }
}

Aby test pisało nam się dalej łatwo i przyjemnie musimy stworzyć sobie metodkę pomocniczą, która będzie zwracała nam źródło czasu (obiekt klasy Clock) wskazujące na określony przez nas punkt w czasie (obiekt klasy LocalDateTime). Oto ta metodka:

private static Clock createClock(final LocalDateTime localDateTime) {
  // w metodach prywatnych nie weryfikuję argumentów wejściowych,
  // pisałem o tym w trzeciej części serii Fail Fast... or die! (tutaj)

  final Clock result;

  final ZoneOffset zoneOffset = ZoneOffset.UTC;
  final Instant instant = localDateTime.toInstant(zoneOffset);

  result = Clock.fixed(instant, zoneOffset);

  return result;
}

Kod tej metodki, choć krótki, to zdecydowanie nie jest trywialny - wszystko przez zabawę ze strefami czasowymi. O tym kodzie mógłbym napisać kilka akapitów, ale wówczas skutecznie odwróciłbym Twoją uwagę od sprawy zasadniczej - testu metody isExpired. Nie będę się więc rozwodził. W skrócie napiszę tylko, że metoda ta zamienia obiekt klasy LocalDateTime na obiekt klasy Instant - wymagany do stworzenia obiektu klasy Clock. Ponieważ klasa Instant pracuje w strefie czasowej UTC a LocalDateTime w lokalnej (cokolwiek to znaczy) metoda ta ustala, że obiekt klasy LocalDateTime również wyraża czas w strefie czasowej UTC. Mając obiekt klasy Instant możemy wreszcie skonstruować obiekt klasy Clock. Dla tego obiektu, jednak, również musimy określić strefę czasową i powinna być ona zgodna ze strefą czasową jaką określiliśmy dla przekazanego w argumencie obiektu klasy LocalDateTime - czyli znów będzie to UTC.


Uff... wróćmy do testu.


W kolejnej linijce testu deklarujemy zmienną pomocniczą o nazwie currentDateTime. Będzie ona reprezentowała punkt w czasie, który będziemy uznawali za czas aktualny. To na podstawie tej zmiennej będziemy tworzyć obiekty klasy Clock - nasze źródło aktualnego czasu. Zmienną tę od razu inicjujemy wartością announcementCreationDateTime.

public class AnnouncementTest {
  @Test
  void testIsExpired() {
    final Duration announcementTimeToLive = Duration.ofHours(1);
    
    final LocalDateTime announcementCreationDateTime =
      LocalDateTime.of(2021, 2, 7, 12, 19, 52, 1893);
    final LocalDateTime exactlyOnAnnouncementExpiryDateTime =
      announcementCreationDateTime.plusHours(announcementTimeToLive);    
    
    LocalDateTime currentDateTime;

    // Chwila utworzenia ogłoszenia
    currentDateTime = announcementCreationDateTime;
    
    // to be continued...
  }
}

Następnie tworzymy obiekt klasy Announcement. Zwróć uwagę, że w argumencie trzecim tego konstruktora przekazujemy aktualne źródło czasu - w tym wypadku jest to wynik wywołania metody createClock(currentTime).

public class AnnouncementTest {
  @Test
  void testIsExpired() {
    final Duration announcementTimeToLive = Duration.ofHours(1);
    
    final LocalDateTime announcementCreationDateTime =
      LocalDateTime.of(2021, 2, 7, 12, 19, 52, 1893);
    final LocalDateTime exactlyOnAnnouncementExpiryDateTime =
      announcementCreationDateTime.plusHours(announcementTimeToLive);    
    
    LocalDateTime currentDateTime;

    // Chwila utworzenia ogłoszenia
    currentDateTime = announcementCreationDateTime;
    
    final Announcement announcement = new Announcement(
      announcementTimeToLive, "irrelevant",
      createClock(currentDateTime));
    
    // to be continued...
  }
}

Od tej pory wszystko jest już trywialne. W kolejnych krokach będziemy: (a) ustawiać zmienną currentTime na odpowiedni punkt w czasie, a następnie (b) będziemy wywoływać metodę isExpired(Clock) przekazując jej w argumencie źródło aktualnego czasu wskazujące na currentTime. Kod testu jest samodokumentujący się, niezwykle prosty i czytelny - nie będą więc go dalej komentował, niech mówi sam za siebie.

public class AnnouncementTest {
  @Test
  void testIsExpired() {
    final Duration announcementTimeToLive = Duration.ofHours(1);
    
    final LocalDateTime announcementCreationDateTime =
      LocalDateTime.of(2021, 2, 7, 12, 19, 52, 1893);
    final LocalDateTime exactlyOnAnnouncementExpiryDateTime =
      announcementCreationDateTime.plusHours(announcementTimeToLive);    
    
    LocalDateTime currentDateTime;

    // Chwila utworzenia ogłoszenia
    currentDateTime = announcementCreationDateTime;
    
    final Announcement announcement = new Announcement(
      announcementTimeToLive, "irrelevant",
      createClock(currentDateTime));

    assertFalse(announcement.isExpired(createClock(currentDateTime)));
    
    // Jedna nanosekunda po utworzeniu ogłoszenia
    currentDateTime = announcementCreationDateTime.plusNanos(1);
    assertFalse(announcement.isExpired(createClock(currentDateTime)));

    // Jedna nanosekunda przed wygaśnięciem ogłoszenia
    currentDateTime =
      exactlyOnAnnouncementExpiryDateTime.minusNanos(1);
    assertFalse(announcement.isExpired(createClock(currentDateTime)));

    // W chwili wygaśnięcia ogłoszenia
    currentDateTime = exactlyOnAnnouncementExpiryDateTime;
    assertTrue(announcement.isExpired(createClock(currentDateTime)));

    // Jedna nanosekunda po wygaśnięciu ogłoszenia
    currentDateTime = exactlyOnAnnouncementExpiryDateTime.plusNanos(1);
    assertTrue(announcement.isExpired(createClock(currentDateTime)));
  }
}

Piękny kawałek kodu, prawda? Ten test nie ma żadnego z problemów, jakie miała jego poprzednia wersja. Na mojej maszynie wykonuje się kilka milisekund, a nie jak poprzedni - godzinę. A do tego jest tak dokładny, że nawet mysz... przepraszam, nanosekunda się przez niego nie prześlizgnie. Dla przykładu spróbuj w metodzie isExpired zamienić warunek z mniejszy lub równy, na mniejszy - gwarantuję, że ten test nie zakończy się wówczas sukcesem.


To rozwiązani nie podoba mi się


Tak, nie podoba mi się. W tworzonych przeze mnie systemach wybieram inne podejście. Zaprezentuję je w kolejnej części tej serii. A dlaczego to rozwiązanie mi się nie podoba? Oto wyjaśnienie.


Dodanie nowego argumentu do metody isExpired niesie ze sobą bardzo poważną konsekwencję - otóż od tej pory każdy klient tej metody, czyli każda metoda wywołująca isExpired, musi sama mieć dostęp do obiektu klasy Clock... żeby przekazać ten obiekt w argumencie isExpired. Te klienckie metody muszą posiadać dostęp do obiektu klasy Clock nawet wówczas, gdy same do niczego go nie potrzebują. To jest pierwsza rzecz, która mi się nie podoba.


Dalej.... te klienckie metody, albo same w jakiś sposób stworzą obiekt klasy Clock, albo do listy ich (!) argumentów będziemy musieli dodać nowy argument - typu Clock. Ale... te metody mają swoich własnych klientów, a oni kolejnych klientów itd. itd. - czy im wszystkim też dodamy argument klasy Clock? Tak właśnie będzie trzeba zrobić. W efekcie każda metoda w łańcuchu wywołań prowadzących do isExpired będzie miała dodatkowy argument klasy Clock... każda metoda za wyjątkiem pierwszej. Pierwsza będzie musiała, bowiem, albo sama stworzyć taki obiekt, albo pobrać go z jakiejś fabryki, albo uzyskać na drodze wstrzyknięcia z frameworku DI (Dependency Injection). Ostatecznie ogromna liczba metod będzie musiała przyjmować w argumencie obiekt klasy Clock - w niektórych systemach może to być znacznie więcej niż połowa wszystkich metod. To jest druga rzecz, która mi się nie podoba. Nie tylko zresztą mi. Martin Fowler, w swojej znakomitej książce pt. "Patterns of Enterprise Architecture Application" napisał, że taki stan rzeczy stanowi mocną przesłankę do rozważenia zastosowania wzorca projektowego o nazwie Registry.


Zdarzają się też sytuacje, w których zastosowanie tego rozwiązania byłoby po prostu niepraktyczne. Wyobraźmy sobie, że przejmujemy w utrzymanie system, który składa się... powiedzmy z miliona linii kodu. W systemie tym metoda LocalDateTime.now() jest wywołana w tysiącu różnych miejsc (tysiąc instrukcji pobrania aktualnego czasu na milion linii kodu to jest jak najbardziej produkcyjna proporcja - wziąłem ją z systemu, nad którym pracuję). Gdybyśmy teraz chcieli zastąpić wszystkie wywołania LocalDateTime.now() przez wywołania metody LocalDateTime.now(Clock), oznaczałoby to, że do każdego z tych miejsc musielibyśmy dostarczyć obiekt klasy Clock - czyli do metody, która wywołuje LocalDateTime.now() musielibyśmy dodać nowy argument. Ale to jest tysiąc metod! Niech każda z nich ma po dwie metody klienckie oraz średni łańcuch wywołań o długości pięciu metod wówczas.... do zmodyfikowani mamy dziesięć tysięcy metod! To zaś oznaczałoby olbrzymi refactoring. Najpewniej nigdy byśmy się go nie podjęli - głównie z powodu braku czasu w projekcie i konieczności skupienia się na dostarczaniu nowych funkcjonalności.


Tak więc z tych oto powodów preferuję inne podejście. Jak już się zapewne domyślasz jest ono oparte o wzorzec projektowy Registry. Podejście to przedstawię w trzeciej części tej serii (tutaj).


"One more thing..."


Ten i inne artykuły piszę po to, żeby podzielić się swoją wiedzą i doświadczeniem z innymi. Jednakże napisanie artykułu to dopiero połowa sukcesu - drugą połowę stanowi dotarcie z treścią do innych. Możesz mi bardzo pomóc w tym zadaniu - wystarczy, że klikniesz w jedną z czterech ikon poniżej i udostępnisz innym link do tego posta. Za tę pomoc będę Ci bardzo wdzięczny.