Kotlin. Domain Specific Language w praktyce
Na początku warto by było sobie wyjaśnić, co oznacza ten enigmatyczny skrót – „DSL”. DSL (Domain Specific Language) czyli język dziedzinowy, dedykowany. Jak wskazuje rozwinięcie skrótu, DSL ma się sprawdzić bardzo dobrze w jednej konkretnej domenie i dziedzinie problemów. Z reguły DSL nie jest tak rozbudowany jak języki programowania ogólnego zastosowania.
Zapytacie – po co komu język, który ma być użyteczny tylko do jednej domeny? Przecież mamy mnóstwo języków, które są ogólnego przeznaczenia i pozwalają na napisanie wszystkiego, czego sobie zapragniemy.
Michał Sieniawski. BE Developer ze Stepwise. Programista Kotlin w Stepwise. Choć nie boi sobie również ubrudzić rąk zajmując się DevOpsem bądź Cloudem. Jeśli znajdzie dużą sumę pieniędzy kupi dom w górach i zajmie się hodowlą owiec. Oczywiście jeśli będzie tam szybki internet. W końcu ktoś musi udostępniać obrazy Debiana i robić relaye Tora.
Oczywiście, że możemy w nich napisać wszystko. Natomiast to, że można nie oznacza, że w każdym z zastosowań będą się sprawdzały świetnie, szczególnie jeśli chodzi o łatwość wyrażania wymagań. Weźmy tu za przykład komunikację z bazami danych. Bez wątpienia do komunikacji z bazami danych można by było, i jest to wykorzystywane, użyć języków ogólnego przeznaczenia w postaci, chociażby Javy, czy Pythona. Natomiast temu, komu przyszło kiedykolwiek tego dokonać, wie jak szybko pragnął wrócić do SQLa. To jest właśnie przykład języka zaprojektowanego tylko i wyłącznie do jednej domeny i sądząc, po jego historii idzie mu to świetnie. Co więcej, nawet mógłbym się pokusić o stwierdzenie, że będzie mu to szło świetnie jeszcze przez długi, długi czas.
Spis treści
Języki wewnętrzne i zewnętrzne
W tym miejscu należy jeszcze rozróżnić jedną kluczową kwestię jeśli chodzi o języki konkretnej domeny, otóż można wyróżnić ich dwa rodzaje:
- zewnętrzne – języki, które mają swoją własną składnię – dwa najpopularniejsze to, przytoczony chwilę temu, SQL oraz CSS,
- wewnętrzne – języki, które nie mają swojej odrębnej składni. Ich składania oparta jest o składnię języka, za pomocą którego się go definiuje, a celem istnienia jest poprawa czytelności. W świecie JVM-a najczęściej mamy z nimi do czynienia pod postacią tzw. fluent interface, prezentujących się następująco:
Asercja z zastosowaniem biblioteki AssertJ
:
assertThat(fellowshipOfTheRing).hasSize(9) .contains(frodo, sam) .doesNotContain(sauron);
Oczywiście nic nie stoi na przeszkodzie, żeby z takich konstrukcji korzystać w dowolnym języku. Natomiast dzisiaj zajmiemy się typowo programowaniem w Kotlinie i wewnętrznym Domain Specific Language.
Kotlin i DSL – tutorial
I to jest dobry moment, aby płynnie przejść do tutorialu i kodu. W końcu jak lepiej nauczyć się jakiegoś pojęcia jak nie zacząć go praktykować. Załóżmy, że mamy takie wymagania biznesowe: mamy wiele różnych kolejek i musimy zdefiniować konfigurację komunikacji między nimi na takiej zasadzie, że jedna kolejka przyjmuje wiadomość, następnie sprawdza wśród swojej listy odbiorców czy któryś z nich akceptują taką wiadomość i jeśli tak, to wysyła.
Konfiguracja
Konfiguracja może tutaj mieć następujące informacje: id flow, nazwę kolejki źródłowej, miejsca docelowe. Każde z miejsc docelowych ma swoją własną listę procesorów, które jak nazwa sugeruje, manipulują danymi wejściowymi według wskazanej kolejności. Tutaj zastrzeżenie – jest to oczywiście tylko przykład i w rzeczywistym programie wyglądałoby to najprawdopodobniej zdecydowanie inaczej.
Gdybyśmy mieli tę konfigurację przestawić za pomocą fluent interface w Kotlinie. Mogłaby się prezentować w następujący sposób:
val config = PipelineConfiguration() .id("Example ID") .from("Input") .to( broadcast( Destination() .id("Output 1") .processedBy(single(Processor.MAP_TO_XML)), Destination() .id("Output2") .processedBy( inOrder( Processor.VALIDATE, Processor.MAP_TO_JSON ) ) ) ) .allowAnonymousAccess()
Konfiguracja za pomocą DSLa
Teraz kiedy już wiemy, jak wygląda postać ze „standardowego” podejścia możemy utworzyć analogiczną konfigurację za pomocą DSLa w Kotlinie:
val config = pipelineConfiguration { id = "Example ID" source = "Input" destination { name = "Output1" processor(Processor.MAP_TO_XML) } destination { name = "Output2" processor(Processor.VALIDATE) processor(Processor.MAP_TO_JSON) } allowAnonymousAccess = false }.build()
Nie zamierzam tutaj nikogo przekonywać odnośnie tego, która z wersji jest czytelniejsza. Oczywiste jest, że jest to subiektywna sprawa. Natomiast jedną rzeczą, którą chciałbym, tu podkreślić jest to, że obie wersje są bardzo czytelne. Nawet bez spoglądania w jakiekolwiek wymagania możemy się naprawdę sporo domyśleć odnośnie logiki. ”Nadmiarowe” słowa w pierwszym przykładzie: broadcast
, single
, inOrder
pomimo tego, że nie są, tam niezbędne w mojej opinii tylko poprawiają czytelność.
Kotlin, tworzenie DSLi – wyjaśnienie
Teraz kiedy widzieliśmy obie wersje, możemy przejść do wyjaśnienia, jakie mechanizmy języka umożliwiają Kotlinowi tworzenie DSL.
fun pipelineConfiguration(init: PipelineConfiguration.Builder.() -> Unit): PipelineConfiguration = PipelineConfiguration.Builder() .apply(init) .build()
Wyjaśnienia odnośnie składni:
Dozwolone jest pominięcie nawiasów klamrowych w przypadku metod, które posiadają w swoim ciele jedną instrukcję. Wówczas w miejscu otwierającego ciało metody nawiasu klamrowego używa się znaku równa się i oczywiście pomija się zamykający nawias klamrowy. Dodatkowo kiedy zastosuje się taki styl, można również pominąć definicję zwracanego typu przez metodę. Co skutkowałoby takim zapisem:
fun pipelineConfiguration(init: PipelineConfiguration.Builder.() -> Unit) = PipelineConfiguration.Builder() .apply(init) .build()
Natomiast nie jest to coś, czego używamy na co dzień w projektach. Ustaliliśmy wewnętrzną zasadę, że w przypadku gdy chcemy skorzystać ze skróconego zapisu i pominąć nawiasy zawsze definiujemy, jaki jest typ zwracany przez metodę. Z naszego doświadczenia jawne definiowanie typu przy ciele metody poprawia czytelność kodu.
Kotlin pozwala definiować funkcje jako top level function więcej o nich można przeczytać tu – a, w skrócie mówiąc: są to funkcje niezawierające się w żadnej klasie. pipelineConfiguration
jest tego przykładem.
W momencie, w którym ostatnim argumentem funkcji jest funkcja wyższego rzędu, tak jak to jest w pipelineConfiguration
język zachęca nas do tego, abyśmy przy jej wywołaniu zdefiniowali ciało funkcji argumentu w następujący sposób:
val config = pipelineConfiguration { id = "Example ID" source = "Input" ... }
Funkcja wyższego rzędu
A teraz przeanalizujmy kluczowy fragment z perspektywy tworzenia DSL:
init: PipelineConfiguration.Builder.() -> Unit
Przede wszystkim jest to funkcja wyższego rzędu (ang. higher order function) – czyli taka funkcja, która jest przekazywana do innej funkcji jako argument. Po więcej informacji o nich odsyłam do pierwszego artykułu z serii o Kotlinie, który jest poświęcony właśnie nim.
Dla nas w tym momencie kluczowe będzie to jak się je definiuje np.:
() -> Unit
I przykład użycia prezentowałby się następująco:
fun doSomething(something: () -> Unit) { operation() }
“Ale! ale!” pomyślicie sobie teraz. “Ta definicja różni się od tej powyżej – tutaj nie ma typu i kropki przed ()
”. I to jest bardzo trafne spostrzeżenie. Otóż tam definiujemy to, że funkcja wywoływana będzie na konkretnym typie. Prześledźmy to na innym przykładzie:
data class Person( var age: Int = 20 ) val increaseAge: Person.(Int) -> Unit = { //this jest obiektem typu Person this.age = this.age + it } val person = Person() person.increaseAge(30) println(person.age) //Pokaże 50
Teraz definicja funkcji pipelineConfiguration
powinna być jasna.
Dla pełni zrozumienia konceptu zaprezentuję jeszcze klasę pipelineConfiguration
:
data class PipelineConfiguration( val id: String, val source: String, val allowAnonymousAccess: Boolean, val destinations: List<Destination> ) { class Builder { lateinit var id: String lateinit var source: String private val destinations: MutableList<Destination> = mutableListOf() var allowAnonymousAccess: Boolean = false fun destination(init: Destination.Builder.() -> Unit) { val destination = Destination.Builder() .apply(init) .build() this.destinations.add(destination) } fun build(): PipelineConfiguration = PipelineConfiguration( id = id, source = source, destinations = destinations, allowAnonymousAccess = allowAnonymousAccess, ) } }
Definiujemy klasę, która jest typu data
(więcej na ten temat tutaj) zawiera ona w sobie klasę Builder
, której pola wypełnialiśmy w przykładzie z kolejkami. Mamy dodatkowo jeszcze metodę build
, która przepisuje wartości z buildera do właściwej zwracanej klasy. Dzięki czemu końcowy obiekt jest immutable.
Destination
I oczywiście zostaje nam jeszcze jedna klasa do analizy – Destination.
data class Destination( val name: String, val processors: List<Processor> ) { class Builder { lateinit var name: String private val processors: MutableList<Processor> = mutableListOf() fun build(): Destination { if (ANONYMIZE) { this.processors.add(0, Processor.ANONYMIZE) } return Destination( name = this.name, processors = this.processors ) } fun processor(value: Processor) { this.processors.add(value) } } }
Zasadniczo jest ona bardzo podobno do klasy PipelineConfiguration
natomiast posiada ona kilka smaczków, przy których warto by było spędzić chwilę.
Zacznijmy od tego, że wykorzystano tutaj zmienną konfiguracyjną w metodzie build
o nazwie ANONYMIZE
. Możemy o niej myśleć jako o zmiennej globalnej, której wczytywana jest z jakiejś konfiguracji. W momencie, w którym ta flaga zmienia się na true
dodajemy do listy procesorów w pierwszym etapie walidację.
Drugą rzeczą, na którą warto spojrzeć, w tej klasie jest metoda o nazwie processor
. Sama w sobie nie jest zbytnio ciekawa, natomiast pokazuje, że można również używać nazw metod do modyfikacji nazw, które DSL zmienia. W tym przypadku zmienna w klasie Destination
nazywa się processors
, co nie byłoby najlepszą nazwą do odwoływania się w DSL. Wobec czego tworzymy metodę o nazwie processor
, która jest dużo bardzo adekwatniejsza do zastosowania.
Zastosowanie lateinit var
powoduje, że pole staje się wymaganym, gdyż w przypadku jego niewypełnienia i wywołania metody build()
rzucony zostanie Null Pointer Exception.
Kotlin DSL tutorial – podsumowanie
I w zasadzie to już wszystko, co jest nam potrzebne do definiowania DSL w Kotlinie. Jak się przekonaliśmy, nie jest zbyt skomplikowana, żeby nie powiedzieć wręcz prosta. A w ramach zaznajomienia się z częścią językową zachęcam do napisania jakiegoś prostego przykładu DSL, dzięki któremu z pewnością znacznie szybciej i lepiej zrozumie się prezentowane powyżej idee.
Podsumujmy sobie, czego się dowiedzieliśmy.
Tworząc DSL w Kotlinie, niejako tworzymy własny język, który w jednym zastosowaniu – jednej domenie, ma się sprawdzić zdecydowanie lepiej niż Kotlin. Chodzi tutaj o rozbudowane, drzewiaste struktury, przy których stosowanie fluent interface mogłoby się okazać nieczytelne. Dodatkowo zyskujemy tutaj separację między definicją konfiguracji a tym, do czego jest ona później wykorzystywana. Co zdecydowanie wpływa na utrzymywalność kodu.
Zdjęcie główne artykułu pochodzi z unsplash.com.