Wybór języka

Rekordy w Javie - refleksja

Robi się ciekawie

Na okoliczność wydania Javy 14 widziałem parę dyskusji przebiegających mniej więcej takim stylu:

  • O, rekordy w Javie, fajnie, w końcu mamy automatycznie generowane settery i gettery!
  • Nie, rekordy w Javie to POJOs bez setterów…
  • A, okay… Fajnie, w końcu mamy w Javie generowane Beany, ale bez setterów!

I teraz muszę napisać dwie chyba najważniejsze rzeczy, jeśli chodzi o rekordy w Javie. Po pierwsze:

Rekordy to nie JavaBeans

oraz po drugie:

Rekordy to nie JavaBeans

Osoby wstrząśnięte i zmieszane znajdą wytłumaczenie tej przedziwnej zagwozdki poniżej.

Nowości w klasie

W poprzednim wpisie o rekordach widać było, że rekordy trochę przypominają typy wyliczeniowe wprowadzone w Javie 5. Podobnie jak enum dziedziczy bezpośrednio z java.lang.Enum i nic z niego nie może dziedziczyć, tak rekordy dziedziczą bezpośrednio z java.lang.Record i też nie można z nich dziedziczyć.

Jedną z różnic jest to, że enum to słowo kluczowe, a record (podobnie jak var) już nie. (Nie skupiaj się, proszę, na kolorowaniu składni Javy w przeglądarce, wtyczka nie obsługuje jeszcze rekordów ;-))

//to nie zadziała
var enum = ...
//to zadziała
var record = ...

Wprowadzenie rekordów przyniosło też dwie nowe metody w klasie Class.

Tak jak można sprawdzić, czy obiekt jest enumem, wykorzystując metodę Class.isEnum(), tak samo można sprawdzić, czy obiekt jest rekordem, wywołując Class.isRecord().

A gdy już wywołanie metody isRecord() powie nam, że to rekord, to może chcemy sprawdzić jakie te komponenty faktycznie są. Możemy je dostać w formie tablicy wywołując public RecordComponent[] Class.getRecordComponents(). Działa to analogicznie do pobierania pól, metod idp. Oczywiście, mając już w rękach RecordComponent, możemy macać dalej wkoło zębem, jakie są dalsze możliwości. Najbardziej intrygującą jest chyba getAccessor(), która pozwala nam pobrać wartość tego komponentu.

Zwróćmy uwagę na nazwę tej metody. To nie jest getter, to jest accessor. Tzw. gettery zaczynają się zgodnie z konwencją od get (ew. is).

Opis nasionka

Pójdźmy dalej tym tropem. W wędrówce będą towarzyszyć nam dwie klasy. “Klasyczny” bean BeanWithSetters oraz rekord ReflectionCheck. Wyglądają one tak (cały kod na GitHubie):

record ReflectionCheck(int a, String b) {}

class BeanWithSetters {

	private final UUID id;
	private String stringField;

	public BeanWithSetters(UUID id, String stringField) {
		this.id = id;
		this.stringField = stringField;
	}

	public UUID getId() {
		return id;
	}

	public String getStringField() {
		return stringField;
	}

	public void setStringField(String stringField) {
		this.stringField = stringField;
	}

}

Jakich rezultatów można się spodziewać, jeśli przepuścić te klasy przez taką maszynkę?

BeanInfo beanInfo = Introspector.getBeanInfo(object.getClass());
Stream.of(beanInfo.getPropertyDescriptors()).forEach(System.out::println);

Dla JavaBean wychodzi to, czego się spodziewamy. Ma on dwie właściwości (poza klasą), z czego jedna ma tylko getter (read method), druga ma setter (write method) i getter:

java.beans.PropertyDescriptor[name=class; values={expert=false; visualUpdate=false; hidden=false; enumerationValues=[Ljava.lang.Object;@5ce65a89; required=false}; propertyType=class java.lang.Class; readMethod=public final native java.lang.Class java.lang.Object.getClass()]
java.beans.PropertyDescriptor[name=id; values={expert=false; visualUpdate=false; hidden=false; enumerationValues=[Ljava.lang.Object;@51521cc1; required=false}; propertyType=class java.util.UUID; readMethod=public java.util.UUID BeanWithSetters.getId()]
java.beans.PropertyDescriptor[name=stringField; values={expert=false; visualUpdate=false; hidden=false; enumerationValues=[Ljava.lang.Object;@1b4fb997; required=false}; propertyType=class java.lang.String; readMethod=public java.lang.String BeanWithSetters.getStringField(); writeMethod=public void BeanWithSetters.setStringField(java.lang.String)]

A co się stanie, gdy podobnej analizie poddać rekord?

java.beans.PropertyDescriptor[name=class; values={expert=false; visualUpdate=false; hidden=false; enumerationValues=[Ljava.lang.Object;@5ce65a89; required=false}; propertyType=class java.lang.Class; readMethod=public final native java.lang.Class java.lang.Object.getClass()]

Jedyną rzeczą, która działa jak w JavaBean, jest pobieranie klasy rekordu. Komponenty z deklaracji rekordu nie tworzą nam getterów. Rekord to nie jest Java Bean, c. k. d.

Co z tego wynika

To, że rekordy nie są JavaBeans, ma swoje konsekwencje. Java 14 nie powstała w próżni. Stoi za nią 25 lat rozwoju, tworzenia ekosystemu, wiele bibliotek i frameworków, wielu programistów korzysta z niej każdego dnia. Słowem: to jest potężna masa, która odziedziczyła po przodkach podejście, że (prawie) wszystko jest JavaBean, jeśli są tam w środku jakieś dane.

A tu pojawiają się rekordy, które idealnie służą do przechowywania danych, i nie mają getterów. Takie VO, DTO, getterów brak. Nieźle, co?

I niektóre biblioteki mogą na razie ich nie wspierać w idealny sposób. (Stąd bardzo dobra moim zdaniem koncepcja preview features, żeby się wszyscy mogli otrząsnąć i dopasować).

Weźmy na warsztat Jacksona w wersji 2.10.3 (cały kod na GitHubie). Jeśli mamy rekord, na przykład taki:

record SerializationRecordCheck(String justOneField) {}

i chcemy taki rekord przerobić Jacksonem na JSON:

new ObjectMapper().writeValueAsString(new SerializationBeanCheck("Bean"))

to dostaniemy w twarz takim pięknym wyjątkiem:

Exception in thread "main" com.fasterxml.jackson.databind.exc.InvalidDefinitionException: 
No serializer found for class SerializationRecordCheck and 
no properties discovered to create BeanSerializer 
(to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)

Te cztery linijki mogą tłumaczyć to, że “Rekordy to nie Java Beans” jeszcze lepiej, niż BeanInfo. Jackson spodziewa się, że do serializacji przekazany zostanie jakiś JavaBean, czyli obiekt, którego klasa ma jakieś metody zaczynające się na get/is poza getClass(). A takich metod w rekordzie brak!

Docelowo to najprawdopodobniej Jackson (i inne biblioteki) będą wykorzystywały metodę isRecord() i będą (de)serializować rekordy inaczej niż JavaBeans. Już teraz to robią z enumami.

A jak poradzić sobie teraz, jeśli bardzo chcemy już teraz mieć rekordy i pojawiają się one na krawędziach naszego sześciokąta?

Oczywiście najprawdopodobniej nie chcemy ustawiać flagi SerializationFeature.FAIL_ON_EMPTY_BEANS, bo raczej zależy nam na danych z rekordu.

  • Można napisać własne gettery ręcznie, które będą wywoływać akcesory. Nie polecam.
  • Można napisać własny serializator. Ma to sens, jeśli za wszelką cenę nie chcemy paskudzić kodu domenowego adnotacjami.
  • Można też dodać adnotację @JsonProperty do komponentów.

Ostatnie rozwiązanie w kodzie wygląda tak:

record SerializationRecordCheck(@JsonProperty String justOneField) {}

Adnotacje, które dodamy do komponentów, “są kopiowane” na akcesory i pola w rekordach. Więc można sobie wyobrazić, że taki rekord po kompilacji wygląda w środku mniej więcej tak:

@JsonProperty 
private final String justOneField;

@JsonProperty 
public String justOneField() {
    return justOneField;
}

“AAA, widzisz, źle zrobili rekordy, biblioteka nie działa!!1jeden”. Dwa pytania:

  • Czy enumy albo typy generyczne też od razu działały we wszystkich bibliotekach?
  • Czy ogon ma machać psem?

Podobny problem był w Scali, gdy case classes były wykorzystywane przez jakieś biblioteki Javy, które spodziewały się getterów. W Scali, żeby pojawił się też getter, składowym trzeba dodać adnotacje @BeanProperty. Trudno w tej chwili orzec, czy to lepsze, czy gorsze podejście. Na plus na pewno zasługuje to, że wtedy “od razu” działa z ekosystemem. Na minus jest to, że zamiast odciąć gnijącą nogę, podtrzymuje się ją przy życiu. Tyle że Scala nie ma takiego zasięgu, więc musiała się w Javę wpasować… Czy sama Java musi się wpasować w Javę?

Myślę, że w temacie braku getterów i wykorzystania akcesorów najlepiej wypowiedział się autor JEPów, p. B. Goetz: sznurek do e-maila.

A Ty co myślisz?

Mnie się raczej podoba, ale ja nie żyję samą Javą, więc do koncepcji “braku getterów” przywykłem już dawno. I w zasadzie mógłbym tylko westchnąć: “No, wreszcie…” Poza bardzo trafną oceną architektów Javy, ja widzę dodatkowy zysk, a mianowicie: akcesory upraszczają życie jeszcze bardziej. Nazywanie rzeczy to jedna z tych dwóch najtrudniejszych koncepcji w IT. Teoretycznie gettery, jak sama nazwa wskazuje, miały mieć przedrostek get. Ale dla boolean mógł jeszcze być is. I już mniej zadbane rozwiązania wykładały się na tym (bo “biegunka regeksowa”). A jeśli to był Boolean? To wtedy bardziej get czy jednak ciągle is? A jeśli pole było Boolean wasDelivered, to wtedy getWas..., isWas..., samo was...? Z akcesorami reguła jest banalnie prosta.

Jeszcze o serializacji

Wieść gminna niesie, że parę rzeczy mielibyśmy w Javie już wcześniej, gdyby nie “ta *** serializacja”. Rekordy można serializować tak samo jak inne obiekty, wystarczy zaimplementować interfejs Serializable. Format binarny jest ciut inny, ale to już JVM sobie z tym radzi. Dla dociekliwych kod do uruchomienia.

Natomiast doszło do dużej zamiany z zakresie DEserializacji. Rekordy są deserializowane za pomocą konstruktora kanonicznego, więc można zaryzykować stwierdzenie, że Java wraca na ścieżkę OOP.

Wybór języka