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.
Podobne artykuły
Krytyczne spojrzenie na kod jest kluczowy dla jego skutecznej analizy. Jak analizować systemy legacy
Sieci neuronowe. PyTorch i praktyczny projekt od początku do końca
Od czego zacząć swoją przygodę w branży IT? Rozmowa z Jackiem Hrynczyszynem, Java Developerem
Kim jest Software Architect? Obowiązki, specjalizacje, kariera
Co nowego w Javie? Przegląd zmian, które przyniosło JDK 20
Efektywne zarządzanie Protocoll Buffers z “Buf”. Wszystko, co powinieneś wiedzieć
Czy Scala to wciąż dobry język dla programistów w 2023 roku?

