To jest trzecia część 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 poprzednimi częściami 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.
Oto rozwiązanie, które preferuję ;-)
Tak, wreszcie nadszedł czas, abym zaprezentował rozwiązanie, które uważam za najlepsze i którego używam we wszystkich tworzonych przeze mnie systemach. Jest ono oparte na wzorcu projektowym Registry, opisanym w znakomitej, must-read książce Martina Fowlera pt. "Patterns of Enterprise Architecture Application".
Spójrzmy jeszcze raz na pierwotną wersję metody isExpired(). Wyglądała 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;
}
Feralną linijkę kodu (zaznaczoną kolorem pomarańczowym) zastąpimy teraz wywołaniem kilku metod:
final LocalDateTime now = RegistryInstance.provide()
.provideTimeProvider().provideCurrentDateTime();
Co robi powyższy kod? Rozbijmy go na mniejsze elementy:
final Registry registry = RegistryInstance.provide();
final TimeProvider timeProvider = registry.provideTimeProvider();
final LocalDateTime now = timeProvider.provideCurrentDateTime();
Analizę zacznijmy od końca, od ostatniej linijki. Otóż na końcu otrzymujemy obiekt klasy LocalDateTime pobrany z obiektu implementującego interfejs TimeProvider. Z tym interfejsem zapoznaliśmy się już. Omawialiśmy go w poprzedniej części tej serii jako jedno z dwóch możliwych źródeł czasu (drugim źródłem była klasa java.time.Clock). Dla przypomnienia i wygody zamieszczam tutaj jeszcze raz kod tego interfejsu:
public interface TimeProvider
{
LocalDateTime provideCurrentDateTime();
}
Jego domyślna implementacja może wyglądać następująco:
public enum SystemTimeProvider implements TimeProvider
{
// Klasyczny singleton
INSTANCE;
@Override
public LocalDateTime provideCurrentDateTime() {
return LocalDateTime.now();
}
}
Wróćmy do analizy i cofnijmy się w kodzie o jedną linijkę. Dopiero teraz zaczyna robić się interesująco. Oto obiekt klasy TimeProvider nie został dostarczony do metody isExpired() w jej argumencie, ale raczej metoda ta pobrała go sobie sama - z obiektu klasy Registry. Klasa ta wygląda następująco:
public class Registry
{
private final TimeProvider timeProvider;
public Registry(final TimeProvider timeProvider) {
if ( timeProvider == null )
throw new IllegalArgumentException();
this.timeProvider = timeProvider;
}
public TimeProvider provideTimeProvider() {
return timeProvider;
}
}
Rzadko kiedy obiekt klasy Registry występuje w tak ubogiej formie. Z reguły posiada więcej metod, dostarczających inne usługi do obiektów biznesowych i w ogóle wszelkich innych obiektów, których nie chcemy przywiązywać do konkretnego frameworku DI (Dependency Injection). Dla przykładu, mógłby dostarczać dodatkowo obiekt interfejsu BusinessConfiguration, z którego nasza klasa Announcement pobierałaby minimalny czas, przez jaki ogłoszenie ma być publikowane. W tej chwili bowiem wartość ta jest zadeklarowana w klasie Announcement na stałe (patrz prywatna stała statyczna MIN_TIME_TO_LIVE).
A skąd pobieramy obiekt klasy Registry? Cofnijmy się w analizowanym kodzie o jeszcze jedną linijkę i zobaczymy wówczas, że... z klasy RegistryInstance. Oto fragment tej klasy:
public final class RegistryInstance
{
private static Registry registrySoleInstance;
private RegistryInstance() {
throw new UnsupportedOperationException("Nie waż się tego "
+ "konstruktora wywoływać... "
+ "nawet za pomocą refleksji, łobuzie jeden!");
}
public static Registry provide() {
return registrySoleInstance;
}
// trzeba jeszcze zainicjować jakoś registrySoleInstance
}
Zatrzymajmy się tutaj na chwilę. Otóż zwróć, proszę, swoją uwagę na to, co do tej pory zrobiliśmy. Cały kod, który do tej pory wyrzeźbiliśmy, czyli interfejs TimeProvider, klasa Registry i wreszcie klasa RegistryInstance (celowo pomijam tutaj klasę SystemTimeProvider) zostały powołane do istnienia z powodu klasy Announcement - jej konstruktora i metody isExpired(). Cały ten kod to abstrakcja dla pobierania źródła czasu. Tę abstrakcję musimy teraz połączyć z kodem który wie, jak to źródło czasu wygląda. Punktem styku tych dwóch światów jest klasa RegistryInstance. Dla pierwszego świata wystawia ona metodę statyczną provide(), dla drugiego zaś, metodę... initialize (również statyczną). Oto pełen kod klasy RegistryInstance:
public final class RegistryInstance
{
private static Registry registrySoleInstance;
private RegistryInstance() {
throw new UnsupportedOperationException("Nie waż się tego "
+ "konstruktora wywoływać... " +
+ "nawet za pomocą refleksji, łobuzie jeden!");
}
public static void initialize(final Registry registry) {
if ( registry == null )
throw new IllegalArgumentException();
if ( isInitliazed() )
throw new IllegalStateException();
registrySoleInstance = registry;
}
public static Registry provide() {
if ( !isInitliazed() )
throw new IllegalStateException();
return registrySoleInstance;
}
private static boolean isInitliazed() {
final boolean result = ( registrySoleInstance != null );
return result;
}
}
Ostatnim elementem naszej układanki jest zainicjowanie klasy RegistryInstance? Załóżmy, że piszemy aplikację w oparciu o framework Spring, a ściślej - z wykorzystaniem Spring Boota. Zapewne więc w naszym kodzie znajdzie się taki oto fragment:
@SpringBootApplication
public class App
{
public static void main(final String[] args) {
SpringApplication.run(App.class, args);
}
}
Jeżeli nasz interfejs TimeProvider zaimplementowaliśmy tak, jak wyżej, czyli jako klasyczny singleton, to możemy klasę RegistryInstance zainicjować w następujący sposób:
@SpringBootApplication
public class App
{
public static void main(final String[] args) {
initRegistry();
SpringApplication.run(App.class, args);
}
private static void initRegistry() {
final Registry registry =
new RegistryImpl(SystemTimeProvider.INSTANCE);
RegistryInstance.initialize(registry);
}
}
Jednakże, tak jak wcześniej pisałem, klasa Registry jest niemal zawsze nieco bardziej skomplikowana. Niektóre z dostarczanych przez nią obiektów będą potrzebowały dostępu do beanów springowych. To oznacza, że inicjację obiektu klasy Registry, a co za tym idzie również i klasy RegistryInstance, będziemy musieli wykonać w kontekście Springa. Taką inicjację przedstawiam poniżej. W tym przykładzie zakładamy, że interfejs TimeProvider nie jest zaimplementowany jako klasycznych singleton oparty na enumie, ale jako bean springowy.
Najpierw, dla porządku, przyjrzyjmy się implementacji interfejsu TimeProvider:
@Component
public class SystemTimeProvider implements TimeProvider
{
private SystemTimeProvider() {
/* NOP */
}
@Override
public LocalDateTime provideCurrentDateTime() {
return LocalDateTime.now();
}
}
A teraz spójrzmy na kod inicjujący klasę RegistryInstance.
@SpringBootApplication
public class App
{
public static void main(final String[] args) {
SpringApplication.run(App.class, args);
}
@Bean(autowireCandidate = false)
Registry createRegistry(final TimeProvider timeProvider) {
if ( timeProvider == null )
throw new IllegalArgumentException();
final Registry result = new Registry(timeProvider);
RegistryInstance.initialize(result);
return result;
}
}
Oczywiście metoda createRegistry powinna znaleźć się w jakimś dedykowanym beanie konfiguracyjnym - dla zachowania porządku w kodzie. Tylko przez wzgląd na zachowanie prostoty przykładu metodę tę umieściłem w klasie App.
Zwróć uwagę, proszę, że w adnotacji Bean dodaliśmy element autowireCandidate = false. To zabieg mający na celu zapewnienie, że bean Registry nie będzie widoczny dla innych beanów Springa. O to nam właśnie chodzi - jedyny dostęp do tego beana powinien być możliwy przez klasę RegistryInstance.
OK. Tyle. Tak wygląda rozwiązanie, które preferuję. Jego olbrzymim plusem jest to, że nie wpływa na argumenty metod, w których potrzebne jest aktualne źródło czasu. Po prostu wywołanie statycznej metody LocalDateTime.now(), której niestety nie da się zamockować, zastępujemy trzema innymi wywołaniami, które razem, wzięte w całość, pozwalają nam podmockować źródło czasu. Jak to zrobić? Już za chwilę pokażę Ci to na przykładzie testu metody isExpired().
Przed przejściem do testu rzućmy jeszcze okiem na diagram obrazujący całość rozwiązania. Jest to mix diagramu klas ze zmodyfikowanym przeze mnie diagramem komunikacji, ale... wydaje mi się, że ten jeden obrazek bardzo czytelnie wszystko podsumowuje.
Przejdźmy do testu
Na początek potrzebna jest nam taka implementacja interfejsu TimeProvider, która pozwoli nam na kontrolowanie czasu, jaki będzie zwracać metoda provideCurrentDateTime(). Implementację tę pokazałem już w poprzedniej części tej serii, ale zamieszczam ją tutaj jeszcze raz... dla Twojej wygody.
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;
}
}
Teraz czas na sam test. W dużej mierze będzie on kopią poprzedniej wersji testu, tej którą stworzyliśmy dla rozwiązania, w którym źródło czasu w postaci obiektu klasy java.time.Clock przekazywaliśmy w argumentach metod i konstruktorów. Już jednak na samym początku pojawia się istotna różnica. Otóż najpierw musimy zainicjować klasę RegistryInstance naszym mockiem źródła czasu, a więc klasą FixedTimeProvider. Zrobimy to tak:
public class AnnouncementTest
{
private static final LocalDateTime CURRENT_DATE_TIME =
LocalDateTime.of(2021, 1, 15, 13, 10, 0, 0);
private FixedTimeProvider fixedTimeProvider;
@BeforeEach
private void beforeEach() {
fixedTimeProvider = new FixedTimeProvider(CURRENT_DATE_TIME);
final Registry registry = new Registry(fixedTimeProvider);
RegistryInstance.initialize(registry);
}
// to be continued...
}
Co robi ten kod? Otóż przed uruchomieniem każdej metody testowej, na nowo inicjuje klasę RegistryInstance. Inicjuje ją naszym mockiem źródła czasu - obiektem klasy FixedTimeProvider, który na początku każdego testu będzie, jako aktualny czas, zwracał wartość CURRENT_DATE_TIME.
Musimy się tutaj na chwilę zatrzymać. Wprowadzenie klasy RegistryInstance ze statycznym polem registrySoleInstance spowodowało, że powstał współdzielony, globalny kontekst dla całej aplikacji i wszystkich testów. Oznacza to więc, że kontekst ten jest współdzielony pomiędzy kolejnymi metodami testującymi wskutek czego poszczególne metody testujące stają się od siebie zależne. Jest to dalece niepożądane i może prowadzić do sytuacji, w której testy przestają przechodzić i to co gorsza.. losowo. Trudno jest wówczas znaleźć przyczynę takiej sytuacji. Dlatego zawsze, w każdej klasie testującej, należy inicjować klasę RegistryInstance w metodzie oznaczonej adnotacją @BeforeEach. Tylko w ten sposób zagwarantujemy, że każdy test będzie startował z takim samym kontekstem, całkowicie oczyszczonym z wpływu, jaki wywarły na niego wcześniej wykonane metody testujące.
Istnieje jeszcze jedna konsekwencja powstania w/w współdzielonego kontekstu. Otóż od tej pory, testów nie wolno uruchamiać współbieżnie tj. w więcej niż jednym wątku. Uważam, że w praktyce nie jest to istotne ograniczenie. To dlatego, że z reguły w testach istnieje więcej współdzielonych kontekstów niż tylko klasa RegistryInstance. Przykładem będzie tutaj np. współdzielona w testach integracyjnych baza danych. I o ile w miarę łatwo jest dostosować klasę RegistryInstance tak, aby możliwe było współbieżne uruchamianie testów, o tyle napisanie testów integracyjnych w taki sposób, aby były całkowicie od siebie niezależne w warstwie bazy danych, jest znacznie trudniejsze i moim zdaniem niewarte wysiłku. Skoro nie jest warte wysiłku, to nie warto też dywagować nad dostosowaniem klasy RegistryInstance do testów wykonywanych współbieżnie. Tak więc, mam nadzieję, że zgodzisz się ze mną, że dla zachowania zwięzłości artykułu nie będę tutaj rozwijał tematu dostosowania klasy RegistryInstance na potrzeby współbieżnego wykonywania testów.
Podsumowując, pamiętaj o tym, aby:
przed każdym wywołaniem metody testującej, klasa RegsitryInstance była ponownie zainicjowane
testy nie były wykonywane współbieżnie, jeżeli klasa RegistryInstance nie jest do tego dostosowana
Jeżeli wnikliwie przeanalizowałeś ten artykuł, to zapewne zauważyłeś, że wyjątkiem zakończy się drugie i kolejne wywołanie metody beforeEach(). Wszystko dlatego, że w klasie RegistryInstance, w metodzie initalize(Registry) mamy warunek, który uniemożliwia wywołanie tej metody więcej niż jeden raz. Jak poradzić sobie z tym problemem? Na to pytanie odpowiem nieco później. Na razie nie martw się.... wszystko jest pod kontrolą.
Przejdźmy teraz wreszcie do napisania metody testIsExpired(). Jest to lekko zmodyfikowana kopia poprzedniej wersji testu - tej wersji, którą stworzyliśmy dla rozwiązania, w którym źródło czasu przekazujemy w argumentach metod i konstruktorów. Kolorem pomarańczowym zaznaczyłem wspomniane modyfikacje.
public class AnnouncementTest
{
private static final LocalDateTime CURRENT_DATE_TIME =
LocalDateTime.of(2021, 1, 15, 13, 10, 0, 0);
private FixedTimeProvider fixedTimeProvider;
@BeforeEach
private void beforeEach() {
fixedTimeProvider = new FixedTimeProvider(CURRENT_DATE_TIME);
final Registry registry = new Registry(fixedTimeProvider);
RegistryInstance.initialize(registry);
}
@Test
public void testIsExpired() {
final Duration announcementTimeToLive = Duration.ofHours(1);
final LocalDateTime announcementCreationDateTime =
LocalDateTime.of(2021, 2, 7, 12, 19, 52, 1893);
final LocalDateTime exactlyOnAnnouncementExpiryDateTime =
announcementCreationDateTime.plus(announcementTimeToLive);
LocalDateTime currentDateTime;
// Chwila utworzenia ogłoszenia
currentDateTime = announcementCreationDateTime;
fixedTimeProvider.updateCurrentDateTime(currentDateTime);
final Announcement announcement =
new Announcement(announcementTimeToLive, "irrelevant");
assertFalse(announcement.isExpired());
// Jedna nanosekunda po utworzeniu ogłoszenia
currentDateTime = announcementCreationDateTime.plusNanos(1);
fixedTimeProvider.updateCurrentDateTime(currentDateTime);
assertFalse(announcement.isExpired());
// Jedna nanosekunda przed wygaśnięciem ogłoszenia
currentDateTime =
exactlyOnAnnouncementExpiryDateTime.minusNanos(1);
fixedTimeProvider.updateCurrentDateTime(currentDateTime);
assertFalse(announcement.isExpired());
// W chwili wygaśnięcia ogłoszenia
currentDateTime = exactlyOnAnnouncementExpiryDateTime;
fixedTimeProvider.updateCurrentDateTime(currentDateTime);
assertTrue(announcement.isExpired());
// Jedna nanosekunda po wygaśnięciu ogłoszenia
currentDateTime = exactlyOnAnnouncementExpiryDateTime.plusNanos(1);
fixedTimeProvider.updateCurrentDateTime(currentDateTime);
assertTrue(announcement.isExpired());
}
}
Oczywiście dodatkowe zmiany, które ciężko było mi wyróżnić kolorem pomarańczowym, to:
usunięcie metody pomocniczej createClock(LocalDateTime) - nie jest już bowiem potrzebna
usunięcie argumentów klasy java.time.Clock z wywołań konstruktora klasy Announcement oraz metody isExpired()
To tyle. Myślę, że wszystko jest jasne.
Pozostał nam jeszcze jeden problem do rozwiązania. Otóż drugie i kolejne wywołanie metody beforeEach() zakończy się wygenerowaniem wyjątku IllegalStateException. To dlatego, że metoda RegistryInstance.initialize(Registry) jest zabezpieczona i nie da się jej wywołać więcej niż jeden raz. Rozwiązanie tego problemu wydaje się być trywialne, ale... choć faktycznie nie jest skomplikowane, to jednak ma kilka fascynujących wariantów, które warto w szczegółach omówić. Ich omówienie zajmie mi jednak parę ładnych słów, więc żeby nie rozdmuchać tego artykułu do rozmiarów wzbudzających przerażenie u czytelnika, postanowiłem opisać te rozwiązania w kolejnej części tej serii. Tak więc zapraszam Cię do przeczytania kolejnego artykułu, który ukaże się już wkrótce. :-)
"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.
Yorumlar