Backend

Kotlin. Domain Specific Language w praktyce

kotlin

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.

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

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/kotlin-domain-specific-language-w-praktyce/" order_type="social" width="100%" count_of_comments="8" ]