20.09.20228 min

Cezary Sanecki Java DeveloperDecerto Sp. z o.o.

Czy Ty też tak robisz z polem status?

Sprawdź, w jaki sposób można lepiej wykorzystywać pole status.

Czy Ty też tak robisz z polem status?

Zakładam, że każdy programista miał do czynienia w swojej karierze ze słynnym polem status.

W moim przypadku miało to miejsce we wszystkich projektach jakich pracowałem. Najczęściej było ono implementowane jako typ wyliczeniowy. Zdarzały się jednak przypadki (na szczęście rzadko), że przybierało także formę integera.

Powstaje więc pytanie: “czy można podejść do jego implementacji w lepszy sposób?”. Postaram się na nie odpowiedzieć na podstawie własnych przemyśleń oraz doświadczeń. Prawdą jest, że lepiej się tłumaczy pewne zagadnienia na podstawie przykładu, więc zacznę od zdefiniowania stadium przypadku.


Studium przypadku

Zacznijmy od zdefiniowania podstawowych wymagań. Naszym zadaniem jest stworzenie systemu do obiegu książek w bibliotece. W zależności od wykonywanych akcji na książce chcemy, aby zmianiała ona swój status. Mamy kilka możliwości:

  • Available
    • reserve → Reserved
    • withdraw → Withdrawn
  • Reserved
    • rent → Rented
    • cancel → Available
  • Rented
    • endRental → Available
  • Withdrawn
    • redo → Available

Mówi się, że jeden obrazek przekazuje, więcej niż tysiąc słów, więc spróbujmy przedstawić powyższe wymagania w postaci grafiki.

Wymagania - pole status


Wiemy już co i jak. Pora zakasać rękawy i wziąć się do roboty. Spróbujmy na początku podejść do tego w sposób standardowy.


Pierwsza iteracja

Tak jak wspomniałem we wstępie, zaczniemy od oklepanego rozwiązania opartego o typ wyliczeniowy posiadającego błyskotliwą nazwę Status. W nim zamieścimy 4 możliwości, które odkryliśmy na podstawie wymagań. Następnie stworzymy klasę Book, której jedno z pól będzie miało właśnie typ Status. Następnie dodamy możliwe akcje do wykonania na książce, które będą zmieniały jej stan.

enum Status {
    Available, Reserved, Rented, Withdrawn
}

@AllArgsConstructor(access = AccessLevel.PRIVATE)
class Book {

    @Getter
    private BookId bookId;
    @Getter(AccessLevel.PACKAGE)
    private Status status;

    static Book createAvailableBook(BookId bookId) {
        return new Book(bookId, Status.Available);
    }

    public void reserve() {
        if (status != Status.Available) {
            throw new IllegalStateException();
        }

        this.status = Status.Reserved;
    }

    public void cancel() {
        if (status != Status.Reserved) {
            throw new IllegalStateException();
        }

        this.status = Status.Available;
    }


    public void rent() {
        if (status != Status.Reserved) {
            throw new IllegalStateException();
        }

        this.status = Status.Rented;
    }

    public void endRental() {
        if (status != Status.Rented) {
            throw new IllegalStateException();
        }
        this.status = Status.Available;
    }

    public void withdraw() {
        if (status != Status.Available) {
            throw new IllegalStateException();
        }
        this.status = Status.Withdrawn;
    }

    public void redo() {
        if (status != Status.Withdrawn) {
            throw new IllegalStateException();
        }
        this.status = Status.Available;
    }
}


Od razu napotykamy pewną trudność. W każdej z metod jesteśmy zmuszeni do wykonania walidacji czy aby na pewno dana książka znajduje się w odpowiednim stanie. Jeśli tak to dopiero wtedy możemy wykonać na niej odpowiednią akcję.

Napiszmy teraz testy weryfikujące działanie naszej implementacji. W celu weryfikacji czy na pewno zrobiliśmy wszystko dobrze niezbędne było dodanie gettera na polu status.

def "should reserve book when is available"() {
    given:
        Book book = Book.createAvailableBook(anyBookId())

    when:
        book.reserve()

    then:
        book.status == Status.Reserved
}

def "should fail when rent not reserved book"() {
    given:
        Book book = Book.createAvailableBook(anyBookId())

    when:
        book.rent()

    then:
        thrown(IllegalStateException.class)
}


Powyższe testy przechodzą, więc wszystko działa prawidłowo. Wychodzi na to, że zadanie zostało wykonane. Jednak w tym miejscu warto zastanowić się co trzeba dodatkowego zrobić w przypadku, gdyby doszedł nowy status o przykładowej nazwie AcceptanceRequired?

Musielibyśmy dodać nowy element do typu wyliczeniowego Status, prawdopodobnie też dodać nową akcję oraz przejrzeć istniejące metody pod kątem walidacji. Czy można w takim razie podejść do tego problemu w inny sposób? Przekonajmy się.


Druga iteracja

Pierwszym pomysłem jest stworzenie interfejsu reprezentującego wszystkie możliwe akcje jakie można wykonać na książce. Następnie każdy element z typu wyliczeniowego Status należy zamienić na klasę implementującą właśnie ten interfejs. W tym momencie powinno nasunąć się następujące pytanie. Czy teraz te nowe klasy będą potrzebowały pola Status? Odpowiedź jest jasna. Oczywiście, że nie.

Warto zaznaczyć, że rezultatem wykonania metody w danej klasie będzie nowy obiekt wybranej implementacja interfejsu Book. To klasa będzie odpowiedzialna za stworzenie swojego nowego stanu. Brzmi to być może trochę zagmatwanie, ale naprawdę takie nie jest. Już pokazuję jak ta koncepcja będzie wyglądała w kodzie. Weźmy na tapet dwa statusy - Available oraz Withdrawn.

interface Book {

    BookId getBookId();

    Book reserve();

    Book cancel();

    Book rent();

    Book endRental();

    Book withdraw();

    Book redo();
}

@AllArgsConstructor(access = AccessLevel.PACKAGE)
class AvailableBook implements Book {

    @Getter
    private final BookId bookId;

    @Override
    public Book reserve() {
        return new ReservedBook(bookId);
    }

    @Override
    public Book cancel() {
        throw new IllegalStateException();
    }

    @Override
    public Book rent() {
        throw new IllegalStateException();
    }

    @Override
    public Book endRental() {
        throw new IllegalStateException();
    }

    @Override
    public Book withdraw() {
        return new WithdrawnBook(bookId);
    }

    @Override
    public Book redo() {
        throw new IllegalStateException();
    }
}

@AllArgsConstructor(access = AccessLevel.PACKAGE)
class WithdrawnBook implements Book {

    @Getter
    private final BookId bookId;

    @Override
    public Book reserve() {
        throw new IllegalStateException();
    }

    @Override
    public Book cancel() {
        throw new IllegalStateException();
    }

    @Override
    public Book rent() {
        throw new IllegalStateException();
    }

    @Override
    public Book endRental() {
        throw new IllegalStateException();
    }

    @Override
    public Book withdraw() {
        throw new IllegalStateException();
    }

    @Override
    public Book redo() {
        return new AvailableBook(bookId);
    }
}

//... ReservedBook i RentedBook


Prawda, że wygląda to znacznie lepiej? Po polu określającym status w jakim znajduje się książka nie zostało nawet wspomnienie. Zamiast tego mamy 4 klasy przedstawiające każdy ze stanów. Zweryfikujmy jak nasz nowy model sprawdzi się w testach.

def "should reserve book when is available"() {
    given:
        Book book = new AvailableBook(anyBookId())

    when:
        Book reservedBook = book.reserve()

    then:
        reservedBook instanceof ReservedBook
}

def "should fail when rent not reserved book"() {
    given:
        Book book = new AvailableBook(anyBookId())

    when:
        book.rent()

    then:
        thrown(IllegalStateException.class)
}


Testy praktycznie nie uległy zmianie, poza asercją w pierwszym teście. Nie ma też potrzeby weryfikowania statusu poprzez getter. Możemy to uczynić przez sprawdzenie czy rezultat danej metody to faktycznie instancja klasy ReservedBook. Daje nam to naprawdę sporą zaletę w postaci tego, że użytkownik nic nie musi wiedzieć o wewnętrznej strukturze klasy. Nie pobiera nic z jej wnętrza, aby zweryfikować czy faktycznie coś uległo zmianie.

Jednak, aby nie było tak kolorowo to zastanówmy się ponownie co by było, gdyby do naszych wymagań doszedł nowy status. Wychodzi na to, że wystarczy dodać tylko kolejną klasę implementującą interfejs Book. Czy to oznacza, że można skończyć w tym miejscu? Niestety, odpowiedź dalej jest przecząca.

Idąc dalej, co by się stało w przypadku dodania nowej akcji? Umieścimy ją w interfejsie Book i teraz każda klasa go implementująca będzie musiała się dostosować do nowego kontraktu. Wychodzi na to, że w jawny sposób zasada Open-Closed Principle wujka Boba została złamana. Pani Barbara Liskov również nie byłaby dumna z naszego rozwiązania. Utworzona przez nią zasada także nie wpisała się w ramy powyższego kodu. Trzeba na nowo się zastanowić czy istnieje jeszcze inna możliwość wyjścia z tej sytuacji.


Trzecia iteracja

A może by tak wyekstrahować część wspólną dla klas, reprezentujących stan książki, do jednego interfejsu? W ten sposób nie musiałby się one dostosować do ogólnego kontraktu i mogłby się pozbyć zbędnych metod. Zobaczmy jak to rozwiązanie będzie wyglądało w praktyce.

interface Book {
    BookId getBookId();
}

@AllArgsConstructor(access = AccessLevel.PACKAGE)
class AvailableBook implements Book {

    @Getter
    private final BookId bookId;

    public ReservedBook reserve() {
        return new ReservedBook(bookId);
    }

    public WithdrawnBook withdraw() {
        return new WithdrawnBook(bookId);
    }
}

@AllArgsConstructor(access = AccessLevel.PACKAGE)
class WithdrawnBook implements Book {

    @Getter
    private final BookId bookId;

    public AvailableBook redo() {
        return new AvailableBook(bookId);
    }
}

//... ReservedBook i RentedBook


Napisany kod jest znacznie krótszy od tych przedstawionych wcześniej. Jest też o wiele czytelniejszy dzięki wyrzuceniu zbędnych metod, które tylko rzucały wyjątki. Spójrzmy jeszcze na testy.

def "should reserve book when is available"() {
    given:
        AvailableBook availableBook = new AvailableBook(anyBookId())

    when:
        Book reservedBook = availableBook.reserve()

    then:
        reservedBook instanceof ReservedBook
}

//def "should fail when rent not reserved book"() {
//    given:
//        AvailableBook availableBook = new AvailableBook(anyBookId())
//
//    when:
//        Book rentedBook = availableBook.rent()
//
//    then:
//        thrown(IllegalArgumentException.class)
//}


Drugi test, weryfikujący możliwość wypożyczenia książki bez uprzedniej rezerwacji, nie kompiluje się. Jest to spowodowane tym, że metoda rent w klasie AvailableBook nie istnieje. Dostaliśmy więc dodatkowe zabezpieczenie całkowicie za darmo! Kompilator chroni nas przed nieprawidłowym wywołaniem akcji w kodzie. Nie ma potrzeby pisania żadnych walidacji czy książka jest aktualnie w prawidłowym stanie, aby coś na niej wykonać.

W przypadku dodania nowego wymagania w postaci nowej akcji należy jedynie wejść do klasy reprezentującej konkretny stan i ją tam zaimplementować. Nie ma potrzeby robienia nic ponad to. Pozostałe klasy nie ulegną żadnym modyfikacją.


Wykorzystanie bilioteki vavr

Jeśli ktoś ma awersję do korzystania z rzutowania w Javie to dla przedstawionego powyżej przypadku można użyć mechanizmu z bilioteki vavr. W ten sposób nasz kod stanie się czytelniejszy. O jakim mechanizmie mowa? Chodzi o klasę Match, która zastąpi nam instrukcję warunkową if oraz switch.

Przekazujemy dany obiekt do dostarczonego przez bibliotekę przyjaznego API, aby potem zweryfikować czy jest to interesująca nas instancja wybranej klasy. Jest to bardzo podobne do tego co robi switch, ale nie ma potrzeby robienia żadnego jawnego rzutowania. Przekonajmy się, jak można wykorzystać to narzędzie.

Book tryToReserve(Book book) {
    return Match(book).of(
            Case($(instanceOf(AvailableBook.class)), AvailableBook::reserve),
            Case($(), () -> book)
    );


Prawda, że czyta się to przyjemnie? Techniczne operacje dzieją się w tle, nie trzeba w tym miejscu się o nic martwić. Tak zdefiniowaną metodę można teraz w łatwy sposób użyć jako jedną z operacji strumienia Stream.


Materiał bonusowy

Można znaleźć jeden minus w powyższym rozwiązaniu. Co w przypadku, gdy dojdzie nam akcja reserve dla innego stanu książki? Trzeba będzie dostosować do tego wymagania każdą operację Match w wielu miejscach. Spróbujmy pójść o krok dalej. Wykorzystajmy zasadę Segragacji Interfejsów w praktyce.

Załóżmy dodatkowo, że na RentedBook można wykonać akcję cancel. W ten sposób mamy możliwość anulowania książki zarezerwowanej jak i wypożyczonej. Skupiając się na tym zachowaniu może warto dodać dedykowany interfejs CancelBookAction?

//... interface Book

interface CancelBookAction {
    AvailableBook cancel();
}

@AllArgsConstructor(access = AccessLevel.PACKAGE)
class ReservedBook implements Book, CancelBookAction {

    @Getter
    private final BookId bookId;

    @Override
    public AvailableBook cancel() {
        return new AvailableBook(bookId);
    }

    public RentedBook rent() {
        return new RentedBook(bookId);
    }
}

@AllArgsConstructor(access = AccessLevel.PACKAGE)
class RentedBook implements Book, CancelBookAction {

    @Getter
    private final BookId bookId;

    @Override
    public AvailableBook cancel() {
        return new AvailableBook(bookId);
    }

    public AvailableBook endRental() {
        return new AvailableBook(bookId);
    }
}

//... AvailableBook i WithdrawnBook


Dzięki takiemu podejście instrukcja Match z biblioteki vavr będzie wyglądała znacznie lepiej, a co ważniejsze, nie ulegnie żadnym modyfikacją przy zmianie wymagań. Gdyby kolejny stan książki mógł być anulowany, to wystarczy, że klasa go reprezentująca implementowałaby interfejs CancelBookAction.

Book tryToCancel(Book book) {
    return Match(book).of(
            Case($(instanceOf(CancelBookAction.class)), CancelBookAction::cancel),
            Case($(), () -> book)
    );
}


Podsumowanie

Celem tego artykułu było przybliżenie Ci możliwości innego zobrazowania pola status w kodzie. W tym miejscu zaznaczę, że jest mój indywidualny punkt widzenia na ten problem. Na pewno istnieją inne możliwości jego rozwiązania. 

Warto na koniec zaznaczyć, że w bazie danych możemy trzymać status książki jako jedną z kolumn tabeli. Na jej postawie nasza warstwa infrastruktury będzie wiedziała jaki obiekt domenowy ma zostać zwrócony. Według mnie to nie jest złe podejście. Są to po prostu dwa oddzielne koncepty. Z powyższym kodem jest po prostu trudniej o pomyłkę lub jakieś niedopatrzenie w rozwiązaniu domenowym.

 
<p>Loading...</p>