Kotlin z JPA – rzeczy nieoczywiste

Zgodnie z tradycją kończąc szkolenie, pokazuję coś ekstra. Dziś tym czymś ekstra była implementacja prościutkiego silnika blogowego. Samo zadanie jest „egzaminem końcowym” kursu JPA. Nie tworzymy niczego ambitnego, bo mamy tylko 4 godziny na to zadanie. Uczestnicy mają samodzielnie przygotować klasy, skonfigurować zależności i odpalić całość. Moja wersja różniła się jednak od tej, którą mieli wykonać uczestnicy. Ja pisałem w Kotlinie.

Wprowadzenie

Na stronie Springa jest bardzo krótkie wprowadzenie do pracy z Kotlinem i JPA. Po jego lekturze możemy czuć się „mocni”, bo tak naprawdę nie ma tam nic odkrywczego. Nie do końca.

Nasz model danych składać się będzie z kilku klas:

  • Author – reprezentuje autora bloga. Taki debilny value object.
  • Blog – będzie zawierać nazwę bloga oraz autora.
  • BlogPost – pojedynczy post zawierający datę, treść, odwołanie do bloga.

Do tego mamy jeszcze klasę DomainObject, w której zdefiniujemy zasady nadawania id oraz wersji. Prościej się nie da.

Dodatkowo chciałem pokazać użycie REST Repositories, których opis znajdziecie tu oraz Actuatora, o którym poczytacie tu. Warstwę prezentacji na chwilę obecną olejmy, zrobi się później za pomocą Angulara czy innego Reacta. Przy czym w tym poście skupię się na elementach związanych encjami.

Aplikacja ma pozwalać na zarządzanie autorami, blogami i postami. Wykorzystując REST Repositories, nie będziemy musieli dużo pisać. Podstawowe operacje są już zmapowane na odpowiednie metody HTTP. Dodatkowo oczekujemy od niej, że będzie można wykonywać proste zapytania wykorzystujące Criteria API. Będziemy musieli zatem dopisać jakiś prościutki serwis, który nam to umożliwi.

Zaczynamy

Używając Spring Initializr, czy to z poziomu IDE, czy to za pośrednictwem strony wygenerowałem pakiet startowy. Projekt jest mavenowy. Po zaimportowaniu do Idei spróbowałem go uruchomić i od razu mała niespodzianka:

Listing 1. Co my tu mamy…

Exception in thread "main" java.lang.NoSuchMethodException: com.luxoft.jva014.blog.Jva014BlogApplication.main([Ljava.lang.String;)
	at java.lang.Class.getMethod(Class.java:1786)
	at com.intellij.rt.execution.application.AppMain.main(AppMain.java:126)

Problem, który tu mamy nie wynika z błędu w konfiguracji startera. Ma on swoje źródło w sposobie, w jaki działa Idea. Rzućmy okiem na kod:

Listing 2. Konfiguracja startowa

@SpringBootApplication
class Jva014BlogApplication

fun main(args: Array<String>) {
    SpringApplication.run(Jva014BlogApplication::class.java, *args)
}

Rzecz w tym, że Idea będzie chciała uruchomić klasę z adnotacją @SpringBootApplication. Klasa ta nie posiada metody main, ponieważ ta jest zdefiniowana jako funkcja. Jest to absolutnie prawidłowe zachowanie, ponieważ Kotlin zamiast metod statycznych wprowadza funkcje „globalne”. Funkcja main w wyniku kompilacji trafi do klasy Jva014BlogApplicationKt. Zatem musimy uruchamiać tę klasę. Nie pozostaje nam zatem nic innego jak zgłosić buga. Nie jest to jednak problem, ale raczej ciekawostka.

Jak już uruchomimy naszą aplikację, to mając na pokładzie Actuatora, możemy sprawdzić jej stan. Wystarczy otworzyć localhost:8080/health i bangla.

Encje

Czas coś zaimplementować. Zacznijmy zabawę od DomainObject, która zawiera id i wersję. Implementacja jest banalnie prosta:

Listing 3. Implementacja DomainObject

@MappedSuperclass
class DomainObject(
        @Id var id: UUID = UUID.randomUUID(),
        @Version var version: Int = 0
)

Pominąłem implementację hashCode i equlas, ale ta jest. Pozostałe encje wyglądają podobnie:

Listing 4. Pozostałe encje modelu

@Entity
@AttributeOverrides(
        AttributeOverride(name = "id", column = Column(name = "AUTHOR_ID")),
        AttributeOverride(name = "version", column = Column(name = "AUTHOR_VERSION"))
)
@Table(name = "AUTHOR")
class Author(var name: String = "") : DomainObject()

@Entity
@NamedQuery(name = "findAll", query = "select B from Blog B order by B.name")
@AttributeOverrides(
        AttributeOverride(name = "id", column = Column(name = "BLOG_ID")),
        AttributeOverride(name = "version", column = Column(name = "BLOG_VERSION"))
)
@Table(name = "BLOG")
class Blog(

        @Column(name = "BLOG_NAME")
        val name: String = "",

        @JoinColumn(name = "BLOG_AUTHOR")
        @OneToOne(cascade = arrayOf(CascadeType.ALL))
        var author: Author = Author(),

        @OneToMany(mappedBy = "blog", fetch = FetchType.EAGER, cascade = arrayOf(CascadeType.ALL))
        var posts: MutableList<BlogPost> = ArrayList<BlogPost>()
) : DomainObject()

@Entity
@NamedQuery(name = "findByBlog", query = "select P from BlogPost P where P.blog.id = :id")
@AttributeOverrides(
        AttributeOverride(name = "id", column = Column(name = "POST_ID")),
        AttributeOverride(name = "version", column = Column(name = "POST_VERSION"))
)
@Table(name = "BLOG_POST")
class BlogPost(
        @Column(name = "POST_DATE")
        var date: Date = Date(),

        @Column(name = "POST_TITLE")
        var title: String = "",

        @Lob
        @Basic(fetch = FetchType.EAGER)
        @Column(name = "POST_TEXT")
        var text: String = "",

        @ManyToOne(fetch = FetchType.EAGER)
        @JoinColumn(name = "BLOG_ID")
        var blog: Blog = Blog()
) : DomainObject()

Uwaga. Wysokie stężenie AttributeOverride wynika z założeń zadania, które mieliśmy wykonać. W rzeczywistości można to olać.

Coś jednak tutaj nie pasuje. Zresztą uważny czytelnik dostrzeże, to już na Listingu 2. Klasy w Kotlinie są domyślnie finalne. Tu musimy omówić kilka kwestii. Kiedyś już pisałem o data class w kontekście JPA. DC choć zacne, to do pracy z JPA średnio się nadają. Z jednej strony finalne, a z drugiej nie mogą nic rozszerzać. Świetnie sprawdzą się jako DTO, ale nie pełnoprawne encje. Tu mamy jednak do czynienia ze zwykłą klasą. Jakim cudem mogę użyć takiej klasy jako niefinalnej? Ano mogę. Z pomocą przychodzi mi plugin do kompilatora o wdzięcznej nazwie allopen. Pozwala on na zdefiniowanie listy adnotacji, których użycie na klasie spowoduje wygenerowanie klasy otwartej. Gdy tworzymy projekt za pomocą springowego inicjalizera, to plugin ten zostanie dodany (pod nazwą kotlin-spring) i skonfigurowany tak, by otwierać klasy, które muszą być niefinalne dla springa. My musimy użyć jeszcze innej wersji tego pluginu pod nazwą kotlin-jpa, ale…

I dupa – czas na przesiadkę na gradle

Problem z JPA i Kotlinem polega na tym, że jeżeli używamy Mavena, to nie mamy od ręki dostępu do wielu ważnych rzeczy. Najistotniejszym brakiem jest brak procesora adnotacji, który potrafiłby poradzić sobie z klasami kotlinowymi. Jest co prawda kapt, ale ten działa tylko z gradlem. Tracimy zatem możliwość automatycznego generowania metamodelu. Rykoszetem dostajemy też w przypadku otwierania klas, bo nie możemy użyć kotlin-jpa, bo nie ma wersji na mavena, a musimy ręcznie skonfigurować wszystko w pom.xml.

Przy czym warto zaznaczyć, że problem ten wynika z niedopasowania narzędzia. Kotlin jest zdecydowanie zorientowany gradlowo i trzeba mieć, to na uwadze.

Mapowania

Tworząc mapowania pomiędzy encjami, nie musimy się ograniczać. Jeżeli tylko mamy podpięty plugin allopen, to pracujemy ze zwykłymi kotlinowymi klasami. Ciekawostką jest to, że allopen otwiera też dc. Jednakże próba kompilacji kodu, w którym dc dziedziczą po sobie, skończy się błędem.

Rzućmy jeszcze raz okiem na encję Blog, ponieważ zawiera ona kilka ciekawostek:

Listing 5. Encja Blog

@Entity
@NamedQuery(name = "findAll", query = "select B from Blog B order by B.name")
@AttributeOverrides(
        AttributeOverride(name = "id", column = Column(name = "BLOG_ID")),
        AttributeOverride(name = "version", column = Column(name = "BLOG_VERSION"))
)
@Table(name = "BLOG")
class Blog(

        @Column(name = "BLOG_NAME")
        val name: String = "",

        @JoinColumn(name = "BLOG_AUTHOR")
        @OneToOne(cascade = arrayOf(CascadeType.ALL))
        var author: Author = Author(),

        @OneToMany(mappedBy = "blog", fetch = FetchType.EAGER, cascade = arrayOf(CascadeType.ALL))
        var posts: MutableList<BlogPost> = ArrayList<BlogPost>()
) : DomainObject()

Pierwszą rzeczą na, którą chciałbym zwrócić uwagę, to wykorzystanie typu Author? zamiast Author. Wynika to z zasady generowania konstruktora domyślnego. Jeżeli wszystkie pola w konstruktorze podstawowym są zainicjowane wartościami domyślnymi, to zostanie wygenerowany konstruktor domyślny. Będzie on publiczny. Po drugie możemy używać zarówno var, jak i val. Przy czym trzeba pamiętać, że pole oznaczone jako val będzie finalne. Po trzecie w przypadku mapowania kolekcji musimy użyć MutableList zamiast List. Kolekcje niezmienne w Kotlinie są co do zasady kowariantne, czyli mówiąc językiem kodu:

Listing 6. Lista niezmienna

@OneToMany(mappedBy = "blog", fetch = FetchType.EAGER, cascade = arrayOf(CascadeType.ALL))
var posts: List<BlogPost> = ArrayList<BlogPost>()

Wygeneruje nam:

Listing 7. Lista niezmienna w bytecodzie

Compiled from "Jva014BlogApplication.kt"
public class pl.koziolekweb.blog.Blog extends pl.koziolekweb.blog.DomainObject {
  //...
  private java.util.List<? extends pl.koziolekweb.blog.BlogPost> posts;
  //...
  public java.util.List<pl.koziolekweb.blog.BlogPost> getPosts();
  public void setPosts(java.util.List<? extends pl.koziolekweb.blog.BlogPost>);
  //...
}

Co przy próbie uruchomienia zakończy się klasycznym błędem ze strony Hibernate:

Listing 8. Lista niezmienna w runtime

Caused by: org.hibernate.AnnotationException: Collection has neither generic type or OneToMany.targetEntity() defined: pl.koziolekweb.blog.Blog.posts
	at org.hibernate.cfg.annotations.CollectionBinder.getCollectionType(CollectionBinder.java:694) ~[hibernate-core-5.0.11.Final.jar:5.0.11.Final]
	at org.hibernate.cfg.annotations.CollectionBinder.bind(CollectionBinder.java:488) ~[hibernate-core-5.0.11.Final.jar:5.0.11.Final]
	at org.hibernate.cfg.AnnotationBinder.processElementAnnotations(AnnotationBinder.java:2140) ~[hibernate-core-5.0.11.Final.jar:5.0.11.Final]
	at org.hibernate.cfg.AnnotationBinder.processIdPropertiesIfNotAlready(AnnotationBinder.java:911) ~[hibernate-core-5.0.11.Final.jar:5.0.11.Final]

Oczywiście zmiana kodu z listingu 6 na poniższy:

Listing 9. Lista niezmienna

@OneToMany(mappedBy = "blog", fetch = FetchType.EAGER, cascade = arrayOf(CascadeType.ALL), targetEntity = BlogPost::class)
var posts: List<BlogPost> = ArrayList<BlogPost>()

Nie zmienia nic w bytecodzie, ale za to błąd jest z serii „magicznych”:

Listing 10. Dodanie targetEntity

Caused by: org.hibernate.annotations.common.AssertionFailure: Fail to process type argument in a generic declaration. Member : pl.koziolekweb.blog.Blog#posts Type: class sun.reflect.generics.reflectiveObjects.WildcardTypeImpl
	at org.hibernate.jpa.internal.metamodel.AttributeFactory$PluralAttributeMetadataImpl.getClassFromGenericArgument(AttributeFactory.java:875) ~[hibernate-entitymanager-5.0.11.Final.jar:5.0.11.Final]
	at org.hibernate.jpa.internal.metamodel.AttributeFactory$PluralAttributeMetadataImpl.<init>(AttributeFactory.java:784) ~[hibernate-entitymanager-5.0.11.Final.jar:5.0.11.Final]
	at org.hibernate.jpa.internal.metamodel.AttributeFactory$PluralAttributeMetadataImpl.<init>(AttributeFactory.java:758) ~[hibernate-entitymanager-5.0.11.Final.jar:5.0.11.Final]

Dlaczego? Ponieważ Kotlin wymaga typu KClass (klasy kotlinowej) w miejsce zwykłej javowej dla targetEntity. Jedyną opcją pozostaje zatem użycie MutableList. Swoją drogą jest, to babol samego Kotlina, bo na pałę podkłada własny typ zamiast javowego.

Podsumowanie

Czas na małe podsumowanie. Kotlin z JPA ma sens, ale trzeba zwracać uwagę na pewne pułapki. Na pewno należy używać gradle zamiast mavena. Dzięki temu będziemy mieli lepsze wsparcie ze strony narzędzi około kotlinowych. W tym większy wybór, jeśli chodzi o pluginy kompilatora. Jak pokazuje nam przykład z targetEntity, Kotlin ogranicz nas w pewnych sytuacjach, uniemożliwiając użycie wszystkich elementów JPA.

Za plus należy uznać zwięzłość kodu, którą w Javie uzyskujemy z wykorzystaniem Lomboka.

O czym nie napisałem, ale napiszę, to przede wszystkim REST Repositories i użycie Criteria API. Szczególnie ten drugi temat jest dość rozbudowany, bo język daje nam tu wiele możliwości.

3 myśli na temat “Kotlin z JPA – rzeczy nieoczywiste

  1. „DC choć zacne, to do pracy z JPA średnio się nadają. Z jednej strony finalne, a z drugiej nie mogą nic rozszerzać.”

    Hmm – a tego nie zrozumiałem. Ja zrobiłem u siebie:

    @MappedSuperclass open class Base ( @Id var id: UUID = UUID.randomUUID(), @Version var version: Int = 0 )
    
    @Entity data class Person(var firstName: String = "", var lastName: String = "") : **Base()** 

    I działa to bez problemu. Główną zagwozdką było to, że bez domyślnych wartości Kotlin nie tworzył domyślnego konstruktora a przez to Hibernate krzyczał "No default constructor".

  2. OK. W przypadku zwykłych klas to zadziała. Problem jest z Data Class, które są finalne i nie za bardzo możemy, to zmienić.

Napisz odpowiedź

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax