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:
1 2 3 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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.
1 2 3 4 |
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:
1 2 3 4 |
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:
1 2 3 4 5 |
val config = pipelineConfiguration { id = "Example ID" source = "Input" ... } |
Funkcja wyższego rzędu
A teraz przeanalizujmy kluczowy fragment z perspektywy tworzenia DSL:
1 |
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.:
1 |
() -> Unit |
I przykład użycia prezentowałby się następująco:
1 2 3 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
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.
Podobne artykuły

Efektywne zarządzanie Protocoll Buffers z “Buf”. Wszystko, co powinieneś wiedzieć

Czy Scala to wciąż dobry język dla programistów w 2023 roku?

Qt – wszystko, co powinieneś wiedzieć na temat najpopularniejszego frameworka C++

Jak tworzyć dokumentację? Doświadczenia Software Engineera

Odczarowujemy PHP - fakty, mity i plotki

GraphQL to ciekawostka w cv czy must have?

Zarobki w IT za granicą. Na jaką pensję może liczyć dev w Niemczech, Austrii, Szwajcarii i Holandii?
