Na pewno każdy z nas spotkał się z takimi adnotacjami jak @NotNull czy @NonNull. W teorii mają one zabezpieczyć nas przed tym, aby dana wartość nie mogła przyjąć owianej złą sławą wartości null. Jednak w praktyce bywa z tym różnie. Jedne chronią nas lepiej, inne gorzej. Przyjrzyjmy się zatem dostępnym adnotacją i sprawdźmy co odróżnia je od siebie?

EDIT: Dzięki komentarzowi Marka wiadomo jak zmienić rzucanie NullPointerException przez @NonNull Lomboka. Wystarczy podmienić właściwość lombok.nonNull.exceptionType na np. IllegalArgumentException w pliku konfiguracyjnym lombok.config.

Przegląd asortymentu

Według jednego wpisu na StackOverflow do dyspozycji mamy takie oto opcje:

  • javax.validation.constraints.NotNull
  • edu.umd.cs.findbugs.annotations.NonNull
  • javax.annotation.Nonnull
  • org.jetbrains.annotations.NotNull
  • lombok.NonNull
  • androidx.annotation.NonNull
  • org.eclipse.jdt.annotation.NonNull

Muszę przyznać, że o niektórych z nich nawet nie miałem pojęcia. My jednak w tym artykule skupimy się na tych, z którymi dotychczas jednak miałem styczność. Będzie to javax.validation.constraints.NotNull, czyli adnotacja znajdująca się w specyfikacji JSR 380: Bean Validation 2.0, dzięki której uruchamiają się reguły weryfikujące czy dany element jest prawidłowy. Kolejną jest lombok.NonNull - jak sama nazwa wskazuje należy ona do biblioteki Lomboka. Gdy użyjemy jej w aplikacji to w trakcie kompilacji wygeneruje się kawałek kodu sprawdzający czy dana zmienna przyjmuje wartość null. Na samym końcu zweryfikujemy jeszcze co daje nam adnotacja org.springframework.lang.NonNull.

Na pierwszy ogień idzie…

Pójdziemy po kolei i na tapet jako pierwsze weźmiemy javax.validation.constraints.NotNull. W dokumentacji znajduje się taki oto opis na temat tej adnotacji: “The annotated element must not be null. Accepts any type.”. I to by było tyle. Natomiast szukając głębiej w Internecie dowiemy się czegoś więcej. Jednym z rozwiązań implementujących specyfikację Bean Validation jest Hibernate Validator. Dzięki niemu oraz Springowi walidacja może zostać uruchomiona. Samo nadanie adnotacji @NotNull nad wybranym elementem nie spowoduje, że program po uruchomieniu uchroni nas przed przypisaniem do niego wartości null. Jest to tylko wskazówka dla wybranego procesora co należy z tym fantem zrobić.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package pl.devcezz.notnullexample;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.validation.constraints.NotNull;

@RestController
@RequestMapping("/client")
public class ClientController {

  @PostMapping
  ClientDto validateClient(@RequestBody ClientDto client) {
    return client;
  }
}

record ClientDto(@NotNull String firstname,
         @NotNull String surname,
         @NotNull Integer bankBalance) {
}

Powyższy kontroler przyjmuje jako ciało zapytania ClientDto, którego pola oznaczono jako @NotNull. Wysyłając poniższe zapytanie jasne jest, że wszystko zadziała prawidłowo.

1
2
3
4
5
Request:
curl -X POST http://localhost:8080/client -H 'Content-Type: application/json' -d '{"firstname":"Albert","surname":"Janosik","bankBalance":-1000}'

Response:
{"firstname":"Albert","surname":"Janosik","bankBalance":-1000}

O dziwo jeśli wyślemy zapytanie zawierające same wartości null to również nie wystąpi żaden błąd, a my dostaniemy odpowiedź z wysłanymi danymi.

1
2
3
4
5
Request:
curl -X POST http://localhost:8080/client -H 'Content-Type: application/json' -d '{"firstname":null,"surname":null,"bankBalance":null}'

Response:
{"firstname":null,"surname":null,"bankBalance":null}

Aby rozwiązać problem do dyspozycji mamy dwa rozwiązania - pobrać specjalny walidator z fabryki albo wykorzystać magię Springa. Pierwsze z nich mogłoby wyglądać następująco.

1
2
3
4
5
6
7
8
9
10
@PostMapping("/factory")
ClientDto validateClientWithFactory(@RequestBody ClientDto client) {
  ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
  Validator validator = factory.getValidator();

  validator.validate(client)
      .forEach(System.out::println);

  return client;
}
1
2
3
4
5
6
7
8
9
10
Request:
curl -X POST http://localhost:8080/client/factory -H 'Content-Type: application/json' -d '{"firstname":null,"surname":null,"bankBalance":null}'

Response:
{"firstname":null,"surname":null,"bankBalance":null}

Logs:
ConstraintViolationImpl{interpolatedMessage='nie może mieć wartości null', propertyPath=firstname, rootBeanClass=class pl.devcezz.notnullexample.ClientDto, messageTemplate='{javax.validation.constraints.NotNull.message}'}
ConstraintViolationImpl{interpolatedMessage='nie może mieć wartości null', propertyPath=surname, rootBeanClass=class pl.devcezz.notnullexample.ClientDto, messageTemplate='{javax.validation.constraints.NotNull.message}'}
ConstraintViolationImpl{interpolatedMessage='nie może mieć wartości null', propertyPath=bankBalance, rootBeanClass=class pl.devcezz.notnullexample.ClientDto, messageTemplate='{javax.validation.constraints.NotNull.message}'}

Co prawda program zadziałał prawidłowo, ale to przez naszą dobrą wolę. Jeśli instrukcja validator.validate(client) zwróciłaby niepusty zbiór to moglibyśmy wyrzucić wyjątek. Drugim sposobem jest wykorzystanie adnotacji @Valid.

1
2
3
4
@PostMapping("/valid")
ClientDto validateClientWithValid(@RequestBody @Valid ClientDto client) {
  return client;
}
1
2
3
4
5
6
7
8
Request:
curl -X POST http://localhost:8080/client/valid -H 'Content-Type: application/json' -d '{"firstname":null,"surname":null,"bankBalance":null}'

Response:
{"timestamp":"2022-03-15T18:55:35.637+00:00","status":400,"error":"Bad Request","path":"/client/valid"}

Logs:
Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in pl.devcezz.notnullexample.ClientDto pl.devcezz.notnullexample.ClientController.validateClientWithValid(pl.devcezz.notnullexample.ClientDto) with 3 errors: [Field error in object 'clientDto' on field 'firstname': rejected value [null]; codes [NotNull.clientDto.firstname,NotNull.firstname,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [clientDto.firstname,firstname]; arguments []; default message [firstname]]; default message [nie może mieć wartości null]] [Field error in object 'clientDto' on field 'surname': rejected value [null]; codes [NotNull.clientDto.surname,NotNull.surname,NotNull.java.lang.String,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [clientDto.surname,surname]; arguments []; default message [surname]]; default message [nie może mieć wartości null]] [Field error in object 'clientDto' on field 'bankBalance': rejected value [null]; codes [NotNull.clientDto.bankBalance,NotNull.bankBalance,NotNull.java.lang.Integer,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [clientDto.bankBalance,bankBalance]; arguments []; default message [bankBalance]]; default message [nie może mieć wartości null]] ]

Tym razem Spring zrobił wszystko za nas i zwrócił status 400. Nie pozwolił na wywołanie ciała metody, a w logach umieścił informację o tym dlaczego tak się zadziało. Wychodzi na to, że sama adnotacja javax.validation.constraints.NotNull tak naprawdę nic nie zmienia. Może służyć jako wskazówka, ale tak naprawdę bez procesora bardzo łatwo ją pominąć.

Pomoc przy walidacji pól encji

Warto dodatkowo wspomnieć, że @NotNull z pakietu javax.validation.constraints sprawdza się także przy walidacji encji bazodanowych. Jeśli utworzylibyśmy następującą klasę z walidacją na polach to Hibernate nie pozwoli nam na zapisanie błędnego obiektu. Na potrzeby tego sprawdzenie wyrzućmy @Valid z sygnatury metody w kontrolerze.

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
package pl.devcezz.notnullexample;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;

@Entity
public class Client {

  @GeneratedValue
  @Id
  private Long id;

  @NotNull
  private String firstname;

  @NotNull
  private String surname;

  @NotNull
  private Integer bankBalance;

  //... getters & setters
}
1
2
3
4
5
6
7
8
9
10
11
@PostMapping("/save")
ClientDto saveClient(@RequestBody ClientDto dto) {
  Client client = new Client();
  client.setFirstname(dto.firstname());
  client.setSurname(dto.surname());
  client.setBankBalance(dto.bankBalance());

  clientRepository.save(client);

  return dto;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
Request:
curl -X POST http://localhost:8080/client/save -H 'Content-Type: application/json' -d '{"firstname":null,"surname":null,"bankBalance":null}'

Response:
{"timestamp":"2022-03-15T11:13:12.931+00:00","status":500,"error":"Internal Server Error","path":"/client/save"}

Logs:
javax.validation.ConstraintViolationException: Validation failed for classes [pl.devcezz.notnullexample.Client] during persist time for groups [javax.validation.groups.Default, ]
List of constraint violations:[
	ConstraintViolationImpl{interpolatedMessage='nie może mieć wartości null', propertyPath=bankBalance, rootBeanClass=class pl.devcezz.notnullexample.Client, messageTemplate='{javax.validation.constraints.NotNull.message}'}
	ConstraintViolationImpl{interpolatedMessage='nie może mieć wartości null', propertyPath=firstname, rootBeanClass=class pl.devcezz.notnullexample.Client, messageTemplate='{javax.validation.constraints.NotNull.message}'}
	ConstraintViolationImpl{interpolatedMessage='nie może mieć wartości null', propertyPath=surname, rootBeanClass=class pl.devcezz.notnullexample.Client, messageTemplate='{javax.validation.constraints.NotNull.message}'}
]

Wartość zwróconego statusu wynosi 500, ponieważ to aplikacja wewnątrz nie dopuściła na nieprawidłowy stan danych. Przy okazji chcę Cię na coś uczulić, przyjrzyjmy się następującej sytuacji.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package pl.devcezz.notnullexample;

import javax.persistence.Embeddable;
import javax.validation.constraints.NotNull;

@Embeddable
public class BankBalance {

  @NotNull
  private Integer value;

  public Integer getValue() {
    return value;
  }

  void setValue(Integer value) {
    this.value = value;
  }
}
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
package pl.devcezz.notnullexample;

import javax.persistence.AttributeOverride;
import javax.persistence.AttributeOverrides;
import javax.persistence.Column;
import javax.persistence.Embedded;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;

@Entity
public class Client {

  @GeneratedValue
  @Id
  private Long id;

  @NotNull
  private String firstname;

  @NotNull
  private String surname;

  @Embedded
  @AttributeOverrides(
      @AttributeOverride(name = "value", column = @Column(name = "bankBalance"))
  )
  private BankBalance bankBalance;

  //... getters & setters
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@PostMapping("/save")
ClientDto saveClient(@RequestBody ClientDto dto) {
  Client client = new Client();
  client.setFirstname(dto.firstname());
  client.setSurname(dto.surname());

  BankBalance bankBalance = new BankBalance();
  bankBalance.setValue(dto.bankBalance());
  client.setBankBalance(bankBalance);

  clientRepository.save(client);

  return dto;
}

Jeśli wyniesiemy jedno z pól do osobnej klasy i oznaczymy ją jako @Embeddable to walidacja na tym polu niestety nie zadziała. Aby nie być gołosłownym sprawdźmy to.

1
2
3
4
5
Request:
curl -X POST http://localhost:8080/client/save -H 'Content-Type: application/json' -d '{"firstname":"Albert","surname":"Janosik","bankBalance":null}'

Response:
{"firstname":"Albert","surname":"Janosik","bankBalance":null}

Otrzymaliśmy brak jakiegokolwiek błędu, a nasze żądanie otrzymało prawidłową odpowiedź. Nasuwa się, więc pytanie jak możemy to naprawić. Rozwiązanie jest dosyć proste. Trzeba użyć znaną nam już adnotację @Valid nad polem oznaczonym jako @Embedded.

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
package pl.devcezz.notnullexample;

import javax.persistence.AttributeOverride;
import javax.persistence.AttributeOverrides;
import javax.persistence.Column;
import javax.persistence.Embedded;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;

@Entity
public class Client {

  @GeneratedValue
  @Id
  private Long id;

  @NotNull
  private String firstname;

  @NotNull
  private String surname;

  @Embedded
  @AttributeOverrides(
      @AttributeOverride(name = "value", column = @Column(name = "bankBalance"))
  )
  @Valid
  private BankBalance bankBalance;

  //... getters & setters
}
1
2
3
4
5
6
7
8
9
10
11
Request:
curl -X POST http://localhost:8080/client/save -H 'Content-Type: application/json' -d '{"firstname":"Albert","surname":"Janosik","bankBalance":null}'

Response:
{"timestamp":"2022-03-15T11:31:01.415+00:00","status":500,"error":"Internal Server Error","path":"/client/save"}

Logs:
javax.validation.ConstraintViolationException: Validation failed for classes [pl.devcezz.notnullexample.BankBalance] during persist time for groups [javax.validation.groups.Default, ]
List of constraint violations:[
	ConstraintViolationImpl{interpolatedMessage='nie może mieć wartości null', propertyPath=bankBalance.value, rootBeanClass=class pl.devcezz.notnullexample.Client, messageTemplate='{javax.validation.constraints.NotNull.message}'}
]

Pora na Lomboka

Lombok również posiada podobną adnotację o nazwie @NonNull. Jak przeczytamy na oficjalnej stronie tej biblioteki ta instrukcja pozwoli procesorowi Lomboka wygenerować za nas sprawdzenie czy dane pole nie jest null. Wygląda to mniej więcej w ten sposób.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package pl.devcezz.notnullexample;

import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;

import static lombok.AccessLevel.PACKAGE;

@Setter(PACKAGE)
@Getter
public class CardDto {

  @NonNull
  private String cardNumber;
  @NonNull
  private ClientDto owner;

  public CardDto(@NonNull String cardNumber, @NonNull ClientDto owner) {
    this.cardNumber = cardNumber;
    this.owner = owner;
  }
}

Aby istniało zabezpieczenie przed podaniem wartości null musimy oznaczyć adnotacją wybrane parametry w konstruktorze. To wygeneruje sprawdzenie w postaci instrukcji if na początku wybranego konstruktora. Natomiast jeśli również chcemy zabezpieczyć w ten sposób settery klasy to należy umieścić tą samą adnotację nad polami. Po tych wszystkich zabiegach wygenerowany kod przez Lomboka wygląda następująco.

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
46
47
48
49
50
51
52
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package pl.devcezz.notnullexample;

import lombok.NonNull;

public class CardDto {
  @NonNull
  private String cardNumber;
  @NonNull
  private ClientDto owner;

  public CardDto(@NonNull String cardNumber, @NonNull ClientDto owner) {
    if (cardNumber == null) {
      throw new NullPointerException("cardNumber is marked non-null but is null");
    } else if (owner == null) {
      throw new NullPointerException("owner is marked non-null but is null");
    } else {
      this.cardNumber = cardNumber;
      this.owner = owner;
    }
  }

  void setCardNumber(@NonNull final String cardNumber) {
    if (cardNumber == null) {
      throw new NullPointerException("cardNumber is marked non-null but is null");
    } else {
      this.cardNumber = cardNumber;
    }
  }

  void setOwner(@NonNull final ClientDto owner) {
    if (owner == null) {
      throw new NullPointerException("owner is marked non-null but is null");
    } else {
      this.owner = owner;
    }
  }

  @NonNull
  public String getCardNumber() {
    return this.cardNumber;
  }

  @NonNull
  public ClientDto getOwner() {
    return this.owner;
  }
}

Rodzi się pytanie - “przed czym nas to w sumie chroni?”. Przecież i tak dostaniemy wyjątek NullPointerException. Jak dla mnie jest to przypadek fail fast, w którym wartość null nie rozleje nam się po całej aplikacji. Możemy dzięki temu zrobić zabezpieczenie na wejściu do naszej domeny i np. gdy w bazie danych znajdą się nieprawidłowe dane to zweryfikujemy czy przypadkiem otrzymana wartość nie ma ma wartości null. Jak widać ta adnotacja różni się od javax.validation.constraints tym, że nie jest leniwa, od razu robi sprawdzenie. Nie potrzebuje do działania żadnych mechanizmów poza procesorem generującym Lomboka. Jednak jej działanie nie akumuluje naruszeń jak ma to miejsce w przypadku pierwszej adnotacji.

Ostatnia adnotacja

Na sam koniec zostawiłem sobie org.springframework.lang.NonNull. Ta adnotacja powstała na podstawie specyfikacji JSR 305: Annotations for Software Defect Detection, której głównym zadaniem jest ustandaryzowanie adnotacji do stosowania ich w aplikacjach Javy, aby wspierać narzędzia do wyłapywania defektów w oprogramowaniu. Według artykułu na DZone JSR 305 został uznany za standard pomimo jego wielu błędów. Sam autor poleca stosowanie go do sprawdzenia wartości null, ale tylko do momentu gdy nie zostanie wynalezione lepsze rozwiązanie do zaadoptowania przez Kotlina i wiele środowisk programistycznych. Skoro mowa o IDE to właśnie tylko w tym miejscu znalazłem zastosowanie adnotacji org.springframework.lang.NonNull.

Jeżeli dodamy nowy konstruktor w klasie CardDto przyjmujący dodatkowy parametr i oznaczymy go opisywaną wyżej adnotacją to IDE pokaże nam ostrzeżenie o treści “Passing ‘null’ argument to parameter annotated as @NotNull”. Oczywiście będzie to również miało odzwierciedlenie w postaci podświetlonia na odpowiedni kolor, aby zasygnalizować, że próbujemy przypisać wartość null do pola, które nie powinno przyjmować takiej wartości.

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
package pl.devcezz.notnullexample;

import lombok.Getter;
import lombok.NonNull;
import lombok.Setter;

import static lombok.AccessLevel.PACKAGE;

@Setter(PACKAGE)
@Getter
public class CardDto {

  @NonNull
  private String cardNumber;
  @NonNull
  private ClientDto owner;
  private Integer CVV;

  //...

  public CardDto(@NonNull String cardNumber, @NonNull ClientDto owner, @org.springframework.lang.NonNull Integer CVV) {
    this.cardNumber = cardNumber;
    this.owner = owner;
    this.CVV = CVV;
  }
}

org.springframework.lang.NonNull wspomoże nas w informowaniu o nieprawidłowych przypisaniach wartości null
org.springframework.lang.NonNull wspomoże nas w informowaniu o nieprawidłowych przypisaniach wartości null

Nie będziemy jednak w żaden sposób chronieni. Dostaniemy tylko i wyłączenie ostrzeżenie w naszym IDE (np. IntelliJ) o nieprawidłowym przypisaniu. Niestety nie odnotowałem innego zastosowania tej adnotacji, więc skoro wiesz coś więcej w tym temacie to podziel się proszę swoją wiedzą w komentarzu pod artykułem.

Podsumowanie

W artykule przedstawiłem tylko kroplę z morza dostępnych adnotacji zabezpieczających nas przed wyjątkiem NullPointerException. Mam nadzieję, że ta garść informacji choć trochę zachęciła Cię do dalszych poszukiwań oraz weryfikacji.

Cały kod znajdziesz tutaj: https://github.com/cezarysanecki/code-from-blog