Co u mnie?

Trochę wody w Wiśle upłynęło odkąd pojawił się tutaj mój ostatni wpis. Jednak nie miało to nic wspólnego z lenistwem. Ciągle aktywnie działałem, ale nie w sferze programistycznej. Przygotowujemy się wraz z żoną na nowego członka rodziny oraz w najlepsze trwa remont naszego mieszkania. Sporo się dzieje. Rok 2023 mnie nie oszczędza.

Jak pewnie zauważyłeś/aś, blog zmienił swój image. Stało się to za sprawą przejścia z Wordpressa na Jekyll. Do tego ruchu zainspirowała mnie metamorfoza bloga Darka Mydlarza. Na pewno jest to zmiana na lepsze! Jekyll jest o wiele mniej toporny i dużo łatwiej obsługiwać go przez Gita. Mogę otworzyć dowolny edytor, napisać artykuł i zrobić git push! Oczywiście korzystałbym z tak przygotowanej maszyny o wiele częściej, gdyby był tylko na to czas…

Przejdźmy do sedna

Po tym krótkim wstępie przejdźmy do dzisiejszego tematu artykułu. Chciałbym Ci w nim przedstawić kodzik, który ostatnio popełniłem. Za cel postawiłem sobie zrefaktoryzowanie mojego kolejnego, starego projektu. Wcześniej skupiłem się na projekcie AnimalShelter. Teraz na tapet wziąłem MemoryGame (stary kod jest na branchu legacy).

Dlaczego akurat ten projekt? Był skończony, działał. Pamiętam jak przy jego pisaniu miałem naprawdę sporo frajdy. To były fajne czasy… Natomiast patrząc na ten kod dzisiaj można w nim zauważyć od razu kilka chorób. Najpoważniejszą z nich jest mocne związanie logiki prezentacyjnej z logiką gry. Importy z bibliotek Swing i AWT latają wszędzie. Projekt cierpi też na brak jakichkolwiek testów. Właśnie z tych powodów zdecydowałem się wejść w to bagno i nie zawahałem się w nim klęknąć! Zapraszam Cię więc na krótką przygodę po meandrach tego kodu legacy.

Przedstawienie projektu

Zanim zaczniemy. Przypomnijmy sobie o co w ogóle chodzi w grze Memory. Chociaż raczej powinienem napisać Concetration, bo właśnie pod tą nazwą kryję się ona na Wikipedii. Celem gry jest odkrycie większej ilość par kart niż przeciwnicy. Robimy to poprzez wybranie w swojej turze dwóch zakrytych kart z dostępnej puli na planszy. Jeśli odkryte karty do siebie pasują, zabieramy je. Jeśli nie, odwracamy je rewersem do góry i tracimy kolejkę. Teraz grę kontynuuje następny gracz. Gramy tak długo aż wszystkie karty znikną z planszy. Opisany przeze mnie przed chwilą wariant jest tym najbardziej znanym. Pozostałe znajdziesz na wcześniej podanym linku do Wikipedii.

Mój projekt legacy skupia się natomiast na tzw. Solitaire, czyli wersji dla jednego gracza. Polega ona na odkryciu wszystkich par kart w jak najszybszym czasie, uwzględniając wcześniej podane zasady. Do jego zaimplementowania wykorzystałem oczywiście Javę wraz z wcześniej wspomnianą biblioteką Swing. Zajrzyjmy w końcu do samego rozwiązania. Znajdziemy w nim klasę GamePanel, która zawiera wszystkie reguły gry oraz wie w jaki sposób renderowana jest plansza z kartami. Jak nic w tym miejscu została złamana reguła SRP. Dodatkowo w kodzie wszędzie znajdują się komentarze w języku polskim, aby każdy kto go czyta wiedział o co chodziło autorowi. Jakby sam kod nie wystarczył… Naprawdę sporo jest tutaj takich smaczków. Co prawda nie ma logiki odpowiedzialnej za wysyłkę maili w konstruktorze, ale też jest ciekawie.

Jednak nie może być aż tak źle, prawda? Zgadza się. W klasie Card znajdziemy promyk nadzei. Pomimo poplątania w niej logiki biznesowej z graficznym interfejsem, jest tam ukryty kawałek programowania obiektowego. Dokładniej mówiąc, w metodzie turnCard. Posiada ona w sobie zhermetyzowaną logikę.

1
2
3
4
5
6
7
public void turnCard() {
  if(this.activeIcon == this.reverse)
    this.activeIcon = this.observe;
  else
    this.activeIcon = this.reverse;
  setIcon(this.activeIcon);
}

Robi ona dokładnie to co opisuje. Pozwala obrócić kartę, na przeciwną stronę, w zależności od stanu w jakim się znajduje. Jak widać, nawet w takim potworku znajdzie się jakiś pozytyw. Jeśli jesteś ciekawy/a co można więcej okryć w tym kodzie to jeszcze raz zapraszam na GitHuba.

Event Storming uwalnia kreatywność

Niestety najczęściej programiści od razu zabierają się do kodowania, bez zamodelowania danego zagadnienia. Tak było, jest i mam nadzieje, że nie będzie. Warto więc zamiast tego zatrzymać się na chwilę i, przed dorwaniem się do klawiatury, przemyśleć co chce się osiągnąć. Można to zrobić na wiele sposobów. Jednym z nich jest zastosowanie wizualizacji graficznej w postaci karteczek, zwanej Event Stormingiem. Właśnie z tego narzędzia skorzystałem, aby naprawić ten projekt. Co prawda jest on zbyt trywialny do tej klasy techniki, ale na czymś trzeba się uczyć, prawda? Poniżej znajdziesz wynik mojej sesji z samym sobą.

Sesja Event Storming dla Memory Game Sesja Event Storming dla Memory Game

Tak jak można się było tego spodziewać. Nie wygląda to na nic specjalnie skomplikowanego. Jednak zebranie tego co wiem w jedno miejsce i zobaczenie tego pozwoliło mi zadać sobie kilka ciekawych pytań. Oto kilka najważniejszych z nich:

  • Czy karta musi wiedzieć, że została zgadnięta? Czy to nie raczej coś wyższego rzędu o tym wie?
  • Czy karta to nie jest tylko reprezentacja graficzna? Czy zamiast kart można by użyć kartek albo kapsli?
  • Czy zawsze muszę odgadnąć tylko 2 karty? Czy może być ich więcej np. 3?

Dzięki takiej autorefleksji nie zdecydowałem się na nazwę Card w kodzie. Wybrałem inną, bardziej adekwatną - FlatItem. Jakby się nad tym zastanowić to wszystkie płaskie przedmioty mają dwie strony zwane rewersem oraz awersem. Można więc powiedzieć, że w ten sposób odkryłem jakiś element głębszego modelu. Oczywiście z biznesem dalej można się posługiwać słowem jakim jest karta. Natomiast w kodzie otwiera nam się droga do podmiany tej reprezentacji graficznej na jakiś inny przedmiot. Ważne, aby spełniał tylko podstawowe założenie jakim jest możliwość obracania się na dwie strony.

Idąc dalej, FlatItem nie może zostać zaprojektowany jako Value Object. Musimy wiedzieć jaką konkretnie kartę odkrywamy. Nawet pomimo tego, że ma ten sam awers co karta znajdująca się z nią w parze. Z tego właśnie powodu FlatItem zostanie oznaczony jako encja z DDD.

Co do 2 albo 3 kart do zgadnięcia będących w grupie. W grze Memory należy odkryć 2 te same karty, aby uznać je za zgadnięte. Jednak należy zadać sobie pytanie - czy to zawsze musi być prawdą? Może warto spojrzeć na to zagadnienie odrobinę szerzej i uzyskać nowe możliwości. Dzięki dobieraniu 3 albo 4 kart w grupę będziemy mogli tworzyć nowe warianty gry bądź zmieniać poziom trudności. I ja właśnie z tej opcji skorzystałem w nowej implementacji.

Po zakończeniu sesji Event Storming zastanawiałem się nad nową architekturą rozwiązania. Oto co wymyśliłem. Moim najniższym poziomem potencjału będzie karta, a ściślej mówiąc FlatItem. Następnie grupujemy je w innej klasie jaką jest FlatItemsGroup. To ona wie, które karty są na jakiej stronie. Jednak nie jest świadoma tego, że bierze udział w jakiejś grze. Do tego celu trzeba skorzystać z jej wystawionego API. I tutaj do gry wchodzi byt o nazwie MemoryGame. To on zarządza stanem danej rozgrywki. W nim znajdują się reguły dotyczące gry Concentration. Dopiero nad tym zbudowany jest interfejs graficzny. Dzięki niemu użytkownik może wejść w interakcję z naszą grą.

Wizualizacja wybranego rozwiązania Wizualizacja wybranego rozwiązania

Proces implementacji

Dzięki wcześniejszemu odrobieniu pracy domowej, wykorzystując Event Storming, pisanie kodu było tylko przyjemnością. Myślę, że spokojnie mogłem to zlecić ChatGPT. Jednak jako rzemieślnik sam zacząłem przepisywanie aplikacji. Poszedłem w kierunku, aby przepisać silnik gry i następnie go podpiąć pod istniejący interfejs graficzny. Idealnie do tego przypadku nadało się TDD, które chciałem użyć od początku do końca by-the-book. Napisanie klas FlatItem czy FlatItemsGroup nie sprawiło mi żadnego problemu. Nie mają one żadnych setterów czy getterów. Wszystko jest zhermetyzowane. Można z nimi jedynie kolaborować przez przejrzyste API. Oto przykład jednego z testów dla FlatItem.

1
2
3
4
5
6
7
8
@Test  
void turning_around_reverse_up_makes_it_obverse_up() {  
  FlatItem flatItem = FlatItem.reverseUp(FlatItemId.of(1));  

  flatItem.flip();  

  assertTrue(flatItem.isObverseUp());  
}

Tak natomiast prezentuje się wnętrze FlatItemsGroup. Specjalnie usunąłem wnętrza metod, aby pokazać jak prosty i przejrzysty jest interfejs klasy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public final class FlatItemsGroup {

  private final FlatItemsGroupId flatItemsGroupId;
  private final Set<FlatItem> flatItems;

  private FlatItemsGroup(  
      FlatItemsGroupId flatItemsGroupId,  
      Set<FlatItemId> flatItemIds,  
      Function<FlatItemId, FlatItem> creator) {  
    this.flatItemsGroupId = flatItemsGroupId;  
    if (flatItemIds.isEmpty()) {  
        throw new IllegalStateException("group of flat items cannot be empty");  
    }  
    this.flatItems = flatItemIds.stream()  
            .map(creator)  
            .collect(Collectors.toUnmodifiableSet());  
  }

  public static FlatItemsGroup allReversed(  
      FlatItemsGroupId flatItemsGroupId,  
      Set<FlatItemId> flatItemIds) {  
    return new FlatItemsGroup(  
        flatItemsGroupId,  
        flatItemIds,  
        FlatItem::reverseUp);  
  }
    
  public void turnAllToReverseUp();

  public void turnToObverse(FlatItemId flatItemId);

  public boolean contains(FlatItemId flatItemId);

  public boolean isAllReverseUp();

  public boolean isAllObverseUp();

  public GroupOfFlatItemsCurrentState currentState() {  
    return new GroupOfFlatItemsCurrentState(
      flatItems.stream()  
        .map(FlatItem::currentState)  
        .collect(Collectors.toUnmodifiableSet()));  
  }
    
}

Umyślnie zostawiłem ciało konstruktora, metody statycznej allReversed oraz zwykłej metody currentState. Dwa pierwsze elementy pokazują jak łatwe jest dodawanie nowych kombinacji stanów początkowych dzięki wykorzystaniu wzorca Strategii. Natomiast na większą uwagę zasługuje ostatni szczegół. Metoda currentState odpowiada za przedstawienie aktualnego stanu obiektu w myśl CQS. Nie zmiania ona nic w danym obiekcie. Powoduje tylko odczyt poprzez stworzenie snapshotu. W ten sposób użytkownik może wydawać polecenia obiektowi poprzez metody reprezentujące komendy, a następnie dzięki metodom odczytowym weryfikować jego aktualny stan.

Graficzny interfejs

To chyba najmniej interesująca część z tego wpisu. Być może dlatego, że dotyczy ona “starych” technologii takich jak Swing. No właśnie, możesz sobie teraz zadać pytanie: “Dlaczego on właściwie został przy tak zaszłej bibliotece?”. Racja, można było to zmienić na coś innego. Dla mnie jednak jest to prostu szczegół implementacyjny. Z “biznesowego” punktu widzenia, po prostu się to nie opłacało. Co prawda musiałem co nieco poszperać w dokumentacji Swinga, aby odkryć zastosowanie niektórych jego elementów. Swoją drogą czy właśnie nie z taką sytuacją możemy spotkać się w pracy? Zakładam, że niekażdy pracuje z najnowszą wersją Javy czy Springa. Zawsze będziemy mieć styczność z zaszłościami. Natomiast jeśli wszystko dobrze zamodelujemy na początku to wymiana jednego rozwiązania na drugie będzie mogła odbyć się niedużym kosztem.

Tak prezentuje się wersja dla fanów angielskiej Premier League Tak prezentuje się wersja dla fanów angielskiej Premier League

Nie przedłużając, dwa zdania o implementacji. Całość GUI oparłem na wzorcu Obserwator. Chciałem, żeby interakcja z jednym komponentem wpływała na stan innych komponentów np. pierwsze kliknięcie w kartę powoduje uruchomienie stopera. Wszystko zrobiłem ręcznie od podstaw. Cała inicjalizacja komponentów aplikacji odbywa się poprzez operatory new. Nie użyłem w tym celu żadnego kontenera zależności takiego jak np. Spring.

Wracając na chwilę do możliwości jakie dała mi sesja Event Stormingowa. Jak widzisz na obrazku powyżej, aby zaliczyć grupę kart jako odkrytą należy odkryć ich aż 3. Dostępna jest również wersja z czteroma kartami. Odkryty potencjał został wykorzystany w tym miejscu.

Ostatnie detale. Możemy zmienić rozmiar planszy przez co ilość kart niezbędnych do odkrycia ulegnie zmianie. Możliwa jest też podmiana motywu awersu czy rewersu na jeden z kilku dostępnych.

Podsumowanie

Cały ten proces sprawił mi ogromną frajdę. Po raz kolejny przekonałem się jak dużo się nauczyłem przez ostatnie lata. Już wiem, że warto jest rozdzielać część silnikową od interfejsu graficznego. Daje to nam więcej pola manewru, aby dodać nowe funkcjonalności czy też wymienić dany moduł. Praca na wyższym poziomie abstrakcji naprawdę się opłaca. Łatwiej jest nam wyrzucić binarną lub fizyczną karteczkę zamiast usuwać kod, do którego się już mocno przywiązaliśmy, bo oddaliśmy mu cząstkę siebie.

Kiedyś sam siadałem po to, aby kodzić. Teraz siadam, aby przemyśleć rozwiązanie. Dopiero jako zwięczenie modelowania zaczynam kodowanie. Zachęcam więc i Ciebie do takiego eksperymentu. Weź swój stary projekt i przyjrzyj mu się dokładnie. Zobacz co się da w nim zmienić, co uprościć. Nie musisz koniecznie kodować. Podejmujesz wyzwanie?

Co do dalszych wpisów na bloga. Tak jak wspomniałem na początku. Rok 2023 jest dla mnie mega obciążający, ale planuję wrócić do regularnego blogowania nawet pomimo ciężkiego okresu. Dlatego mam nadzieję, że do zobaczenia już wkrótce!