Kotlin DSL dla Arkuszy Google

Michał Staśkiewicz

Większość programistów specjalizuje się w jednym lub dwóch spośród wielu języków programowania ogólnego przeznaczenia takich jak JavaC lub PHP. Może to „oddzielać” ich od firm, stanowisk, projektów czy zespołów, z którymi chcieliby podjąć współpracę. Istnieją jednak języki powszechnie używane w branży – i nie mówię tu o angielskim. W końcu każdy programista musi czasami zdefiniować wzorzec sprawdzania poprawności tekstu, wysłać zapytania do bazy danych lub skonfigurować skrypt budowy. Służą do tego regexpSQL i Maven/Gradle. Nie ma jednak ofert pracy na stanowiska takie jak programista regex, inżynier Gradle czy guru PlantUML. Czy to znaczy, że technologie te są lukrem składniowym i w każdej chwili możemy się ich bezboleśnie pozbyć?

Jeden język to za mało

Chcesz odpowiedzieć twierdząco na pytanie ze wstępu? Zastanów się dwa razy, jak nakłonić programistę Java do wdrożenia weryfikacji e-mailowej bez użycia wyrażeń regularnych. W końcu musi on jedynie przekonwertować na Javę to wyrażenie regularne:

/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*
 @(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/g

Powodzenia! 😜 Może jesteś też na tyle szalony, aby poprosić inżyniera C# o zapytanie bazodanowe do Microsoft SQL Server bez użycia wyrażeń SQL? Nie ma takiej możliwości! Skuteczne spełnianie wymagań oprogramowania często wymaga wykorzystania więcej niż jednego języka programowania. Zwykle rozwiązanie opiera się na języku ogólnego przeznaczenia w połączeniu z językami rozwiązującymi niektóre zadania specyficzne dla domeny.

DSL? Czy zamierzasz pochwalić się swoim zabytkowym routerem?

Wbrew pozorom nie mam na myśli urządzenia bezprzewodowego, a język dziedzinowy DSL (ang. domain-specific language). Na co dzień wykorzystujemy go do rozwiązywania konkretnych problemów, np. odpytywania baz danych, wyszukiwania dopasowań w tekście czy budowy opisów procesów. Poniżej wymieniam tylko kilka z naprawdę wielu języków dziedzinowych.

  1. PlantUML
  2. sed
  3. HTML
  4. BPEL
  5. XPath
  6. Make
  7. LaTeX
  8. MATLAB

W tym artykule chciałbym pokazać, jak możemy szybko zaprojektować własny, niestandardowy DSL dla domeny świata rzeczywistego. Dzięki elastyczności Kotlin i jego specyficznemu zestawowi funkcji to zadanie powinno być tzw. bułką z masłem.

Problem

Był czas, kiedy musiałem generować arkusze kalkulacyjne Excel z kodu Java/Kotlin. Niestety, spowodowało to wykładniczy przyrost kodu spaghetti po pojawieniu się każdego nowego wymagania biznesowego. Byłem wtedy całkiem blisko napisania własnego DSL jako zamiennika obecnego kodu. Na szczęście natknąłem się na całkiem schludną bibliotekę, która uwolniła mnie od tego zadania. Implementacja kolejnej biblioteki ExcelDSL okazała się wówczas bezsensowna. Obecnie, pchany ambicjami, zdecydowałem się opracować bibliotekę równoważną Arkuszom Google DSL o wymyślnej nazwie Arkusze DSL.

Oczekiwany rezultat

Ponieważ DSL dla tego samego problemu domeny można zaprojektować w zupełnie inny sposób, chciałbym zastosować się do jednej konkretnej implementacji. Wybieram więc, jako mój punkt odniesienia, prosty przykład z biblioteki ExcelDSL. Po małej poprawce chciałbym używać mojej biblioteki SheetsDSL w następujący sposób:

val spreadsheet: Spreadsheet = spreadsheet {
    sheet {
        row {
            cell("Hello")
            cell("World!")
        }
        row(2)
        row {
            emptyCell(3)
            cell("Here!")
        }
    }
}

Po otwarciu tego arkusza kalkulacyjnego w przeglądarce…

Desktop.getDesktop().browse(URI.create(spreadsheet.spreadsheetUrl))

powinniśmy spodziewać się zupełnie nowego dokumentu w naszej przestrzeni osobistej:

Do dzieła

Inicjalizacja projektu

Podążaj za tym przewodnikiem.

  1. Utwórz prosty projekt z pomocą kreatora IntelliJ

Dodaj zależności Google do pliku build.gradle.kts:

// ....
implementation("com.fasterxml.jackson.core:jackson-databind:2.13.4.2")
implementation("com.google.api-client:google-api-client:2.0.0")
implementation("com.google.oauth-client:google-oauth-client-jetty:1.34.1")
implementation("com.google.apis:google-api-services-sheets:v4-rev20220927-2.0.0")
// ....

2. Przed Tobą najbardziej nieprzyjemna część (mam nadzieję, że nie ochłodzi Twojego zapału)

  • Stwórz nowy projekt w Google Cloud Console:

  • Create OAuth client ID:
    • wybierz aplikację na komputer
    • zapisz JSON jako plik credentials.json w katalogu zasobów głównych projektu
  • Dodaj ten kod, aby łatwo autoryzować swój projekt do manipulowania arkuszami kalkulacyjnymi na Twoim koncie Google
object SheetsService {

    private val httpTransport by lazy { GoogleNetHttpTransport.newTrustedTransport() }
    private val jsonFactory: JsonFactory by lazy { GsonFactory.getDefaultInstance() }
    private val credential by lazy { credential(httpTransport) }

    private val sheets: Sheets by lazy {
        Sheets.Builder(httpTransport, jsonFactory, credential)
            .setApplicationName("SheetsDSL")
            .build()
    }

    private fun credential(httpTransport: NetHttpTransport): Credential {
        val credentialUrl = Resources.getResource(
            SheetsService::class.java, "/credentials.json"
        )

        val clientSecrets = GoogleClientSecrets.load(
            jsonFactory, credentialUrl.openStream().bufferedReader()
        )

        val flow = GoogleAuthorizationCodeFlow.Builder(
            httpTransport, jsonFactory, clientSecrets, listOf(SPREADSHEETS)
        )
            .setDataStoreFactory(FileDataStoreFactory(File("tokens")))
            .setAccessType("offline")
            .build()

        val receiver = LocalServerReceiver.Builder().setPort(8888).build()

        return AuthorizationCodeInstalledApp(flow, receiver).authorize("user")
    }
}

3. Testy dymne (ang. smoke tests) – spróbuj utworzyć pusty arkusz kalkulacyjny

  • Dodaj tę metodę do obiektu SheetService:
fun create(spreadsheet: Spreadsheet): Spreadsheet =
    sheets.spreadsheets().create(spreadsheet).execute()

Pozwoli nam to szybko zainicjować klasę Sheets – rdzeń API Arkuszy Google do manipulacji arkuszami kalkulacyjnymi.

  • Uruchom:
fun main() {
    print(SheetsService.create(Spreadsheet()))
}
  • Przy pierwszym uruchomieniu zostaniesz poproszony o zezwolenie SheetDSL na manipulowanie plikami. Co zrobić? Po prostu kliknij „advanced” i kontynuuj 😈, ponieważ ostrzeżenie może się pojawić ze względu na fakt, że nie zakończyliśmy procesu weryfikacji. Jeśli ten proces się powiedzie, informacje autoryzacyjne dla przyszłych wywołań API zostaną zapisane w folderze „tokens”.
  • Sprawdź, czy właśnie utworzony arkusz kalkulacyjny został wydrukowany w konsoli.

Implementacja

  • Wybrałem naiwne, ale tzw. kuloodporne podejście do tworzenia arkuszy kalkulacyjnych. Zobacz, co możesz po kolei zrobić.
    1. Modeluj arkusz kalkulacyjny, korzystając z pomocniczych klas i funkcji DSL (podobnie jak pokazano w części Oczekiwany rezultat)
    2. Zbuduj z niego nową instancję klasy arkusz kalkulacyjnego z API Google’a
    3. Wywołaj funkcję API na ścieżce create z wynikiem poprzedniego kroku jako argumentem
  • Aby odróżnić nowe klasy od istniejących w importowanych bibliotekach, do ich nazw dodałem końcówkę Dsl, np. SheetDslCellDsl.
  • Mocno zainspirowane przez bibliotekę ExcelDSL, czyli dostosowywanie elementów arkusza kalkulacyjnego odbywa się poprzez uruchomienie dostarczonej funkcji init w ich kontekście.
class RowDsl {

    fun cell(value: String, init: CellDsl.() -> Unit): CellDsl {
        val cellDsl = CellDsl(value)
        cellDsl.init()
        return cellDsl
    }

    // ...
}
  • Wywołanie metody „build” w klasie modelującej encję Arkuszy Google zwraca jej nową instancję.

Google Sheets API

Dla lepszego zrozumienia struktury klas przygotowałem uproszczony wykres UML:

Podstawowe rozwiązanie

Aby spełnić wymagania opisane w części Oczekiwany rezultat, moja implementacja będzie przebiegać zgodnie z podejściem bottom-up.

  1. Model CellData:
class CellDsl(
    private val value: String? = null,
) {
    internal fun build() = CellData().apply {
        userEnteredValue = when (value) {
            null, "" -> null
            else -> ExtendedValue().setStringValue(value.toString())
        }
    }
}

2. Model RowData wraz z metodami do tworzenia komórek:

class RowDsl(val height: Int? = null) {

    private val cells = mutableListOf<CellDsl>()

    fun cell(value: String = "", init: CellDsl.() -> Unit = {}) =
        CellDsl(value).apply(init).also { cells += it }

    fun emptyCell(count: Int = 1) {
        if (count < 1) return
        repeat(count) { cell() }
    }

    internal fun build() = RowData().apply {
        setValues(cells.map { it.build() })
    }
}

3. Prosty model Sheet:

class SheetDsl {

    private val rows = mutableListOf<RowDsl>()

    fun row(rowCount: Int = 1, init: RowDsl.() -> Unit = {}) {
        repeat(rowCount) {
            rows += RowDsl().apply(init)
        }
    }

    fun build() = Sheet().apply {
        data = listOf(GridData().apply {
            rowData = rows.map { it.build() }
        })
    }
}

4. Znajdująca się najwyżej w hierarchii klasa Spreadsheet:

class SpreadsheetDsl {

    private val sheetList = mutableListOf<SheetDsl>()

    fun sheet(init: SheetDsl.() -> Unit = {}) {
        sheetList += SheetDsl().apply(init)
    }

    internal fun build() = Spreadsheet().apply {
        sheets = sheetList.map { it.build() }
    }
}

fun spreadsheet(
    init: SpreadsheetDsl.() -> Unit,
): Spreadsheet =
    SheetsService.create(SpreadsheetDsl().apply(init).build())

Spełnia to zadany wymóg i pozwala na podstawowe tworzenie arkuszy kalkulacyjnych. Mam jednak nadzieję, że Twoje ambicje są znacznie większe i będziesz chętnie zgłębiać bardziej zaawansowane koncepcje. Dokładnie opisałem je w dalszej części artykułu.

Złożony przykład

Po co przygotowałem bardziej złożony przykład? Aby pokazać elastyczność Kotlina i różnorodność sposobów projektowania DSL. Oto on:

al spreadsheet = spreadsheet("Generated Spreadsheet") {
    sheet("First") {
        row {
            cell("Bold Roboto") {
                font {
                    fontFamily = "Roboto"
                    bold = true
                }
            }

            cell("White on red and aligned to right") {
                font {
                    color = WHITE
                }
            } bg RED align RIGHT

            +"Am I rotated?" % 45
        }

        columnWidth(1, 240)

        row(cellCount = 2) {
            cell("4 * 84 =") {
                horizontalAlignment = RIGHT
            }
            cellFormula("= 4 * 84") {
                backgroundColor = GRAY
            }
        }

        row()

        row {
            emptyCell()
            cell(4234.234) {
                border(DOTTED, BLUE, TOP_BOTTOM)
            }
        }
    }

    sheet("second - empty")

    sheet {
        columnWidth(0..2, 150)
        columnWidth(3, 60)

        row(cellCount = 3) {
            cell("thin") {
                font {
                    bold = true
                }
                horizontalAlignment = CENTER
            }
        }

        row(2)

        row(height = 63) {
            cell("tall") {
                font {
                    bold = true
                }
            } align CENTER vAlign MIDDLE
        }
    }
}

Tworzy on:

Wyjaśnienie zastosowanych pojęć

Funkcje wyższego rzędu (ang. Higher Order Functions) (docs)

Funkcja wyższego rzędu przyjmuje funkcje jako parametry lub zwraca funkcję.

Dzięki obsłudze tego rodzaju funkcji i funkcji notacji lambdy poza nawiasami możemy komponować części kodu DSL w ten elegancki sposób:

val spreadsheet = spreadsheet("Generated Spreadsheet") {
    sheet("First") {
        row {
            cell("Bold Roboto") {
                font {
                    fontFamily = "Roboto"
                    bold = true
                }
            }

            // ...
        }
    }
}

Przeciążenie operatora (ang. Operator Overloading) (docs)

Zanim poznałem Kotlina, byłem dość sceptycznie nastawiony, co do przeciążania operatorów. Właściwie nadal nie jestem przekonany, że jest to dobre podejście do kodowania. Nie mogę się jednak oprzeć tej uroczej i przydatnej składni 😍.

row {
    // initiates a cell with rotated text
    // using two operator overloads
    +"Am I rotated?" % 45
}
class RowDsl {
    // ...
    operator fun String.unaryPlus() = cell(this)
}
operator fun CellDsl.rem(angle: Int) = this.apply {
    rotation = angle
}

Lambda z odbiornikiem (ang. Lambda with Receiver) (docs)

Ta funkcja pozwala w łatwy sposób uruchamiać niestandardowe metody w określonym kontekście instancji. Na tym opierają się nasze metody init:

class RowDsl {

    private val cells = mutableListOf<CellDsl>()

    fun cell(value: String = "", init: CellDsl.() -> Unit = {}) =
        CellDsl(value).apply(init).also { cells += it }

    fun cell(value: Number, init: CellDsl.() -> Unit = {}) =
        CellDsl(value).apply(init).also { cells += it }

    // ...
}

Funkcje rozszerzające (ang. Extension Functions) (docs)

Dzięki nim możemy zdefiniować dodatkową metodę nawet dla obcej klasy, nad którą nie mamy kontroli.

fun Spreadsheet.openInBrowser() {
    Desktop.getDesktop().browse(URI.create(checkNotNull(spreadsheetUrl) {
        "Missing spreadsheetUrl!"
    }))
}

Notacja infiksowa (ang. Infix Notation) (docs)

Dość zaawansowana możliwość definiowania składni. Tutaj używany do zmiany tła komórki i wyrównania tekstu.

cell("White on red and aligned to right") {
    font {
        color = WHITE
    }
} bg RED align RIGHT
infix fun CellDsl.bg(color: Color) = this.apply {
    backgroundColor = color
}

infix fun CellDsl.align(horizontalAlignment: HorizontalAlignmentDsl) = this.also {
    it.horizontalAlignment = horizontalAlignment
}

Kontrola kontekstu (ang. Context Control) (docs)

Podczas tworzenia drzewa arkusza kalkulacyjnego wywołujemy metody w wielu zagnieżdżonych kontekstach. Może to doprowadzić nas do napisania czegoś takiego:

row {
    cell("1 - I'm OK") {
        cell("2 - Where am I? Next to the first cell or inside it?")
    }
}

IDE i kompilator nie będą się z nami kłócić. Nawet kod będzie działał bez wyjątku! Mam jednak nadzieję, że zgodzisz się ze mną, że taka składnia komplikuje kod. Co więcej – łamie to prostotę i czytelność kodu, a więc główne zalety korzystania z DSL. Bez obaw, Kotlin ma na to lekarstwo! 💉

Najpierw zdefiniujemy adnotację dla naszego DSL z zastosowanym @DslMarker

@DslMarker
annotation class SheetsDslMarker

i oznaczamy nasze klasy adnotacją:

@SheetsDslMarker
class SheetDsl
// ...

@SheetsDslMarker
class CellDsl
// ...

Następnie IDE i kompilator zabronią nam wywoływania metody kontekstów nadrzędnych, dzięki czemu kod będzie łatwy do zrozumienia.

Podsumowanie

Projektowanie nowego DSL osadzonego w tak dobrze skonstruowanym języku jak Kotlin było dla mnie bardzo ekscytujące i przyjemne. Mam nadzieję, że ten tekst zachęci Cię do samodzielnych eksperymentów. Nadal nie mam wystarczającego doświadczenia, aby jasno stwierdzić, czy języki DSL w Kotlinie zwiększają jakość kodu i można je utrzymywać między modułami, zespołami lub kolejnymi wersjami. Ocenę pozostawiam Tobie.

Forma tego artykułu wymagała ode mnie kilku uproszczeń i selektywnego doboru tematów. Mam nadzieję, że poniższe odniesienia i ciekawe przykłady Kotlin DSL uznasz za obszerne i przydatne uzupełnienie mojego tekstu. Prezentowany kod znajdziesz w tym repozytorium GitHub.

Odnośniki

Dokumentacja

Inne artykuły

DSL examples

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami