Diversity w polskim IT
Semyon Kirekov
Semyon KirekovFullstack Software Engineer @ MTS Group

Sposoby na uniknięcie problemów z Java Optional

Poznaj błędne przypadki użycia Optional w Javie i sprawdź, jak radzić sobie z takimi problemami.
10.04.20229 min
Sposoby na uniknięcie problemów z Java Optional

NullPointerException to jedna z najbardziej irytujących rzeczy, jakie można spotkać w Javie. Na ratunek przybywa nam na szczęście Optional. Nie można jednak jednoznacznie stwierdzić, czy NullPointerException zniknęło na dobre, ale myślę, że wiele tutaj dokonaliśmy. 

Wiele popularnych bibliotek i frameworków ma Optional w swoim ekosystemie. Na przykład, repozytoria Spring Data zwracają Optional<Entity> zamiast null

Myślę, że niektórzy programiści są już tak podekscytowani Optional, że zaczęli go trochę za dużo używać i korzystać z niego we wzorcach, w których nie powinni tego robić. Wymienię tutaj niektóre takie błędne przypadki użycia i pokażę mój sposób na poradzenie sobie z tymi problemami. 

Optional nigdy nie może być nullem

Wydaje mi się, że nie trzeba tutaj wiele tłumaczyć. Przypisywanie Optional do null sprawi, że klasa ta nie będzie mieć sensu. 

Żaden klient Twojego API nie sprawdzi, czy Optional jest równe null. Zamiast tego powinieneś użyć Optional.empty().

Poznaj API

Ponieważ wydano nowe wersje Javy, Optional rozszerzono o nowe funkcje, które pozwalają pisać mniej rozwlekły kod. 

public String getPersonName() {
  Optional<String> name = getName();
  if (name.isPresent()) {
    return name.get();
  }
  return "DefaultName";
}

Optional.isPresent

Pomysł jest całkiem prosty - jeśli nazwa jest pusta, zwróć domyślną wartość. Można to trochę ładniej napisać.  

public String getPersonName() {
  Optional<String> name = getName();
  return name.orElse("DefautName");
}

Optional.orElse

Stwórzmy coś bardziej skomplikowanego. Załóżmy, że musimy zwrócić Optional imienia jakiejś osoby. Jeśli dane imię zawiera się w zestawie dozwolonych imion, to wartość powinna być obecna, w przeciwnym razie, nie będzie. 

Oto trochę rozwlekły przykład: 

public Optional<String> getPersonName() {
  Person person = getPerson();
  if (ALLOWED_NAMES.contains(person.getName())) {
    return Optional.ofNullable(person.getName());
  }
  return Optional.empty();
}

Optional name

Optional.filter sprawi, że powyższy kod będzie wyglądał o wiele lepiej. 

public Optional<String> getPersonName() {
  Person person = getPerson();
  return Optional.ofNullable(person.getName())
                 .filter(ALLOWED_NAMES::contains);
}

Optional name - lepsze podejście

Właściwie to powyższa rada ma zastosowanie w całym procesie developmentu. 

Jeśli w danym języku istnieje już rozwiązanie danego problemu, to postaraj się z niego skorzystać, zamiast tworzyć swoje własne.

Alternatywy oparte na wartościach są lepsze, niż te ogólne

Mamy również specjalne klasy Optional, które nie są ogólne. OptionalInt, OptionalLong oraz OptionalDouble

Jeśli musisz działać z prymitywnymi typami danych, lepiej jest użyć kontenerów opartych na wartościach. W takim przypadku nie mamy żadnych dodatkowych procedur związanych z opakowywaniem, które mogłyby wpłynąć na wydajność.

Nie lekceważ lenistwa

Optional.orElse to wygodne podejście do zwracania wartości domyślnych. Jeśli obliczenie wartości domyślnej jest kosztowne, to może to spowodować problemy z wydajnością. 

public Optional<Table> retrieveTable() {
  return Optional.ofNullable(constructTableFromCache())
                 .orElse(fetchTableFromRemote());
}

Gorsza wydajność przy użyciu Optional

Nawet jeśli mamy daną tabelę w cache, to będzie ona za każdym razem pobierana z zewnętrznego serwera. Ten problem można jednak prosto rozwiązać za pomocą Optional.orElseGet

public Optional<Table> retrieveTable() {
  return Optional.ofNullable(constructTableFromCache())
                 .orElseGet(this::fetchTableFromRemote);
}

Wydajnie działające Optional

Optional.orElseGet akceptuje lambdę, która będzie obliczana tylko, jeśli zastosuje się ją w pustym kontenerze.

Nie twórz Optional z kolekcji 

Nie widziałem czegoś takiego zbyt często - no ale zdarza się. 

public Optional<List<String>> getNames() {
  if (isDevMode()) {
    return Optional.of(getPredefinedNames());
  }
  try {
    List<String> names = getNamesFromRemote();
    return Optional.of(names);
  }
  catch (Exception e) {
    log.error("Cannot retrieve names from the remote server", e);
    return Optional.empty();
  }
}

Optional listy

Jakakolwiek kolekcja jest kontenerem, którego pustkę można określić bez dodatkowych klas. 

public List<String> getNames() {
  if (isDevMode()) {
    return getPredefinedNames();
  }
  try {
    return getNamesFromRemote();
  }
  catch (Exception e) {
    log.error("Cannot retrieve names from the remote server", e);
    return emptyList();
  }
}

Pusta lista - lepsze podejście

Niepotrzebne klasy Optional sprawiają, że z danym API za ciężko się pracuje.

Nie przekazuj Optional jako parametru

Tutaj zaczniemy mówić o najbardziej dyskusyjnych kwestiach, jakie poruszę w tym artykule. 

Dlaczego nie powinniśmy przekazywać Optional jako parametru? Na pierwszy rzut oka wydaje się to logiczne - pomaga w uniknięciu niespodziewanych wystąpień NullPointerException. Być może tak jest, ale problem jednak sięga głębiej. 

public void doAction() {
  OptionalInt age = getAge();
  Optional<Role> role = getRole();
  applySettings(name, age, role);
}

Parametry Optional

Po pierwsze, API posiada niepotrzebne ograniczenia. Użytkownik musi przy każdym wywołaniu opakowywać wartości w Optional, nawet jeśli mówimy tu o znanych, stałych wartościach. 

Po drugie, applySettings ma 4 potencjalne zachowania, oparte na tym, że Optional jest obecny. Nie jest to niestety zgodne z zasadą pojedynczej odpowiedzialności. 

No i finalnie nie wiemy, jak nasza metoda interpretuje Optional. Czy zamienia nieobecną wartość wartością domyślną? A może całkowicie zmienia logikę biznesową? A może po prostu wyrzuci NoSuchElementException?

Jeśli spojrzymy na Optional w dokumentacji Javy, to znajdziemy coś takiego:

Optional można używać jako metody na zwracanie typu, kiedy zachodzi wyraźna potrzeba reprezentacji “no result” oraz tam, gdzie korzystanie z null może spowodować wiele błędów. 

Optional jest dosłowną definicją obiektu possible no result. Przekazywanie possible no result w danej metodzie nie brzmi jak dobry pomysł. Oznacza to, że API wie za dużo o kontekście i podejmuje decyzje, których nie powinno być w stanie podjąć ze względu na brak odpowiedniej wiedzy. 

Jak możemy to poprawić? Jeśli age oraz role zawsze muszą być obecne, to możemy wyrzucić Optional z parametrów i obsłużyć jego nieobecność wysokopoziomowo. 

public void doAction() {
  OptionalInt age = getAge();
  Optional<Role> role = getRole();
  applySettings(name, age.orElse(defaultAge), role.orElse(defaultRole));
}

Wyeliminowane parametry Optional

Kod, którego używamy do wywoływania, kontroluje wartości argumentów - jest to jeszcze ważniejsze, gdy rozwijamy framework lub jakąś bibliotekę. 

Albo przeciwnie, jeśli age i role będzie można pominąć, to takie podejście nie zadziała. W takim przypadku najlepszym sposobem będzie deklaracja oddzielnych metod dla różnych potrzeb użytkowników. 

void applySettings(String name) { ... }
void applySettings(String name, int age) { ... }
void applySettings(String name, Role role) { ... }
void applySettings(String name, int age, Role role) { ... }

Podzielona metoda ‘applySettings’

Może to wyglądać trochę rozwlekle, ale użytkownik ma teraz możliwość robienia dokładnie tego, co musi zrobić, unikając niespodziewanych błędów.

Nie używaj Optional jako pól klas

Słyszałem wiele opinii na ten temat. Niektórzy myślą, że przechowywanie Optional w klasach pozwala na bezpośrednią redukcję NullPointerException i to w znacznym stopniu. 

Mam kolegę, który pracuje w znanym startupie. Mówi on, że takie podejście jest w jego firmie uznawane za wzorzec. Inni jednak sądzą, że psuje to całą koncepcję monady. 

Pomimo że przechowywanie Optional bezpośrednio w klasie brzmi dobrze, to wydaje mi się jednak, że przyniesie więcej problemów, niż korzyści. 

Brak serializacji

Optional nie implementuje interfejsu Serializable. Nie jest to żaden błąd - zostało to zrobione ręcznie, ponieważ Optional zaprojektowano jako typ zwracany. A zatem klasa, która ma jakiekolwiek pole Optional nie może być serializowana.  

Wydaje mi się, że to najmniej przekonujący element tej dyskusji. W nowoczesnym świecie, w którym istnieją systemy rozproszone i serializacja oparta na platformach mikroserwisów, nie ma to aż takiego znaczenia, jak kiedyś. 

Zatrzymywanie niepotrzebnych referencji 

Optional to obiekt, którego użytkownik potrzebuje przez kilka milisekund. Potem można go usunąć za pomocą garbage collectora. 

Jeśli jednak zatrzymamy Optional jako pole klasy, to można je tam przechować do momentu zatrzymania działania programu. W małym projekcie możliwe problemy z działaniem będą jednak niezauważalne. 

Jeśli mówimy o dużej aplikacji z dużą liczbą beanów, to może to doprowadzić do różnych problemów. 

Słaba integracja ze Spring Data/Hibernate

Załóżmy, że budujemy prostą aplikację Spring Boot - musimy wyciągnąć wartości z tablicy. Można to prosto uzyskać za pomocą deklarowania danej encji i odpowiadającego mu repozytorium.

@Entity
@Table(name = "person")
public class Person {
    @Id
    private long id;

    @Column(name = "firstname")
    private String firstName;

    @Column(name = "lastname")
    private String lastName;
    
    // constructors, getters, toString, and etc.
}

public interface PersonRepository extends JpaRepository<Person, Long> {
}

Prosta encja z repozytorium

Poniżej możliwy wynik personRepository.findAll().

Person(id=1, firstName=John, lastName=Brown)
Person(id=2, firstName=Helen, lastName=Green)
Person(id=3, firstName=Michael, lastName=Blue)


Nie chcemy dać ciała z NullPointerException, więc zamieniamy proste pole na deklaracje używające Optional.

@Entity
@Table(name = "person")
public class Person {
    @Id
    private long id;

    @Column(name = "firstname")
    private Optional<String> firstName;

    @Column(name = "lastname")
    private Optional<String> lastName;
    
    // constructors, getters, toString, and etc.
}

Encja z polami Optional

Teraz to już wszystko się popsuło.

org.hibernate.MappingException: Could not determine type for: java.util.Optional, at table: person, for columns: [org.hibernate.mapping.Column(firstname)]


Hibernate nie może mapować wartości z bazy danych na Optional.

Jest też trochę rzeczy, które jednak działają

Muszę przyznać, że nie wszystko jest takie złe - niektóre frameworki dobrze zintegrowały się z Optional. 

Jackson

Zadeklarujemy prosty punkt końcowy oraz DTO. 

public class PersonDTO {
  private long id;
  private String firstName;
  private String lastName;
  // getters, constructors, and etc.
}

PersonDTO

@GetMapping("/person/{id}")
public PersonDTO getPersonDTO(@PathVariable long id) {
    return personRepository.findById(id)
            .map(person -> new PersonDTO(
                    person.getId(),
                    person.getFirstName(),
                    person.getLastName())
            )
            .orElseThrow();
}

Prosty punkt końcowy

Oto wynik GET /person/1.

{
  "id": 1,
  "firstName": "John",
  "lastName": "Brown"
}


Jak widać, nie mamy tutaj żadnej konfiguracji. Wszystko jest od razu gotowe do działania. Spróbujmy zastąpić String przy pomocy Optional<String>

public class PersonDTO {
  private long id;
  private Optional<String> firstName;
  private Optional<String> lastName;
  // getters, constructors, and etc.
}

PersonDTO razem z Optionals

Aby przetestować kilka przypadków, zastąpiłem jedno zadanie przy pomocy Optional.empty()

@GetMapping("/person/{id}")
public PersonDTO getPersonDTO(@PathVariable long id) {
    return personRepository.findById(id)
            .map(person -> new PersonDTO(
                    person.getId(),
                    Optional.ofNullable(person.getFirstName()),
                    Optional.empty()
            ))
            .orElseThrow();
}

Punkt końcowy z wieloma Optional

Wszystko cały czas dobrze działa, co mnie zaskakuje. 

{
  "id": 1,
  "firstName": "John",
  "lastName": null
}

W Spring Web możemy więc bezpiecznie użyć Optional jako wartości pola, prawda? I tak i nie - jest kilka szczególnych przypadków. 

SpringDoc

SpringDoc to biblioteka aplikacji SpringBoot, która generuje Open Api Schema w sposób automatyczny. 

Oto, co mamy w związku z punktem końcowym GET /person/{id}

"PersonDTO": {
  "type": "object",
  "properties": {
    "id": {
      "type": "integer",
      "format": "int64"
    },
    "firstName": {
      "type": "string"
    },
    "lastName": {
      "type": "string"
    }
  }
}


Musimy tutaj jednak uczynić właściwość id obowiązkową. Można to zrobić używając @NotNull albo @Schema(required = true)

Dodajmy jeszcze kilka ciekawych szczegółów. Co, jeśli umieścimy @NotNull w polu Optional?

public class PersonDTO {
  @NotNull
  private long id;
  @NotNull
  private Optional<String> firstName;
  private Optional<String> lastName;
  // getters, constructors, and etc.
}

PersonDTO z obowiązkowym id

Może to dać ciekawe rezultaty. 

"PersonDTO": {
  "required": [
    "firstName",
    "id"
  ],
  "type": "object",
  "properties": {
    "id": {
      "type": "integer",
      "format": "int64"
    },
    "firstName": {
      "type": "string"
    },
    "lastName": {
      "type": "string"
    }
  }
}


Jak widać, id jest polem wymaganym. Tak jak firstName.  Zaczyna się robić ciekawie. Optional nie może być wymagane, ponieważ cały sens Optional polega na tym, że daną wartość można pominąć. Tak czy siak, trochę popsuliśmy framework, używając zaledwie jednej adnotacji. 

W czym więc problem? Na przykład, jeśli część frontendowa korzysta z jakiegokolwiek generatora typów opartego na Open Api Schema, to popsułoby to format danych i doprowadziło do nieprzyjemnych konsekwencji.

Rozwiązanie

Co możemy na to poradzić? Odpowiedź jest prosta - używaj Optional tylko dla getterów.

public class PersonDTO {
    private long id;
    private String firstName;
    private String lastName;
    
    public PersonDTO(long id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }
    
    public long getId() {
        return id;
    }
    
    public Optional<String> getFirstName() {
        return Optional.ofNullable(firstName);
    }
    
    public Optional<String> getLastName() {
        return Optional.ofNullable(lastName);
    }
}

PersonDTO z getterami Optional

Teraz klasy tej można spokojnie użyć jako encji DTO albo Hibernate. Optional nie ma żadnego wpływu na dane - opakowuje wartości null, aby obsłużyły nieobecne dane. 

Jest jednak jeden szkopuł - tego podejścia nie da się w pełni zintegrować z Lombok. Gettery Optional nie są obsługiwane przez tę bibliotekę. Nie ma też co liczyć na to, żeby się to zmieniło. A przynajmniej może się tak wydawać, patrząc na niektóre dyskusje na GitHubie

Napisałem kiedyś artykuł o Lombok i uważam, że to wspaniałe narzędzie. Fakt, że nie jest zintegrowane z wzorcem Optional-Getter jest nieprzyjemny. 

Jedynym sposobem na obejście tej przeszkody jest definiowanie getterów manualnie.

Podsumowanie

To wszystko, co chciałem powiedzieć o java.util.Optional. Wiem, że jest to kwestia mocno sporna. Daj znać, co myślisz, w komentarzach!

<p>Loading...</p>