Jak używamy Kotlina z wykorzystaniem biblioteki Exposed
Przy okazji nowych projektów w TouK staramy się wypróbowywać nowinki technologiczne. Dobieramy technologie najlepiej pasujące do dziedziny problemu, nawet jeśli nie są jeszcze popularne na rynku. Pierwszy raz zastosowaliśmy język Kotlin w połowie 2016 roku, jeszcze w wersji 1.0.2. Wiedzieliśmy, że staje się on popularny językiem wśród deweloperów Androida, ale nie było zbyt głośno o użyciu Kotlina w backendzie, a już na pewno próżno było szukać doświadczeń z systemów wdrożonych produkcyjnie.
Piotr Jagielski. Senior Developer w TouK, interesuje się poszukiwaniem nowych możliwości pozwalających przełamać monotonię projektów w Java/Spring/Hibernate. Od czterech lat poznaje świat Clojure, od trzech śledzi możliwości wykorzystania Kotlina po stronie backendu. W wolnym czasie składa muzykę w Clojure i gra w Battlefield.
Po przejrzeniu kilku projektów “Hello world” na GitHubie, m.in. tego bardzo użytecznego artykułu Sebastiana Deleuze, postanowiliśmy dać szansę Kotlinowi w projekcie dla nowego MVNO. Projekt zakładał stworzenie głównie API RESTowego dla aplikacji mobilnej, integracji z kilkoma usługami zewnętrznymi (czat, smsy, płatności) i kilku zadań działających w tle, dotyczących cyklu życia subskrypcji klienta. Czuliśmy, że Kotlin może wnieść nieco świeżości do kodu, będzie przyjemny dla deweloperów, jednocześnie podobało nam się podejście “nie wymyślania na nowo koła” i użyciu wielu części ekosystemu Javy/JVMa, z których byliśmy zadowoleni w innych projektach (Spring, JUnit, Gradle).
Spis treści
Dlaczego Exposed?
Od początku mieliśmy przekonanie, że połączenie Kotlina i JPA/Hibernate nie będzie w pełni bezproblemowe. Funkcyjne elementy Kotlina wraz z naturalnym wsparciem dla niemutowalnych struktur danych (data klasy) nie mogły do końca dobrze współpracować z pełnoprawnym ORM zbudowanym jeszcze przed rewolucją Javy 8. Artykuł Sebastiena podsunął nam inne rozwiązanie — lekką bibliotekę Exposed, utrzymywaną przez twórców Kotlina, firmę JetBrains. Od początku spodobały nam się główne założenia Exposed:
- nie dążyć do tego, aby być pełnym frameworkiem typu ORM,
- dwa warianty — typowany DSL tłumaczony na zapytania SQL lub lekka warstwa DAO w stylu ActiveRecord z Rails,
- lekka biblioteka, bez użycia refleksji,
- brak kroku pośredniego z generowaniem kodu,
- integracja ze Springiem,
- brak konieczności dodawania adnotacji na klasach domenowych (w podejściu SQL DSL),
- łatwo rozszerzalna (np. elementy GIS i dodawanie nowych dialektów baz danych).
TL;DR
Jeśli chcesz zobaczyć w jaki sposób używamy tandemu Kotlin + Exposed w naszych projektach, zobacz to repozytorium na GitHubie. Jest to aplikacja Spring Boot implementująca API klona Medium, wyspecyfikowanego na stronie https://realworld.io (“The mother of all demo apps”).
SQL DSL
W naszych projektach postanowiliśmy skorzystać z wariantu ze statycznie typowanym DSL’em przekształcającym się na zapytania SQL. W tym podejściu nie ma konieczności modyfikacji klas domenowych, potrzebne jest dodanie jawnego mapowania klas na schemat bazy danych w postaci kodu Kotlina (konfiguracja jako kod):
data class User( val username: Username, val password: String, val email: String ) object UserTable : Table("users") { val username = text("username") val email = text("email") val password = text("password") }
I możemy już wtedy używać otypowanych, odpornych na null-e zapytań SQL z prostym mapowaniem na nasze klasy domenowe:
UserTable.select { UserTable.username eq username }?.toUser() // lub UserTable.select { UserTable.username like username }.map { it.toUser() } fun ResultRow.toUser() = User( username = this[UserTable.username], email = this[UserTable.email], password = this[UserTable.password] )
Identyfikatory Ref
Staramy się w naszych projektach praktykować architektury z elementami DDD. W tym podejściu sprawdza się posiadanie we wspólnym kodzie statycznie typowanych identyfikatorów Ref obiektów, których używamy do komunikowania się pomiędzy kontekstami.
Zamiast używać wszędzie identyfikatorów jako typów long i string, opakowujemy je w proste klasy (np. UserId, ArticleId) i używamy jako pola w obiektach domenowych i parametry w serwisach. Exposed pozwala łatwo zarejestrować własne typy kolumn do mapowania tego typu klas, możemy wręcz stworzyć generyczne rozwiązanie (WrapperColumnType) korzystające z interfejsu.
Używając tej techniki możemy zmodyfikować nasze mapowanie w ten sposób:
sealed class UserId : RefId<Long>() { object New : UserId() { override val value: Long by IdNotPersistedDelegate<Long>() } data class Persisted(override val value: Long) : UserId() { override fun toString() = "UserId(value=$value)" } } data class User( val id: UserId = UserId.New, //... ) fun Table.userId(name: String) = longWrapper<UserId>(name, UserId::Persisted, UserId::value) object UserTable : Table("users") { val id = userId("id").primaryKey().autoIncrement() //... }
Mapowanie relacji
Jednym z mocnych punktów frameworków ORM jest to jak łatwo posługiwać się relacjami pomiędzy encjami. Potrzeba tylko oznaczyć odpowiednie pole w klasie jedną z adnotacji @OneToOne lub @OneToMany i możemy już ściągnąć cały graf obiektów za pomocą jednego wywołania API. W teorii — całkiem przyjemna idea, jednak (jak zwykle) w praktyce nie zawsze jest tak różowo. Nie będę tu wchodził w szczegóły, w zamian odsyłam do kilku fragmentów e-booka “Opinionated JPA with Querydsl” (1, 2)
W podejściu SQL DSL w Exposed obsługa relacji jest w gestii programisty — jeśli w ogóle zdecyduje on, że chce daną relację odzwierciedlić w hierarchii klas (o tym trochę później). Spójrzmy na przykład artykułu (Article) i jego tagów (Tag) z domeny naszego modelowego projektu. Mamy tu relację wiele-do-wielu, więc potrzebujemy w bazie dodatkowej tabeli łączące (article_tags):
object ArticleTagTable : Table("article_tags") { val tagId = tagId("tag_id").references(TagTable.id) val articleId = articleId("article_id").references(ArticleTable.id) }
Gdy tworzymy artykuł, podpinamy do niego wszystkie powiązane tagi poprzez wstawienie do tabeli article_tags par złożonych z nowo wygenerowanego identyfikatora artykułu i przekazanych identyfikatorów tagów (zakładamy, że zapisywanie samych tagów zostało wykonane wcześniej):
override fun create(article: Article): Article { val savedArticle = ArticleTable.insert { it.from(article) } .getOrThrow(ArticleTable.id) .let { article.copy(id = it) } savedArticle.tags.forEach { tag -> ArticleTagTable.insert { it[ArticleTagTable.tagId] = tag.id it[ArticleTagTable.articleId] = savedArticle.id } } return savedArticle }
Ciekawą częścią jest mapowanie artykułu z tagami w metodach odpytujących — w specyfikacji API artykuł zawsze zwracany jest ze wszystkimi tagami — musimy więc “dociągnąć” tagi, najlepiej w jednym zapytaniu, przy użyciu leftJoin:
val ArticleWithTags = (ArticleTable leftJoin ArticleTagTable leftJoin TagTable) override fun findBy(articleId: ArticleId) = ArticleWithTags .select { ArticleTable.id eq articleId } .toArticles() .singleOrNull()
Po takim połączeniu będziemy mieli jeden rekord dla każdej pary artykuł-tag, musimy więc pogrupować je po identyfikatorze artykułu (ArticleId) i zbudować poprawny obiekt Article, poprzez stopniowe wypełnianie kolekcji tagów:
fun Iterable<ResultRow>.toArticles(): List<ResultRow> { return fold(mutableMapOf<ArticleId, Article>()) { map, resultRow -> val article = resultRow.toArticle() val tagId = resultRow.tryGet(ArticleTagTable.tagId) val tag = tagId?.let { resultRow.toTag() } val current = map.getOrDefault(article.id, article) map[article.id] = current.copy(tags = current.tags + listOfNotNull(tag)) map }.values.toList() }
Taka implementacja pozwala nam na obsłużenie wszystkich przypadków:
- brak artykułów (fold zwróci pustą mapę),
- artykułu bez tagów (wartość tag jest null’em, więc lista stworzona przez listOfNotNull jest pusta),
- artykuły z wieloma tagami (najpierw wstawiamy do mapy artykuł z jednym tagiem, przy kolejnych rekordach dodajemy tagi poprzez metodę copy).
Co prawda implementacja nie jest trywialna i powtarzanie jej przy każdej relacji byłoby uciążliwe, należy jednak zastanowić się kiedy właściwie potrzebujemy wyciągnąć cały graf zależności wraz z obiektem. Dla tagów ma to sens, ponieważ zawsze pobieramy tagi z artykułem, a ilość tagów dla pojedynczego artykułu nie będzie zbyt duża. Zastanówmy się, co z komentarzami?
Potencjalnie może ich być dużo i pewnie nie chcielibyśmy wyciągać za każdym razem artykułu w wszystkimi komentarzami. Zamiast tego wolelibyśmy użyć jakiegoś rodzaju stronicowania, bądź nawet pobierania komentarzy w relacji rodzic-dziecko (odpowiedzi). Wtedy bardziej pasuje mapowanie tylko jedną stroną — każdy komentarz ma pole ArticleId prowadzące to odpowiedniego artykułu, a API naszego repozytorium mogłoby wystawiać metody:
fun findAllBy(articleId: ArticleId): List<Comment> // or fun findAllByPaged(articleId: ArticleId, pageRequest: PageRequest): Page<Comment>
Rozszerzalność
Bibliotek Exposed została zaprojektowana z dużym naciskiem na możliwość rozszerzania, które jest dodatkowo ułatwione przez wsparcie Kotlina — np. extension methods. Istnieje możliwość zdefiniowania własnych typów kolumn lub wyrażeń, np. dedykowanych dla dodatku PostGIS dla bazy danych Postgres (typ Point i funkcje na nim operujące), co zostało pokazane we wspomnianym artykule Sebastiana Delezue. Wykorzystaliśmy podobne rozszerzenia również w naszym projekcie (wybór punktów zawartych w okręgu o zadanym promieniu). Ponadto byliśmy w stanie łatwo zaimplementować wsparcie dla kolumn DateTime z Javy 8 — standardowo Exposed obsługuje typy z Joda-time, generyczne rozwiązanie dla różnych bibliotek obsługi daty/czasu znajduje się na roadmapie.
Większym wyzwaniem była implementacja nowego dialektu bazy danych — w pewnym momencie projektu klient postanowił zmienić bazę na Oracle, który w tamtym czasie nie był wspierany przez Exposed. Stworzyliśmy pull-request z podstawowym wsparciem dla Oracle 12c, z którego nawet przez pewien czas korzystaliśmy na produkcji (następnie znowu wróciliśmy do Postgresa…). Sama implementacja była dość trywialna, szczególnie dzięki interfejsom DataType- i FunctionProvider, które w łatwy sposób pozwalają dostarczyć elementy najbardziej rozbieżne pomiędzy różnymi dialektami baz danych (funkcje i typy danych).
Podsumowanie
Nasze doświadczenia z developmentem aplikacji z wykorzystaniem tandemu Kotlin + Exposed są bardzo pozytywne. Jeśli nie planujecie w projekcie bezpośredniego mapowania wielu relacji, chcecie tylko użyć prostych data klas połączonych przez identyfikatory Ref, to podejście działa bardzo dobrze. Samej bibliotece Exposed przydałaby się może bardziej szczegółowa dokumentacja, pokazująca jak rozwiązać praktycznie różne problemy; ponadto zostało w niej zastosowanych kilka denerwujących mechanizmów (np. zarządzanie transakcjami przez thread-local — zmiana podejścia jest na roadmapie), ale gorąco rekomendujemy spróbowania jej w Waszym kolejnym projekcie!
Artykuł został pierwotnie opublikowany na touk.pl. Zdjęcie główne artykułu pochodzi z unsplash.com.