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 enum
em, 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.