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żeli 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, iż 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, iż 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 ;-))
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, iż 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):
Jakich rezultatów można się spodziewać, jeżeli przepuścić te klasy przez taką maszynkę?
Dla JavaBean wychodzi to, czego się spodziewamy. Ma on dwie adekwatnoś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, iż 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, iż (prawie) wszystko jest JavaBean, jeżeli 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żeli mamy rekord, na przykład taki:
i chcemy taki rekord przerobić Jacksonem na JSON:
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, iż “Rekordy to nie Java Beans” jeszcze lepiej, niż BeanInfo. Jackson spodziewa się, iż 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żeli 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 manualnie, które będą wywoływać akcesory. Nie polecam.
- Można napisać własny serializator. Ma to sens, jeżeli 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:
Adnotacje, które dodamy do komponentów, “są kopiowane” na akcesory i pola w rekordach. Więc można sobie wyobrazić, iż taki rekord po kompilacji wygląda w środku mniej więcej tak:
“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, iż wtedy “od razu” działa z ekosystemem. Na minus jest to, iż zamiast odciąć gnijącą nogę, podtrzymuje się ją przy życiu. Tyle iż Scala nie ma takiego zasięgu, więc musiała się w Javę wpasować… Czy sama Java musi się wpasować w Javę?
Myślę, iż 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żeli to był Boolean? To wtedy bardziej get czy jednak ciągle is? A jeżeli pole było Boolean wasDelivered, to wtedy getWas..., isWas..., samo was...? Z akcesorami reguła jest banalnie prosta.
Jeszcze o serializacji
Wieść gminna niesie, iż 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 dzięki konstruktora kanonicznego, więc można zaryzykować stwierdzenie, iż Java wraca na ścieżkę OOP.